From d246e392113f8cdf402f677d920ecc22ddc973c2 Mon Sep 17 00:00:00 2001 From: NotNite Date: Thu, 27 Apr 2023 15:33:53 -0400 Subject: [PATCH] add connections section, unlink, relink --- src/app/api/login/route.ts | 2 +- src/app/api/unlink/route.ts | 78 ++++++++++++++++++++++++ src/app/me/AboutMe.module.css | 48 ++++++++++++++- src/app/me/AboutMe.tsx | 68 +++++++++++++++++++-- src/app/me/page.tsx | 24 +++++++- src/app/oauth/github/redirect/route.ts | 7 +-- src/app/register/RegisterForm.module.css | 8 +++ src/app/register/RegisterForm.tsx | 23 ++++--- src/app/register/page.tsx | 3 +- src/auth/AuthProvider.ts | 7 +++ src/auth/auth.ts | 24 +++++++- src/auth/discord.ts | 14 ++++- src/auth/github.ts | 27 +++++++- 13 files changed, 309 insertions(+), 24 deletions(-) create mode 100644 src/app/api/unlink/route.ts diff --git a/src/app/api/login/route.ts b/src/app/api/login/route.ts index c22183b..b9565fb 100644 --- a/src/app/api/login/route.ts +++ b/src/app/api/login/route.ts @@ -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 })); } diff --git a/src/app/api/unlink/route.ts b/src/app/api/unlink/route.ts new file mode 100644 index 0000000..4a03828 --- /dev/null +++ b/src/app/api/unlink/route.ts @@ -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 }); +} diff --git a/src/app/me/AboutMe.module.css b/src/app/me/AboutMe.module.css index 71ebbf7..2b2130c 100644 --- a/src/app/me/AboutMe.module.css +++ b/src/app/me/AboutMe.module.css @@ -23,4 +23,50 @@ border-radius: 0.15rem; cursor: pointer; padding: 0.5em 1em; -} \ No newline at end of file +} + +/* 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; +} diff --git a/src/app/me/AboutMe.tsx b/src/app/me/AboutMe.tsx index 0a88b4f..206009c 100644 --- a/src/app/me/AboutMe.tsx +++ b/src/app/me/AboutMe.tsx @@ -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(); + const inputRef = React.useRef(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 ( + + ); +} + +function AuthProviderEntry({ provider }: { provider: AuthProviderState }) { + return ( + <> +

+ {provider.name}:{" "} + {provider.connected ? provider.username : "Not connected"} +

+ + + + ); +} + +export default function AboutMe({ + info, + providers +}: { + info: UserInfo; + providers: AuthProviderState[]; +}) { const [globalError, setGlobalError] = React.useState(null); const [madeProfileChanges, setMadeChanges] = React.useState(false); const [madePasswordChanges, setMadePasswordChanges] = React.useState(false); @@ -229,9 +288,10 @@ export default function AboutMe({ info }: { info: UserInfo }) {

Connections

-
-

discord: {info.discordId}

-

github: {info.githubId}

+
+ {providers.map((provider) => ( + + ))}

diff --git a/src/app/me/page.tsx b/src/app/me/page.tsx index 0e7bb70..7dfa5a3 100644 --- a/src/app/me/page.tsx +++ b/src/app/me/page.tsx @@ -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 ; + 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 ; } diff --git a/src/app/oauth/github/redirect/route.ts b/src/app/oauth/github/redirect/route.ts index e61a8ff..2cd4bd6 100644 --- a/src/app/oauth/github/redirect/route.ts +++ b/src/app/oauth/github/redirect/route.ts @@ -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(); diff --git a/src/app/register/RegisterForm.module.css b/src/app/register/RegisterForm.module.css index f3b0ba8..f6fca1f 100644 --- a/src/app/register/RegisterForm.module.css +++ b/src/app/register/RegisterForm.module.css @@ -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; diff --git a/src/app/register/RegisterForm.tsx b/src/app/register/RegisterForm.tsx index f878614..0ac01c9 100644 --- a/src/app/register/RegisterForm.tsx +++ b/src/app/register/RegisterForm.tsx @@ -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(null); const router = useRouter(); @@ -105,7 +106,6 @@ export default function RegisterForm({ label="Username" placeholder="julian" /> - - - - - )} /> -
+ + { + await fetch("/api/unlink", { method: "POST" }); + document.cookie = + "ticket=; expires=" + new Date().toUTCString() + "; path=/"; + window.location.href = "/"; + }} + > + {`Didn't mean to sign up?`} + )} diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx index 7b9165b..a2ef87c 100644 --- a/src/app/register/page.tsx +++ b/src/app/register/page.tsx @@ -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") diff --git a/src/auth/AuthProvider.ts b/src/auth/AuthProvider.ts index 1fe81eb..ae5f6bf 100644 --- a/src/auth/AuthProvider.ts +++ b/src/auth/AuthProvider.ts @@ -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; diff --git a/src/auth/auth.ts b/src/auth/auth.ts index 628675f..b3b0853 100644 --- a/src/auth/auth.ts +++ b/src/auth/auth.ts @@ -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; } diff --git a/src/auth/discord.ts b/src/auth/discord.ts index 9d0d745..7859655 100644 --- a/src/auth/discord.ts +++ b/src/auth/discord.ts @@ -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 { + 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`; } diff --git a/src/auth/github.ts b/src/auth/github.ts index 8460109..66c703e 100644 --- a/src/auth/github.ts +++ b/src/auth/github.ts @@ -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 { + 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; } }