diff --git a/web/src/components/Login.tsx b/web/src/components/Login.tsx new file mode 100644 index 000000000..48895028d --- /dev/null +++ b/web/src/components/Login.tsx @@ -0,0 +1,171 @@ +import { Loader2, LogIn, Moon, Sun } from "lucide-react"; +import { motion } from "motion/react"; +import { useEffect, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { requestPasswordReset, requestSignup } from "@/lib/api"; +import { login } from "@/lib/auth"; +import { asset } from "@/lib/utils"; + +const LINK = "cursor-pointer text-primary hover:underline"; + +type Mode = "signin" | "reset" | "signup"; + +const COPY: Record = { + signin: { title: "", action: "", sent: "" }, + reset: { + title: "Reset your password", + action: "Email me a reset link", + // Deliberately the same whether or not the address has an account: a + // different answer here would tell an anonymous caller who is registered. + sent: "If that address has an account, a reset link is on its way.", + }, + signup: { + title: "Create an account", + action: "Email me a signup link", + sent: "If that address can be registered, a signup link is on its way.", + }, +}; + +/** Full-screen sign-in against POST /api/v1/auth/tokens. */ +export function Login({ onDone }: Readonly<{ onDone: () => void }>) { + const [mode, setMode] = useState("signin"); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [sent, setSent] = useState(false); + const [busy, setBusy] = useState(false); + + const go = (next: Mode) => { + setMode(next); + setError(null); + setSent(false); + }; + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + setBusy(true); + setError(null); + try { + if (mode === "signin") { + await login(email, password); + onDone(); + } else { + await (mode === "reset" ? requestPasswordReset : requestSignup)(email); + setSent(true); + } + } catch (err) { + setError(err instanceof Error ? err.message : "That did not work."); + } finally { + setBusy(false); + } + }; + + const [dark, setDark] = useState(() => localStorage.getItem("sp-theme") === "dark"); + useEffect(() => { + document.documentElement.classList.toggle("dark", dark); + localStorage.setItem("sp-theme", dark ? "dark" : "light"); + }, [dark]); + + return ( +
+ + +
+ CCExtractor +
+
Sample Platform
+
CCExtractor regression testing
+
+
+ + {mode !== "signin" && ( +
{COPY[mode].title}
+ )} + + + setEmail(e.target.value)} + placeholder="you@example.com" + className="mb-3" + /> + {mode === "signin" && ( + <> + + setPassword(e.target.value)} + className="mb-4" + /> + + )} + {error && ( +
+ {error} +
+ )} + {sent && ( +
+ {COPY[mode].sent} +
+ )} + + + {/* Both links finish on the classic site, which is where accounts are + created and passwords are set. */} +
+ {mode === "signin" ? ( + <> + + · + + + ) : ( + + )} +
+ +

+ Use the same account as the CCExtractor Sample Platform. +

+
+
+ ); +} diff --git a/web/src/components/ResetPassword.tsx b/web/src/components/ResetPassword.tsx new file mode 100644 index 000000000..38a8c011f --- /dev/null +++ b/web/src/components/ResetPassword.tsx @@ -0,0 +1,162 @@ +import { KeyRound, Loader2 } from "lucide-react"; +import { motion } from "motion/react"; +import { useEffect, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { completePasswordReset } from "@/lib/api"; +import { asset } from "@/lib/utils"; + +/** + * Set a new password from the link in a recovery email. + * + * Reached without a session, so it renders on its own rather than inside + * the shell. The three values in the query string are the platform's + * signed link: they are handed straight back to the API, which is what + * checks them. Nothing here can decide whether a link is good. + */ +export function ResetPassword() { + const [params, setParams] = useState<{ + uid: number; + expires: number; + mac: string; + } | null>(null); + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [done, setDone] = useState(false); + + // Read from the URL rather than the router: the link is built by the + // platform's email template, and lands here under either history mode. + useEffect(() => { + const raw = window.location.href; + const query = raw.slice(raw.indexOf("?") + 1); + const q = new URLSearchParams(raw.includes("?") ? query : ""); + const uid = Number(q.get("uid")); + const expires = Number(q.get("expires")); + const mac = q.get("mac"); + if (uid && expires && mac) setParams({ uid, expires, mac }); + }, []); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!params) return; + setBusy(true); + setError(null); + try { + await completePasswordReset({ + user_id: params.uid, + expires: params.expires, + mac: params.mac, + password, + }); + setDone(true); + } catch (err) { + setError(err instanceof Error ? err.message : "That did not work."); + } finally { + setBusy(false); + } + }; + + return ( +
+ +
+ CCExtractor +
+
+ Choose a new password +
+
CCExtractor Sample Platform
+
+
+ + {!params && ( +
+ This link is missing part of itself. Ask for a new one from the sign-in + page. +
+ )} + + {params && !done && ( + <> + + setPassword(e.target.value)} + className="mb-3" + /> + + setConfirm(e.target.value)} + className="mb-4" + /> + {confirm && password !== confirm && ( +
+ The two do not match. +
+ )} + {error && ( +
+ {error} +
+ )} + + {/* The link carries the old password's signature, so using it + once is the last thing it can do. */} +

+ This link works once, and stops working as soon as the password + changes. +

+ + )} + + {done && ( + <> +
+ Password changed. You can sign in with it now. +
+ + + )} +
+
+ ); +} diff --git a/web/src/pages/Account.tsx b/web/src/pages/Account.tsx new file mode 100644 index 000000000..fdfca2240 --- /dev/null +++ b/web/src/pages/Account.tsx @@ -0,0 +1,368 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { ExternalLink, GitBranch, KeyRound, Loader2, UserRound, UserX } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { ConfirmDialog } from "@/components/ui/confirm"; +import { Input } from "@/components/ui/input"; +import { + deactivateUser, + unlinkGithub, + updateAccount, + useGithubLink, + useMe, +} from "@/lib/api"; +import { logout, setSessionEmail } from "@/lib/auth"; + +/** + * Your own account: name, email, password and closing it. + * + * Everything here is the caller acting on themselves, so it needs no role. + * Administration of other people's accounts stays on the administration + * page, where the rest of the platform's privileged actions live. + */ +export function Account() { + const { data: me, isLoading } = useMe(); + + return ( +
+

Your account

+

+ {me ? ( + <> + Signed in as {me.email} ·{" "} + {me.role} + + ) : ( + "Loading your details…" + )} +

+ + {isLoading &&
} + {me && ( +
+ + + + +
+ )} +
+ ); +} + +function ProfileSection({ name, email }: Readonly<{ name: string; email: string }>) { + const qc = useQueryClient(); + const [draftName, setDraftName] = useState(name); + const [draftEmail, setDraftEmail] = useState(email); + const [current, setCurrent] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [saved, setSaved] = useState(false); + + // The query refetches after a save, so pick the server's values back up + // rather than leaving whatever was typed sitting in the inputs. + useEffect(() => { + setDraftName(name); + setDraftEmail(email); + }, [name, email]); + + const emailChanged = draftEmail !== email; + const nameChanged = draftName !== name; + const dirty = emailChanged || nameChanged; + // Both are required on the account, so an emptied field is a round trip + // the server would only reject. + const complete = draftName.trim() !== "" && draftEmail.trim() !== ""; + + const save = async () => { + setBusy(true); + setError(null); + setSaved(false); + try { + const patch: Parameters[0] = {}; + if (nameChanged) patch.name = draftName.trim(); + if (emailChanged) { + patch.email = draftEmail.trim(); + patch.current_password = current; + } + await updateAccount(patch); + setCurrent(""); + // The sidebar reads the email out of the stored session rather than + // from the API, so it has to be told when this changes it. + if (emailChanged) setSessionEmail(draftEmail.trim()); + await qc.invalidateQueries({ queryKey: ["me"] }); + setSaved(true); + } catch (e) { + setError(e instanceof Error ? e.message : "That did not work."); + } finally { + setBusy(false); + } + }; + + return ( +
+ + Profile + +
+ + setDraftName(e.target.value)} maxLength={50} /> + + + setDraftEmail(e.target.value)} + /> + + {emailChanged && ( + + setCurrent(e.target.value)} + placeholder="required to change your email" + /> + + )} +
+ + {saved && !dirty && Saved.} + {error && {error}} +
+
+
+ ); +} + +function PasswordSection() { + const [current, setCurrent] = useState(""); + const [next, setNext] = useState(""); + const [confirm, setConfirm] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [done, setDone] = useState(false); + + const mismatch = confirm.length > 0 && next !== confirm; + + const save = async () => { + setBusy(true); + setError(null); + setDone(false); + try { + await updateAccount({ current_password: current, new_password: next }); + setCurrent(""); + setNext(""); + setConfirm(""); + setDone(true); + } catch (e) { + setError(e instanceof Error ? e.message : "That did not work."); + } finally { + setBusy(false); + } + }; + + return ( +
+ + Password + +
+ + setCurrent(e.target.value)} + /> + + + setNext(e.target.value)} + /> + + + setConfirm(e.target.value)} + /> + + {/* The minimum length lives in the platform's config, so the server + is the one that reports it rather than a number copied here. */} +
+ + {mismatch && ( + The two do not match. + )} + {done && !current && !next && ( + + Password changed. This session stays signed in. + + )} + {error && {error}} +
+
+
+ ); +} + +/** + * The GitHub connection, used for runs on your own forks and pull requests. + * + * Connecting is an OAuth redirect that finishes on the platform's classic + * callback, so this sends you there and picks the result up afterwards + * rather than handling the exchange itself. + */ +function GithubSection() { + const qc = useQueryClient(); + const { data, isLoading } = useGithubLink(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const disconnect = async () => { + setBusy(true); + setError(null); + try { + await unlinkGithub(); + await qc.invalidateQueries({ queryKey: ["github-link"] }); + } catch (e) { + setError(e instanceof Error ? e.message : "That did not work."); + } finally { + setBusy(false); + } + }; + + return ( +
+ + GitHub + +
+ {isLoading &&
} + {data && ( + <> +

+ {data.linked ? ( + <> + Connected as{" "} + @{data.github_login}. + Runs on your own forks and pull requests use this. + + ) : ( + <> + Not connected. Connecting lets the platform queue runs for your own + forks and pull requests. + + )} +

+ {error &&
{error}
} + {data.linked ? ( + + ) : ( + + + + )} + {/* The redirect finishes on the classic site, so this page will + not know about it until it is loaded again. */} +

+ {data.linked + ? "Disconnecting only forgets the platform's copy. Withdraw the authorisation itself from your GitHub applications page." + : "GitHub opens in a new tab. Reload this page once you are done there."} +

+ + )} +
+
+ ); +} + +function CloseSection({ userId }: Readonly<{ userId: number }>) { + const [confirming, setConfirming] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const go = async () => { + setBusy(true); + setError(null); + try { + await deactivateUser(userId); + logout(); + } catch (e) { + setError(e instanceof Error ? e.message : "That did not work."); + setBusy(false); + setConfirming(false); + } + }; + + return ( +
+ + Close account + +
+

+ Your name and email are replaced with a placeholder and the password is scrambled. + The account itself stays so the samples and runs you own keep an author. +

+ {error &&
{error}
} + +
+ + You are signed out straight away and cannot sign back in.{" "} + This cannot be undone. Ask an administrator if you need the account back. + + } + confirmLabel={busy ? "Closing…" : "Close account"} + busy={busy} + onConfirm={go} + /> +
+ ); +} + +function Field({ label, children }: Readonly<{ label: string; children: React.ReactNode }>) { + return ( + + ); +} + +function SectionLabel({ children }: Readonly<{ children: React.ReactNode }>) { + return ( +
+ {children} +
+ ); +}