add connections section, unlink, relink

This commit is contained in:
Julian 2023-04-27 15:33:53 -04:00
parent 91e54793ff
commit d246e39211
Signed by untrusted user: NotNite
GPG key ID: BD91A5402CCEB08A
13 changed files with 309 additions and 24 deletions

View file

@ -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 }));
}

View file

@ -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 });
}

View file

@ -23,4 +23,50 @@
border-radius: 0.15rem;
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;
}

View file

@ -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} />

View file

@ -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} />;
}

View file

@ -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();

View file

@ -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;

View file

@ -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>

View file

@ -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")

View file

@ -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;

View file

@ -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;
}

View file

@ -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`;
}

View file

@ -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;
}
}