Repository Public
1"use client";2import { useEffect, useState } from "react";3import { browserSupportsWebAuthn, startAuthentication, startRegistration } from "@simplewebauthn/browser";4import { useLocale, useT } from "@/i18n";5import { adminCore } from "@/i18n/admin-core";6import { api, ApiClientError } from "@/lib/client-api";7import { Button, errorText, Field, Icon, Mark, Notice } from "@/components/ui";89const cancelled = (e: unknown) => e instanceof Error && (e.name === "NotAllowedError" || e.name === "AbortError");1011export function LoginForm() {12 const t = useT(adminCore);13 const locale = useLocale();14 const [busy, setBusy] = useState(false);15 const [error, setError] = useState<string | null>(null);16 const [recovery, setRecovery] = useState(false);17 const [code, setCode] = useState("");18 const [supported, setSupported] = useState(true);19 useEffect(() => { setSupported(browserSupportsWebAuthn()); }, []);2021 const signIn = async () => {22 setBusy(true); setError(null);23 try {24 const { challengeId, options } = await api<{ challengeId: string; options: never }>("/api/v1/auth/login/options", { method: "POST", body: {} });25 const response = await startAuthentication({ optionsJSON: options });26 await api("/api/v1/auth/login/verify", { body: { challengeId, response } });27 window.location.href = "/admin";28 } catch (e) {29 setError(cancelled(e) ? t("loginCancelled") : e instanceof ApiClientError && e.code !== "login_failed" ? errorText(e, locale) : t("loginFailed"));30 setBusy(false);31 }32 };33 const useCode = async (ev: React.FormEvent) => {34 ev.preventDefault(); setBusy(true); setError(null);35 try { await api("/api/v1/auth/recovery", { body: { code } }); window.location.href = "/admin/settings?tab=security"; }36 catch (e) { setError(e instanceof ApiClientError && e.code !== "login_failed" ? errorText(e, locale) : t("loginFailed")); setBusy(false); }37 };38 return (39 <div className="card p-6 sm:p-9 rise text-center shadow-e2">40 <div className="flex justify-center mb-5"><span className="floaty"><Mark size={68} /></span></div>41 <h1 className="t-title !text-[30px]">{t("loginTitle")}</h1>42 <p className="t-mute mt-2 mb-7 text-pretty">{t("loginLead")}</p>43 {!supported ? <div className="mb-4"><Notice tone="warn">{t("loginUnsupported")}</Notice></div> : null}44 {error ? <div className="mb-4"><Notice tone="danger">{error}</Notice></div> : null}45 <Button large variant="primary" className="w-full !px-3 max-[380px]:!text-[15px]" icon="key" busy={busy && !recovery} disabled={!supported} onClick={signIn}>{t("loginButton")}</Button>46 <div className="hairline mt-7 pt-4">47 {!recovery ? <button className="text-blue font-semibold text-sm cursor-pointer min-h-[44px]" onClick={() => setRecovery(true)}>{t("recoveryToggle")}</button> : (48 <form onSubmit={useCode} className="grid gap-3 text-left">49 <Field label={t("recoveryLabel")} hint={t("recoveryHint")}>{(p) => <input {...p} className="input font-mono" autoComplete="one-time-code" autoCapitalize="none" spellCheck={false} placeholder="xxxx-xxxx-xxxx" value={code} onChange={(e) => setCode(e.target.value)} required minLength={8} />}</Field>50 <Button type="submit" busy={busy}>{t("recoveryButton")}</Button>51 <p className="hint">{t("recoveryNote")}</p>52 </form>53 )}54 </div>55 </div>56 );57}5859export function SetupForm() {60 const t = useT(adminCore);61 const locale = useLocale();62 const [token, setToken] = useState<string | null | undefined>(undefined);63 const [name, setName] = useState("");64 const [busy, setBusy] = useState(false);65 const [error, setError] = useState<string | null>(null);66 const [codes, setCodes] = useState<string[] | null>(null);67 const [copied, setCopied] = useState(false);68 // The token travels in the URL fragment: it is never sent to the server in a request line, a log or a Referer.69 // It is removed from the address bar at once and kept for this tab only, so a reload does not lose it.70 useEffect(() => {71 const h = window.location.hash.slice(1);72 let stored: string | null = null;73 try { if (h) sessionStorage.setItem("spcal:setup", h); stored = sessionStorage.getItem("spcal:setup"); } catch { /* private mode */ }74 setToken(h || stored || null);75 if (h) window.history.replaceState(window.history.state, "", window.location.pathname);76 }, []);77 const forget = () => { try { sessionStorage.removeItem("spcal:setup"); } catch { /* ignore */ } };7879 const enrol = async (ev: React.FormEvent) => {80 ev.preventDefault(); setBusy(true); setError(null);81 try {82 const { challengeId, options } = await api<{ challengeId: string; options: never }>("/api/v1/auth/register/options", { body: { setupToken: token } });83 const response = await startRegistration({ optionsJSON: options });84 const res = await api<{ recoveryCodes: string[] | null }>("/api/v1/auth/register/verify", { body: { challengeId, response, name, setupToken: token } });85 forget();86 if (res.recoveryCodes?.length) setCodes(res.recoveryCodes); else window.location.href = "/admin";87 } catch (e) {88 setError(cancelled(e) ? t("loginCancelled") : e instanceof ApiClientError && e.code === "invalid_setup_token" ? (forget(), t("setupInvalid")) : errorText(e, locale));89 setBusy(false);90 }91 };92 if (codes) return (93 <div className="card p-6 sm:p-9 rise shadow-e2">94 <Notice tone="ok">{t("setupDoneTitle")}</Notice>95 <h1 className="t-title !text-[22px] mt-5">{t("codesTitle")}</h1>96 <p className="t-mute mt-2 mb-4">{t("codesLead")}</p>97 <ul className="grid grid-cols-2 gap-2 font-mono text-[15px] mb-4" aria-label={t("codesTitle")}>{codes.map((c) => <li key={c} className="bg-elev rounded-[14px] px-3 py-3 text-center select-all font-semibold tracking-wide">{c}</li>)}</ul>98 <div className="flex flex-wrap gap-2">99 <Button onClick={async () => { await navigator.clipboard.writeText(codes.join("\n")); setCopied(true); }}>{copied ? t("codesCopied") : t("codesCopy")}</Button>100 <Button variant="primary" onClick={() => { window.location.href = "/admin"; }}>{t("codesContinue")}</Button>101 </div>102 </div>103 );104 return (105 <div className="card p-6 sm:p-9 rise shadow-e2">106 <div className="w-14 h-14 rounded-[18px] accent-bg shadow-glow flex items-center justify-center mb-5"><Icon name="shield" size={28} /></div>107 <h1 className="t-title !text-[28px]">{t("setupTitle")}</h1>108 <p className="t-mute mt-2 mb-6 text-pretty">{t("setupLead")}</p>109 {token === null ? <Notice tone="warn">{t("setupMissing")}</Notice> : (110 <form onSubmit={enrol} className="grid gap-4">111 {error ? <Notice tone="danger">{error}</Notice> : null}112 <Field label={t("setupName")}>{(p) => <input {...p} className="input" value={name} onChange={(e) => setName(e.target.value)} placeholder={t("setupNamePh")} maxLength={60} />}</Field>113 <Button large type="submit" variant="primary" icon="key" busy={busy} disabled={token === undefined}>{t("setupButton")}</Button>114 </form>115 )}116 </div>117 );118}119