Loading packages/client/src/components/Login/Normal.tsx +15 −4 Original line number Diff line number Diff line Loading @@ -3,13 +3,16 @@ import { type DialogData } from "../../contexts/DialogContext"; import { useCallback, useState } from "react"; import { RECOMMENDED_INSTANCES } from "../../lib/constants"; import { Button } from "../core/Button"; import { ProgrammingDevSignup } from "./ProgrammingDevSignup"; export const Normal = ({ state, }: { state: DialogData<"LOGIN_INFO"> & { mode: "NORMAL" }; }) => { const [step, setStep] = useState<"WELCOME" | "NEW">("WELCOME"); const [step, setStep] = useState< "WELCOME" | "NEW" | "PROGRAMMING_DEV_SIGNUP" >("WELCOME"); const WelcomeStep = useCallback( () => ( Loading Loading @@ -62,7 +65,7 @@ export const Normal = ({ </p> </> ), [] [], ); const NewStep = useCallback( Loading Loading @@ -96,9 +99,15 @@ export const Normal = ({ <strong>Return to this page after registering an account</strong> </> ), [] [], ); if (step === "PROGRAMMING_DEV_SIGNUP") { return ( <ProgrammingDevSignup loginUrl={state.url} onBack={() => setStep("NEW")} /> ); } return ( <> <ModalBody> Loading @@ -108,7 +117,9 @@ export const Normal = ({ <ModalFooter> {step === "WELCOME" && ( <> <Button onPress={() => setStep("NEW")}>I'm new here</Button> <Button onPress={() => setStep("PROGRAMMING_DEV_SIGNUP")}> I'm new here </Button> <Button as={Link} href={state.url}> I'm already a part of the Fediverse </Button> Loading packages/client/src/components/Login/ProgrammingDevSignup.tsx 0 → 100644 +549 −0 Original line number Diff line number Diff line import { Alert, Input, Link, ModalBody, ModalFooter, Spinner } from "@heroui/react"; import { type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Button } from "../core/Button"; type SignupView = | "FORM" | "REGISTERING" | "WAITING_ACCOUNT_CREATION" | "SENDING_HANDOFF" | "WAITING_HANDOFF_REPLY" | "REDIRECTING" | "ERROR" | "TIMED_OUT"; type MetadataResponse = { success: true; instance: { captchaEnabled: boolean; registrationMode?: string; requireEmailVerification: boolean; }; handoff: { enabled: boolean; account?: string; }; }; type Captcha = { uuid: string; png: string; wav?: string; }; type CaptchaResponse = { success: true; captcha: Captcha; }; type RegisterResponse = { success: true; state: "ACCOUNT_READY" | "WAITING_ACCOUNT_CREATION"; }; type CheckResponse = | { success: true; state: "WAITING_ACCOUNT_CREATION" | "WAITING_HANDOFF_REPLY"; } | { success: true; state: "HANDOFF_READY"; redirect: string; }; type ErrorResponse = { success: false; error?: string; error_message?: string; }; const POLL_INTERVALS_MS = [3_000, 5_000, 10_000, 20_000, 30_000]; const POLL_TIMEOUT_MS = 15 * 60 * 1_000; export const ProgrammingDevSignup = ({ loginUrl, onBack, }: { loginUrl: string; onBack: () => void; }) => { const [view, setView] = useState<SignupView>("FORM"); const [metadata, setMetadata] = useState<MetadataResponse>(); const [captcha, setCaptcha] = useState<Captcha>(); const [loadingMetadata, setLoadingMetadata] = useState(true); const [loadingCaptcha, setLoadingCaptcha] = useState(false); const [error, setError] = useState<string>(); const [registered, setRegistered] = useState(false); const [username, setUsername] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [passwordVerify, setPasswordVerify] = useState(""); const [captchaAnswer, setCaptchaAnswer] = useState(""); const passwordRef = useRef(password); const pollTimer = useRef<number | undefined>(undefined); const pollAttempt = useRef(0); const pollStartedAt = useRef<number | undefined>(undefined); useEffect(() => { passwordRef.current = password; }, [password]); const clearPollTimer = useCallback(() => { if (typeof pollTimer.current !== "undefined") { window.clearTimeout(pollTimer.current); pollTimer.current = undefined; } }, []); const blockingProblem = useMemo(() => { if (!metadata) return undefined; if (metadata.handoff.enabled !== true || !metadata.handoff.account) { return "Fediverse Auth handoff is not available right now."; } if (metadata.instance.registrationMode !== "RequireApplication") { return "programming.dev changed its registration flow, so automatic signup is paused."; } if (metadata.instance.requireEmailVerification) { return "programming.dev now requires email verification, so this signup flow needs to be updated first."; } if (!metadata.instance.captchaEnabled) { return "programming.dev changed its captcha settings, so automatic signup is paused."; } return undefined; }, [metadata]); const reloadCaptcha = useCallback(async () => { setLoadingCaptcha(true); setError(undefined); try { const data = await requestJson<CaptchaResponse>( "/api/signup/programming-dev/captcha", ); setCaptcha(data.captcha); setCaptchaAnswer(""); } catch (e) { setError(getErrorMessage(e, "Could not load captcha.")); setView("ERROR"); } finally { setLoadingCaptcha(false); } }, []); useEffect(() => { let cancelled = false; const load = async () => { setLoadingMetadata(true); setError(undefined); try { const data = await requestJson<MetadataResponse>( "/api/signup/programming-dev/metadata", ); if (cancelled) return; setMetadata(data); if (data.instance.captchaEnabled) { await reloadCaptcha(); } } catch (e) { if (!cancelled) { setError(getErrorMessage(e, "Could not load signup metadata.")); setView("ERROR"); } } finally { if (!cancelled) setLoadingMetadata(false); } }; void load(); return () => { cancelled = true; clearPollTimer(); void fetch("/api/signup/programming-dev", { method: "DELETE" }); }; }, [clearPollTimer, reloadCaptcha]); const pollCheck = useCallback(async () => { clearPollTimer(); if (!pollStartedAt.current) pollStartedAt.current = Date.now(); if (Date.now() - pollStartedAt.current > POLL_TIMEOUT_MS) { setPassword(""); setView("TIMED_OUT"); return; } if (!passwordRef.current) { setView("TIMED_OUT"); return; } setView((current) => current === "WAITING_ACCOUNT_CREATION" || current === "WAITING_HANDOFF_REPLY" ? current : "SENDING_HANDOFF", ); try { const data = await requestJson<CheckResponse>( "/api/signup/programming-dev/check", { method: "POST", body: JSON.stringify({ password: passwordRef.current }), }, ); if (data.state === "HANDOFF_READY") { clearPollTimer(); setView("REDIRECTING"); window.location.assign(data.redirect); return; } setView(data.state); const attempt = pollAttempt.current++; const delay = attempt < POLL_INTERVALS_MS.length ? POLL_INTERVALS_MS[attempt] : 60_000; pollTimer.current = window.setTimeout(() => void pollCheck(), delay); } catch (e) { setError(getErrorMessage(e, "Could not check the account yet.")); setView("ERROR"); } }, [clearPollTimer]); const startPolling = useCallback(() => { pollAttempt.current = 0; pollStartedAt.current = Date.now(); void pollCheck(); }, [pollCheck]); const cancelSignup = useCallback(() => { clearPollTimer(); void fetch("/api/signup/programming-dev", { method: "DELETE" }); onBack(); }, [clearPollTimer, onBack]); const submitRegister = useCallback( async (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); if (blockingProblem) { setError(blockingProblem); setView("ERROR"); return; } if (password !== passwordVerify) { setError("Passwords do not match."); setView("ERROR"); return; } if (!captcha) { setError("Captcha is not ready yet."); setView("ERROR"); return; } setView("REGISTERING"); setError(undefined); try { const data = await requestJson<RegisterResponse>( "/api/signup/programming-dev/register", { method: "POST", body: JSON.stringify({ username, email: email || undefined, password, passwordVerify, captchaUuid: captcha.uuid, captchaAnswer, }), }, ); setRegistered(true); setView( data.state === "ACCOUNT_READY" ? "SENDING_HANDOFF" : "WAITING_ACCOUNT_CREATION", ); startPolling(); } catch (e) { setError(getErrorMessage(e, "Could not create the account.")); setView("ERROR"); void reloadCaptcha(); } }, [ blockingProblem, captcha, captchaAnswer, email, password, passwordVerify, reloadCaptcha, startPolling, username, ], ); const resumePolling = useCallback(() => { if (!password) { setError("Enter the password for the programming.dev account to keep checking."); return; } setError(undefined); setView("SENDING_HANDOFF"); startPolling(); }, [password, startPolling]); const busy = view === "REGISTERING" || view === "SENDING_HANDOFF" || view === "WAITING_ACCOUNT_CREATION" || view === "WAITING_HANDOFF_REPLY" || view === "REDIRECTING"; return ( <> <ModalBody className="gap-4"> <div className="flex flex-col gap-1"> <h1 className="text-2xl font-bold">Create a Fediverse account</h1> <p className="text-sm text-default-600"> Canvas will create a programming.dev account, then login through Fediverse Auth. </p> </div> {loadingMetadata && ( <div className="flex items-center gap-2 text-sm text-default-600"> <Spinner size="sm" /> Loading programming.dev signup... </div> )} {error && <Alert color="danger" title="Signup error" description={error} />} {blockingProblem && !error && ( <Alert color="warning" title="Signup unavailable" description={blockingProblem} /> )} {(view === "FORM" || (!registered && view === "ERROR") || view === "REGISTERING") && ( <form className="flex flex-col gap-3" onSubmit={submitRegister}> <Input label="Username" value={username} onValueChange={setUsername} minLength={3} maxLength={50} isRequired isDisabled={busy} autoComplete="username" /> <Input label="Email" type="email" value={email} onValueChange={setEmail} isDisabled={busy} autoComplete="email" /> <Input label="Password" type="password" value={password} onValueChange={setPassword} minLength={10} isRequired isDisabled={busy} autoComplete="new-password" /> <Input label="Confirm password" type="password" value={passwordVerify} onValueChange={setPasswordVerify} minLength={10} isRequired isDisabled={busy} autoComplete="new-password" /> <div className="flex flex-col gap-2"> <div className="flex items-center gap-3"> {captcha ? ( <img className="h-24 w-auto max-w-full" src={`data:image/png;base64,${captcha.png}`} alt="programming.dev captcha" /> ) : ( <div className="flex h-14 min-w-40 items-center justify-center rounded border border-default-200 text-sm text-default-500"> {loadingCaptcha ? "Loading captcha..." : "Captcha unavailable"} </div> )} <Button type="button" onPress={reloadCaptcha} isLoading={loadingCaptcha} isDisabled={busy} > Reload </Button> {captcha?.wav && ( <Link href={`data:audio/wav;base64,${captcha.wav}`} target="_blank"> Audio </Link> )} </div> <Input label="Captcha answer" value={captchaAnswer} onValueChange={setCaptchaAnswer} isRequired isDisabled={busy || !captcha} autoComplete="off" /> </div> <div className="flex justify-end gap-2 pt-1"> <Button type="button" onPress={cancelSignup}> Sign up with another instance </Button> <Button type="submit" color="primary" isLoading={view === "REGISTERING"} isDisabled={Boolean(blockingProblem) || loadingMetadata || loadingCaptcha} > Create account </Button> </div> </form> )} {busy && view !== "REGISTERING" && ( <div className="flex items-center gap-3"> <Spinner size="sm" /> <strong>{getBusyTitle(view)}</strong> </div> )} {view === "ERROR" && registered && ( <div className="flex flex-col gap-3"> <Input label="Password" type="password" value={password} onValueChange={setPassword} isRequired autoComplete="current-password" /> <div className="flex justify-end gap-2"> <Button onPress={cancelSignup}>Cancel</Button> <Button color="primary" onPress={resumePolling}> Retry handoff </Button> </div> </div> )} {view === "TIMED_OUT" && ( <div className="flex flex-col gap-3"> <Alert color="warning" title="Still waiting" description="Canvas has been checking for 15 minutes. Enter the programming.dev password again to resume polling." /> <Input label="Password" type="password" value={password} onValueChange={setPassword} isRequired autoComplete="current-password" /> <div className="flex justify-end gap-2"> <Button onPress={cancelSignup}>Cancel</Button> <Button color="primary" onPress={resumePolling}> Resume waiting </Button> </div> </div> )} </ModalBody> <ModalFooter className={busy ? "justify-end" : "justify-between"}> {!busy && ( <Button as={Link} href={loginUrl}> I already have an account </Button> )} {busy && <Button onPress={cancelSignup}>Cancel</Button>} </ModalFooter> </> ); }; function getBusyTitle(view: SignupView): string { switch (view) { case "WAITING_ACCOUNT_CREATION": return "Waiting for account creation..."; case "SENDING_HANDOFF": return "Sending Fediverse Auth handoff..."; case "WAITING_HANDOFF_REPLY": return "Waiting for Fediverse Auth reply..."; case "REDIRECTING": return "Redirecting..."; default: return "Working..."; } } async function requestJson<T>(url: string, init: RequestInit = {}): Promise<T> { const headers = new Headers(init.headers); if (init.body && !headers.has("Content-Type")) { headers.set("Content-Type", "application/json"); } const response = await fetch(url, { ...init, credentials: "same-origin", headers, }); const data = (await response.json().catch(() => ({}))) as T | ErrorResponse; if (!response.ok || (isObject(data) && data.success === false)) { throw new Error(getErrorMessage(data, response.statusText)); } return data as T; } function getErrorMessage(error: unknown, fallback: string): string { if (error instanceof Error && error.message) return error.message; if (isObject(error)) { const message = error.error_message || error.error; if (typeof message === "string" && message) return message; } return fallback; } function isObject(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null; } packages/server/src/api/client/index.ts +2 −0 Original line number Diff line number Diff line Loading @@ -4,6 +4,7 @@ import { OpenIDController } from "../../controllers/OpenIDController"; import { prisma } from "../../lib/prisma"; import { AuthEndpoints } from "./auth"; import { CanvasEndpoints } from "./canvas"; import { ProgrammingDevSignupEndpoints } from "./programming_dev_signup"; import { ReportEndpoints } from "./reports"; import SentryRouter from "./sentry"; import { UserEndpoints } from "./user"; Loading @@ -20,6 +21,7 @@ app.use(SentryRouter); app.use(new AuthEndpoints().router); app.use("/reports", new ReportEndpoints().router); app.use("/signup/programming-dev", new ProgrammingDevSignupEndpoints().router); app.use("/canvas", new CanvasEndpoints().router); app.use("/user", new UserEndpoints().router); app.use("/share", ShareEndpoints); Loading packages/server/src/api/client/programming_dev_signup.ts 0 → 100644 +693 −0 File added.Preview size limit exceeded, changes collapsed. Show changes packages/server/src/types.ts +8 −0 Original line number Diff line number Diff line Loading @@ -5,6 +5,14 @@ import session from "express-session"; declare module "express-session" { interface SessionData { user: AuthSession; programmingDevSignup?: { username: string; registeredAt: string; handoffActorHandle?: string; handoffActorId?: number; handoffActorActorId?: string; handoffMessageSentAt?: string; }; } } Loading Loading
packages/client/src/components/Login/Normal.tsx +15 −4 Original line number Diff line number Diff line Loading @@ -3,13 +3,16 @@ import { type DialogData } from "../../contexts/DialogContext"; import { useCallback, useState } from "react"; import { RECOMMENDED_INSTANCES } from "../../lib/constants"; import { Button } from "../core/Button"; import { ProgrammingDevSignup } from "./ProgrammingDevSignup"; export const Normal = ({ state, }: { state: DialogData<"LOGIN_INFO"> & { mode: "NORMAL" }; }) => { const [step, setStep] = useState<"WELCOME" | "NEW">("WELCOME"); const [step, setStep] = useState< "WELCOME" | "NEW" | "PROGRAMMING_DEV_SIGNUP" >("WELCOME"); const WelcomeStep = useCallback( () => ( Loading Loading @@ -62,7 +65,7 @@ export const Normal = ({ </p> </> ), [] [], ); const NewStep = useCallback( Loading Loading @@ -96,9 +99,15 @@ export const Normal = ({ <strong>Return to this page after registering an account</strong> </> ), [] [], ); if (step === "PROGRAMMING_DEV_SIGNUP") { return ( <ProgrammingDevSignup loginUrl={state.url} onBack={() => setStep("NEW")} /> ); } return ( <> <ModalBody> Loading @@ -108,7 +117,9 @@ export const Normal = ({ <ModalFooter> {step === "WELCOME" && ( <> <Button onPress={() => setStep("NEW")}>I'm new here</Button> <Button onPress={() => setStep("PROGRAMMING_DEV_SIGNUP")}> I'm new here </Button> <Button as={Link} href={state.url}> I'm already a part of the Fediverse </Button> Loading
packages/client/src/components/Login/ProgrammingDevSignup.tsx 0 → 100644 +549 −0 Original line number Diff line number Diff line import { Alert, Input, Link, ModalBody, ModalFooter, Spinner } from "@heroui/react"; import { type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Button } from "../core/Button"; type SignupView = | "FORM" | "REGISTERING" | "WAITING_ACCOUNT_CREATION" | "SENDING_HANDOFF" | "WAITING_HANDOFF_REPLY" | "REDIRECTING" | "ERROR" | "TIMED_OUT"; type MetadataResponse = { success: true; instance: { captchaEnabled: boolean; registrationMode?: string; requireEmailVerification: boolean; }; handoff: { enabled: boolean; account?: string; }; }; type Captcha = { uuid: string; png: string; wav?: string; }; type CaptchaResponse = { success: true; captcha: Captcha; }; type RegisterResponse = { success: true; state: "ACCOUNT_READY" | "WAITING_ACCOUNT_CREATION"; }; type CheckResponse = | { success: true; state: "WAITING_ACCOUNT_CREATION" | "WAITING_HANDOFF_REPLY"; } | { success: true; state: "HANDOFF_READY"; redirect: string; }; type ErrorResponse = { success: false; error?: string; error_message?: string; }; const POLL_INTERVALS_MS = [3_000, 5_000, 10_000, 20_000, 30_000]; const POLL_TIMEOUT_MS = 15 * 60 * 1_000; export const ProgrammingDevSignup = ({ loginUrl, onBack, }: { loginUrl: string; onBack: () => void; }) => { const [view, setView] = useState<SignupView>("FORM"); const [metadata, setMetadata] = useState<MetadataResponse>(); const [captcha, setCaptcha] = useState<Captcha>(); const [loadingMetadata, setLoadingMetadata] = useState(true); const [loadingCaptcha, setLoadingCaptcha] = useState(false); const [error, setError] = useState<string>(); const [registered, setRegistered] = useState(false); const [username, setUsername] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [passwordVerify, setPasswordVerify] = useState(""); const [captchaAnswer, setCaptchaAnswer] = useState(""); const passwordRef = useRef(password); const pollTimer = useRef<number | undefined>(undefined); const pollAttempt = useRef(0); const pollStartedAt = useRef<number | undefined>(undefined); useEffect(() => { passwordRef.current = password; }, [password]); const clearPollTimer = useCallback(() => { if (typeof pollTimer.current !== "undefined") { window.clearTimeout(pollTimer.current); pollTimer.current = undefined; } }, []); const blockingProblem = useMemo(() => { if (!metadata) return undefined; if (metadata.handoff.enabled !== true || !metadata.handoff.account) { return "Fediverse Auth handoff is not available right now."; } if (metadata.instance.registrationMode !== "RequireApplication") { return "programming.dev changed its registration flow, so automatic signup is paused."; } if (metadata.instance.requireEmailVerification) { return "programming.dev now requires email verification, so this signup flow needs to be updated first."; } if (!metadata.instance.captchaEnabled) { return "programming.dev changed its captcha settings, so automatic signup is paused."; } return undefined; }, [metadata]); const reloadCaptcha = useCallback(async () => { setLoadingCaptcha(true); setError(undefined); try { const data = await requestJson<CaptchaResponse>( "/api/signup/programming-dev/captcha", ); setCaptcha(data.captcha); setCaptchaAnswer(""); } catch (e) { setError(getErrorMessage(e, "Could not load captcha.")); setView("ERROR"); } finally { setLoadingCaptcha(false); } }, []); useEffect(() => { let cancelled = false; const load = async () => { setLoadingMetadata(true); setError(undefined); try { const data = await requestJson<MetadataResponse>( "/api/signup/programming-dev/metadata", ); if (cancelled) return; setMetadata(data); if (data.instance.captchaEnabled) { await reloadCaptcha(); } } catch (e) { if (!cancelled) { setError(getErrorMessage(e, "Could not load signup metadata.")); setView("ERROR"); } } finally { if (!cancelled) setLoadingMetadata(false); } }; void load(); return () => { cancelled = true; clearPollTimer(); void fetch("/api/signup/programming-dev", { method: "DELETE" }); }; }, [clearPollTimer, reloadCaptcha]); const pollCheck = useCallback(async () => { clearPollTimer(); if (!pollStartedAt.current) pollStartedAt.current = Date.now(); if (Date.now() - pollStartedAt.current > POLL_TIMEOUT_MS) { setPassword(""); setView("TIMED_OUT"); return; } if (!passwordRef.current) { setView("TIMED_OUT"); return; } setView((current) => current === "WAITING_ACCOUNT_CREATION" || current === "WAITING_HANDOFF_REPLY" ? current : "SENDING_HANDOFF", ); try { const data = await requestJson<CheckResponse>( "/api/signup/programming-dev/check", { method: "POST", body: JSON.stringify({ password: passwordRef.current }), }, ); if (data.state === "HANDOFF_READY") { clearPollTimer(); setView("REDIRECTING"); window.location.assign(data.redirect); return; } setView(data.state); const attempt = pollAttempt.current++; const delay = attempt < POLL_INTERVALS_MS.length ? POLL_INTERVALS_MS[attempt] : 60_000; pollTimer.current = window.setTimeout(() => void pollCheck(), delay); } catch (e) { setError(getErrorMessage(e, "Could not check the account yet.")); setView("ERROR"); } }, [clearPollTimer]); const startPolling = useCallback(() => { pollAttempt.current = 0; pollStartedAt.current = Date.now(); void pollCheck(); }, [pollCheck]); const cancelSignup = useCallback(() => { clearPollTimer(); void fetch("/api/signup/programming-dev", { method: "DELETE" }); onBack(); }, [clearPollTimer, onBack]); const submitRegister = useCallback( async (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); if (blockingProblem) { setError(blockingProblem); setView("ERROR"); return; } if (password !== passwordVerify) { setError("Passwords do not match."); setView("ERROR"); return; } if (!captcha) { setError("Captcha is not ready yet."); setView("ERROR"); return; } setView("REGISTERING"); setError(undefined); try { const data = await requestJson<RegisterResponse>( "/api/signup/programming-dev/register", { method: "POST", body: JSON.stringify({ username, email: email || undefined, password, passwordVerify, captchaUuid: captcha.uuid, captchaAnswer, }), }, ); setRegistered(true); setView( data.state === "ACCOUNT_READY" ? "SENDING_HANDOFF" : "WAITING_ACCOUNT_CREATION", ); startPolling(); } catch (e) { setError(getErrorMessage(e, "Could not create the account.")); setView("ERROR"); void reloadCaptcha(); } }, [ blockingProblem, captcha, captchaAnswer, email, password, passwordVerify, reloadCaptcha, startPolling, username, ], ); const resumePolling = useCallback(() => { if (!password) { setError("Enter the password for the programming.dev account to keep checking."); return; } setError(undefined); setView("SENDING_HANDOFF"); startPolling(); }, [password, startPolling]); const busy = view === "REGISTERING" || view === "SENDING_HANDOFF" || view === "WAITING_ACCOUNT_CREATION" || view === "WAITING_HANDOFF_REPLY" || view === "REDIRECTING"; return ( <> <ModalBody className="gap-4"> <div className="flex flex-col gap-1"> <h1 className="text-2xl font-bold">Create a Fediverse account</h1> <p className="text-sm text-default-600"> Canvas will create a programming.dev account, then login through Fediverse Auth. </p> </div> {loadingMetadata && ( <div className="flex items-center gap-2 text-sm text-default-600"> <Spinner size="sm" /> Loading programming.dev signup... </div> )} {error && <Alert color="danger" title="Signup error" description={error} />} {blockingProblem && !error && ( <Alert color="warning" title="Signup unavailable" description={blockingProblem} /> )} {(view === "FORM" || (!registered && view === "ERROR") || view === "REGISTERING") && ( <form className="flex flex-col gap-3" onSubmit={submitRegister}> <Input label="Username" value={username} onValueChange={setUsername} minLength={3} maxLength={50} isRequired isDisabled={busy} autoComplete="username" /> <Input label="Email" type="email" value={email} onValueChange={setEmail} isDisabled={busy} autoComplete="email" /> <Input label="Password" type="password" value={password} onValueChange={setPassword} minLength={10} isRequired isDisabled={busy} autoComplete="new-password" /> <Input label="Confirm password" type="password" value={passwordVerify} onValueChange={setPasswordVerify} minLength={10} isRequired isDisabled={busy} autoComplete="new-password" /> <div className="flex flex-col gap-2"> <div className="flex items-center gap-3"> {captcha ? ( <img className="h-24 w-auto max-w-full" src={`data:image/png;base64,${captcha.png}`} alt="programming.dev captcha" /> ) : ( <div className="flex h-14 min-w-40 items-center justify-center rounded border border-default-200 text-sm text-default-500"> {loadingCaptcha ? "Loading captcha..." : "Captcha unavailable"} </div> )} <Button type="button" onPress={reloadCaptcha} isLoading={loadingCaptcha} isDisabled={busy} > Reload </Button> {captcha?.wav && ( <Link href={`data:audio/wav;base64,${captcha.wav}`} target="_blank"> Audio </Link> )} </div> <Input label="Captcha answer" value={captchaAnswer} onValueChange={setCaptchaAnswer} isRequired isDisabled={busy || !captcha} autoComplete="off" /> </div> <div className="flex justify-end gap-2 pt-1"> <Button type="button" onPress={cancelSignup}> Sign up with another instance </Button> <Button type="submit" color="primary" isLoading={view === "REGISTERING"} isDisabled={Boolean(blockingProblem) || loadingMetadata || loadingCaptcha} > Create account </Button> </div> </form> )} {busy && view !== "REGISTERING" && ( <div className="flex items-center gap-3"> <Spinner size="sm" /> <strong>{getBusyTitle(view)}</strong> </div> )} {view === "ERROR" && registered && ( <div className="flex flex-col gap-3"> <Input label="Password" type="password" value={password} onValueChange={setPassword} isRequired autoComplete="current-password" /> <div className="flex justify-end gap-2"> <Button onPress={cancelSignup}>Cancel</Button> <Button color="primary" onPress={resumePolling}> Retry handoff </Button> </div> </div> )} {view === "TIMED_OUT" && ( <div className="flex flex-col gap-3"> <Alert color="warning" title="Still waiting" description="Canvas has been checking for 15 minutes. Enter the programming.dev password again to resume polling." /> <Input label="Password" type="password" value={password} onValueChange={setPassword} isRequired autoComplete="current-password" /> <div className="flex justify-end gap-2"> <Button onPress={cancelSignup}>Cancel</Button> <Button color="primary" onPress={resumePolling}> Resume waiting </Button> </div> </div> )} </ModalBody> <ModalFooter className={busy ? "justify-end" : "justify-between"}> {!busy && ( <Button as={Link} href={loginUrl}> I already have an account </Button> )} {busy && <Button onPress={cancelSignup}>Cancel</Button>} </ModalFooter> </> ); }; function getBusyTitle(view: SignupView): string { switch (view) { case "WAITING_ACCOUNT_CREATION": return "Waiting for account creation..."; case "SENDING_HANDOFF": return "Sending Fediverse Auth handoff..."; case "WAITING_HANDOFF_REPLY": return "Waiting for Fediverse Auth reply..."; case "REDIRECTING": return "Redirecting..."; default: return "Working..."; } } async function requestJson<T>(url: string, init: RequestInit = {}): Promise<T> { const headers = new Headers(init.headers); if (init.body && !headers.has("Content-Type")) { headers.set("Content-Type", "application/json"); } const response = await fetch(url, { ...init, credentials: "same-origin", headers, }); const data = (await response.json().catch(() => ({}))) as T | ErrorResponse; if (!response.ok || (isObject(data) && data.success === false)) { throw new Error(getErrorMessage(data, response.statusText)); } return data as T; } function getErrorMessage(error: unknown, fallback: string): string { if (error instanceof Error && error.message) return error.message; if (isObject(error)) { const message = error.error_message || error.error; if (typeof message === "string" && message) return message; } return fallback; } function isObject(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null; }
packages/server/src/api/client/index.ts +2 −0 Original line number Diff line number Diff line Loading @@ -4,6 +4,7 @@ import { OpenIDController } from "../../controllers/OpenIDController"; import { prisma } from "../../lib/prisma"; import { AuthEndpoints } from "./auth"; import { CanvasEndpoints } from "./canvas"; import { ProgrammingDevSignupEndpoints } from "./programming_dev_signup"; import { ReportEndpoints } from "./reports"; import SentryRouter from "./sentry"; import { UserEndpoints } from "./user"; Loading @@ -20,6 +21,7 @@ app.use(SentryRouter); app.use(new AuthEndpoints().router); app.use("/reports", new ReportEndpoints().router); app.use("/signup/programming-dev", new ProgrammingDevSignupEndpoints().router); app.use("/canvas", new CanvasEndpoints().router); app.use("/user", new UserEndpoints().router); app.use("/share", ShareEndpoints); Loading
packages/server/src/api/client/programming_dev_signup.ts 0 → 100644 +693 −0 File added.Preview size limit exceeded, changes collapsed. Show changes
packages/server/src/types.ts +8 −0 Original line number Diff line number Diff line Loading @@ -5,6 +5,14 @@ import session from "express-session"; declare module "express-session" { interface SessionData { user: AuthSession; programmingDevSignup?: { username: string; registeredAt: string; handoffActorHandle?: string; handoffActorId?: number; handoffActorActorId?: string; handoffMessageSentAt?: string; }; } } Loading