add connections section, unlink, relink
This commit is contained in:
parent
91e54793ff
commit
d246e39211
|
@ -25,5 +25,5 @@ export async function POST(request: Request) {
|
|||
|
||||
const [_, ticket] = await authTicketLogin(username);
|
||||
// not confident if we can set-cookie and I cba to try
|
||||
return new Response(JSON.stringify({ ok: true, ticket }));
|
||||
return new Response(JSON.stringify({ ok: true, ticket: ticket.ticket }));
|
||||
}
|
||||
|
|
|
@ -0,0 +1,78 @@
|
|||
import {
|
||||
AuthState,
|
||||
getAuthState,
|
||||
getRegisteringUser,
|
||||
getUser
|
||||
} from "@/auth/auth";
|
||||
import prisma from "@/prisma";
|
||||
|
||||
async function unlinkDiscord(id: string) {
|
||||
await prisma.discordAuth.delete({
|
||||
where: {
|
||||
id
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function unlinkGitHub(id: string) {
|
||||
await prisma.gitHubAuth.delete({
|
||||
where: {
|
||||
id
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteUser(id: number) {
|
||||
await prisma.authTicket.deleteMany({
|
||||
where: {
|
||||
userId: id
|
||||
}
|
||||
});
|
||||
|
||||
await prisma.user.delete({
|
||||
where: {
|
||||
id
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authState = await getAuthState();
|
||||
|
||||
if (authState == AuthState.Registering) {
|
||||
const registeringUser = (await getRegisteringUser())!;
|
||||
if (registeringUser.discordAuth !== null)
|
||||
await unlinkDiscord(registeringUser.discordAuth.id);
|
||||
|
||||
if (registeringUser.githubAuth !== null)
|
||||
await unlinkGitHub(registeringUser.githubAuth.id);
|
||||
|
||||
await deleteUser(registeringUser.id);
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
|
||||
const user = await getUser();
|
||||
if (user == null) return new Response(null, { status: 401 });
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const provider = searchParams.get("provider");
|
||||
switch (provider) {
|
||||
case "discord":
|
||||
const discord = await user.getDiscord();
|
||||
if (discord == null) return new Response(null, { status: 400 });
|
||||
await unlinkDiscord(await discord.getId());
|
||||
break;
|
||||
|
||||
case "github":
|
||||
const github = await user.getGitHub();
|
||||
if (github == null) return new Response(null, { status: 400 });
|
||||
await unlinkGitHub(await github.getId());
|
||||
break;
|
||||
|
||||
default:
|
||||
return new Response(null, { status: 400 });
|
||||
}
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
|
@ -24,3 +24,49 @@
|
|||
cursor: pointer;
|
||||
padding: 0.5em 1em;
|
||||
}
|
||||
|
||||
/* stolen from prettyform */
|
||||
.fancyInput {
|
||||
background: var(--bg-dark);
|
||||
border: 0;
|
||||
border-radius: 0.15rem;
|
||||
cursor: pointer;
|
||||
padding: 0.5em 1em;
|
||||
}
|
||||
|
||||
.authProviderList {
|
||||
display: grid;
|
||||
grid-template-columns: max-content min-content;
|
||||
/* padding */
|
||||
grid-gap: 0.5rem;
|
||||
}
|
||||
|
||||
.authProviderList p {
|
||||
/* flex spam is fun */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* the !importants here piss me off but it wouldn't accept the property otherwise */
|
||||
.progress {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--fg-darker) 50%,
|
||||
var(--bg-dark) 50%
|
||||
) !important;
|
||||
background-size: 200% 100% !important;
|
||||
background-position: right bottom !important;
|
||||
transition: all 0s linear !important;
|
||||
|
||||
border: 0;
|
||||
border-radius: 0.15rem;
|
||||
cursor: pointer;
|
||||
padding: 0.5em 1em;
|
||||
}
|
||||
|
||||
/* when clicked */
|
||||
.progress:active {
|
||||
transition: all 3s linear !important;
|
||||
background-position: left bottom !important;
|
||||
}
|
||||
|
|
|
@ -15,6 +15,8 @@ import {
|
|||
} from "@/schemas";
|
||||
import PrettyForm from "@/components/PrettyForm";
|
||||
import Toast from "@/components/Toast";
|
||||
import { AuthProviderState } from "@/auth/AuthProvider";
|
||||
import { exec } from "child_process";
|
||||
|
||||
type UpdateResponse = {
|
||||
ok: boolean;
|
||||
|
@ -34,7 +36,64 @@ async function fileAsBase64(f: File) {
|
|||
});
|
||||
}
|
||||
|
||||
export default function AboutMe({ info }: { info: UserInfo }) {
|
||||
function AuthProviderButton({ provider }: { provider: AuthProviderState }) {
|
||||
// bullshit hack
|
||||
const holdTime = provider.connected ? 3000 : 0;
|
||||
const interval = React.useRef<NodeJS.Timeout | null>();
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const execute = async () => {
|
||||
const name = provider.name.toLowerCase();
|
||||
if (!provider.connected) {
|
||||
window.location.href = `/oauth/${name}/login`;
|
||||
} else {
|
||||
await fetch(`/api/unlink?provider=${name}`, { method: "POST" });
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
const mouseDown = () => {
|
||||
interval.current = setTimeout(execute, holdTime);
|
||||
};
|
||||
|
||||
const mouseUp = () => {
|
||||
if (interval.current) clearTimeout(interval.current);
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
type="submit"
|
||||
className={
|
||||
styles.fancyInput + " " + (provider.connected ? styles.progress : "")
|
||||
}
|
||||
onMouseDown={mouseDown}
|
||||
onMouseUp={mouseUp}
|
||||
value={provider.connected ? "Disconnect" : "Connect"}
|
||||
ref={inputRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthProviderEntry({ provider }: { provider: AuthProviderState }) {
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
{provider.name}:{" "}
|
||||
{provider.connected ? provider.username : "Not connected"}
|
||||
</p>
|
||||
|
||||
<AuthProviderButton provider={provider} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AboutMe({
|
||||
info,
|
||||
providers
|
||||
}: {
|
||||
info: UserInfo;
|
||||
providers: AuthProviderState[];
|
||||
}) {
|
||||
const [globalError, setGlobalError] = React.useState<string | null>(null);
|
||||
const [madeProfileChanges, setMadeChanges] = React.useState(false);
|
||||
const [madePasswordChanges, setMadePasswordChanges] = React.useState(false);
|
||||
|
@ -229,9 +288,10 @@ export default function AboutMe({ info }: { info: UserInfo }) {
|
|||
|
||||
<hr className={styles.divider} />
|
||||
<h2 className={styles.header}>Connections</h2>
|
||||
<div>
|
||||
<p>discord: {info.discordId}</p>
|
||||
<p>github: {info.githubId}</p>
|
||||
<div className={styles.authProviderList}>
|
||||
{providers.map((provider) => (
|
||||
<AuthProviderEntry provider={provider} key={provider.name} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<hr className={styles.divider} />
|
||||
|
|
|
@ -2,6 +2,20 @@ import { getUser } from "@/auth/auth";
|
|||
import { getUserInfo } from "@/ldap";
|
||||
import AboutMe from "./AboutMe";
|
||||
import { redirect } from "next/navigation";
|
||||
import { DiscordAuthProvider } from "@/auth/discord";
|
||||
import { GitHubAuthProvider } from "@/auth/github";
|
||||
import { AuthProviderState } from "@/auth/AuthProvider";
|
||||
|
||||
// this sucks but i'm lazy
|
||||
const discordFallback: AuthProviderState = {
|
||||
name: "Discord",
|
||||
connected: false
|
||||
};
|
||||
|
||||
const githubFallback: AuthProviderState = {
|
||||
name: "GitHub",
|
||||
connected: false
|
||||
};
|
||||
|
||||
export default async function Page() {
|
||||
const user = await getUser();
|
||||
|
@ -10,5 +24,13 @@ export default async function Page() {
|
|||
const info = await getUserInfo(user);
|
||||
if (info === null) redirect("/register");
|
||||
|
||||
return <AboutMe info={info} />;
|
||||
const discord = await user.getDiscord();
|
||||
const discordState = (await discord?.getState()) ?? discordFallback;
|
||||
const github = await user.getGitHub();
|
||||
const githubState = (await github?.getState()) ?? githubFallback;
|
||||
|
||||
const providers = [discordState, githubState];
|
||||
|
||||
// not sure how to feel about passing it like this
|
||||
return <AboutMe info={info} providers={providers} />;
|
||||
}
|
||||
|
|
|
@ -41,18 +41,17 @@ export async function GET(request: Request) {
|
|||
gluestickId ?? undefined
|
||||
);
|
||||
|
||||
const [user, authTicket] = await authTicketOAuth(userId);
|
||||
|
||||
if (user?.username !== null) {
|
||||
if (gluestickId != null) {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
"Set-Cookie": `ticket=${authTicket.ticket}; Path=/;`,
|
||||
Location: "/me"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const [user, authTicket] = await authTicketOAuth(userId);
|
||||
|
||||
const username = await provider.getDisplayName();
|
||||
const email = await provider.getEmail();
|
||||
const avatarUrl = await provider.getAvatar();
|
||||
|
|
|
@ -12,6 +12,14 @@
|
|||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bail {
|
||||
color: var(--fg-darker);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.buttonContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
|
|
@ -10,6 +10,7 @@ import Input from "@/components/Input";
|
|||
import PrettyForm from "@/components/PrettyForm";
|
||||
import HugeSubmit from "@/components/HugeSubmit";
|
||||
import AvatarChanger from "@/components/AvatarChanger";
|
||||
import { ValidAuthProvider } from "@/auth/AuthProvider";
|
||||
|
||||
type RegisterResponse = {
|
||||
ok: boolean;
|
||||
|
@ -25,7 +26,7 @@ export default function RegisterForm({
|
|||
initialDisplayName?: string;
|
||||
initialEmail?: string;
|
||||
initialAvatarBase64?: string;
|
||||
avatarSource: "Discord" | "GitHub" | null;
|
||||
avatarSource: ValidAuthProvider | null;
|
||||
}) {
|
||||
const [globalError, setGlobalError] = React.useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
@ -105,7 +106,6 @@ export default function RegisterForm({
|
|||
label="Username"
|
||||
placeholder="julian"
|
||||
/>
|
||||
|
||||
<Input
|
||||
hint="Your display name - this can be what you go by online, for example."
|
||||
type="text"
|
||||
|
@ -113,7 +113,6 @@ export default function RegisterForm({
|
|||
label="Display name"
|
||||
placeholder="NotNite"
|
||||
/>
|
||||
|
||||
<Input
|
||||
hint="Your email address. An inbox will be created on @n2.pm that forwards to this email."
|
||||
type="email"
|
||||
|
@ -121,7 +120,6 @@ export default function RegisterForm({
|
|||
label="Email"
|
||||
placeholder="hi@notnite.com"
|
||||
/>
|
||||
|
||||
<Input
|
||||
hint="Your password. To secure NotNet services, make this a strong and long password."
|
||||
type="password"
|
||||
|
@ -131,7 +129,6 @@ export default function RegisterForm({
|
|||
minLength={12}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
|
@ -139,10 +136,9 @@ export default function RegisterForm({
|
|||
placeholder="deeznuts47"
|
||||
minLength={12}
|
||||
/>
|
||||
|
||||
<Input
|
||||
hint={
|
||||
(avatarSource != null && avatarSource !== "Discord"
|
||||
(avatarSource != null
|
||||
? `We found your avatar from ${avatarSource}, but you can change it if you'd like.`
|
||||
: "") +
|
||||
" This will automatically be used as your avatar with supported services - maximum 1 MB. "
|
||||
|
@ -160,10 +156,21 @@ export default function RegisterForm({
|
|||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className={styles.buttonContainer}>
|
||||
<HugeSubmit value="Join NotNet!" disabled={isSubmitting} />
|
||||
</div>
|
||||
|
||||
<a
|
||||
className={styles.bail}
|
||||
onClick={async () => {
|
||||
await fetch("/api/unlink", { method: "POST" });
|
||||
document.cookie =
|
||||
"ticket=; expires=" + new Date().toUTCString() + "; path=/";
|
||||
window.location.href = "/";
|
||||
}}
|
||||
>
|
||||
{`Didn't mean to sign up?`}
|
||||
</a>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
|
|
|
@ -3,8 +3,9 @@ import styles from "@/app/page.module.css";
|
|||
import RegisterForm from "./RegisterForm";
|
||||
import { redirect, useRouter } from "next/navigation";
|
||||
import { ensureJpg } from "@/image";
|
||||
import { ValidAuthProvider } from "@/auth/AuthProvider";
|
||||
|
||||
function avatarUrlSource(url: URL): "Discord" | "GitHub" | null {
|
||||
function avatarUrlSource(url: URL): ValidAuthProvider | null {
|
||||
if (
|
||||
url.hostname === "cdn.discordapp.com" &&
|
||||
url.pathname.startsWith("/avatars")
|
||||
|
|
|
@ -1,3 +1,10 @@
|
|||
export type ValidAuthProvider = "Discord" | "GitHub";
|
||||
|
||||
// Can't send the providers across the wire, do this instead
|
||||
export type AuthProviderState = {
|
||||
name: string;
|
||||
} & ({ connected: false } | { connected: true; id: string; username: string });
|
||||
|
||||
export abstract class AuthProvider {
|
||||
protected readonly accessToken: string;
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ import { cookies } from "next/dist/client/components/headers";
|
|||
import { v4 } from "uuid";
|
||||
import * as ldap from "../ldap";
|
||||
import { getLogger } from "../logger";
|
||||
import { AuthTicket, User } from "@prisma/client";
|
||||
import { AuthTicket, DiscordAuth, User } from "@prisma/client";
|
||||
import { DiscordAuthProvider } from "./discord";
|
||||
import { GitHubAuthProvider } from "./github";
|
||||
import { AuthProvider } from "./AuthProvider";
|
||||
|
@ -97,6 +97,26 @@ async function getAuthTicket() {
|
|||
return ticket ?? null;
|
||||
}
|
||||
|
||||
export async function getRegisteringUser(ticket?: AuthTicket) {
|
||||
if (ticket == null) {
|
||||
let newTicket = await getAuthTicket();
|
||||
if (newTicket == null) return null;
|
||||
ticket = newTicket;
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: ticket.userId
|
||||
},
|
||||
include: {
|
||||
discordAuth: true,
|
||||
githubAuth: true
|
||||
}
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function getUser(ticket?: AuthTicket) {
|
||||
if (ticket == null) {
|
||||
let newTicket = await getAuthTicket();
|
||||
|
@ -147,7 +167,7 @@ export async function getAuthState() {
|
|||
if (ticket == null) return AuthState.LoggedOut;
|
||||
|
||||
const user = await getUser(ticket);
|
||||
if (user == null) return AuthState.Registering;
|
||||
if (user == null || user.isRegistering) return AuthState.Registering;
|
||||
|
||||
return AuthState.LoggedIn;
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { AuthProvider } from "./AuthProvider";
|
||||
import { AuthProvider, AuthProviderState } from "./AuthProvider";
|
||||
import prisma from "@/prisma";
|
||||
|
||||
export type DiscordAccessTokenResponse = {
|
||||
|
@ -77,6 +77,18 @@ export class DiscordAuthProvider extends AuthProvider {
|
|||
return me.email;
|
||||
}
|
||||
|
||||
async getState(): Promise<AuthProviderState> {
|
||||
const username = await this.getUsername();
|
||||
const id = await this.getId();
|
||||
|
||||
return {
|
||||
name: "Discord",
|
||||
connected: true,
|
||||
id,
|
||||
username
|
||||
};
|
||||
}
|
||||
|
||||
static get redirectUri(): string {
|
||||
return `${process.env.BASE_DOMAIN}oauth/discord/redirect`;
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { AuthProvider } from "./AuthProvider";
|
||||
import { AuthProvider, AuthProviderState } from "./AuthProvider";
|
||||
import prisma from "@/prisma";
|
||||
|
||||
export type GitHubAccessTokenResponse = {
|
||||
|
@ -63,6 +63,18 @@ export class GitHubAuthProvider extends AuthProvider {
|
|||
return me.email;
|
||||
}
|
||||
|
||||
async getState(): Promise<AuthProviderState> {
|
||||
const username = await this.getUsername();
|
||||
const id = await this.getId();
|
||||
|
||||
return {
|
||||
name: "GitHub",
|
||||
connected: true,
|
||||
id,
|
||||
username
|
||||
};
|
||||
}
|
||||
|
||||
static get redirectUri(): string {
|
||||
return `${process.env.BASE_DOMAIN}oauth/github/redirect`;
|
||||
}
|
||||
|
@ -111,6 +123,19 @@ export class GitHubAuthProvider extends AuthProvider {
|
|||
}
|
||||
});
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: a.userId
|
||||
},
|
||||
data: {
|
||||
githubAuth: {
|
||||
connect: {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return a.userId;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue