SPB Git

Repository Public

auth-forms.tsx 7.0 KB 119 lines tsx

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
Files main
Menu

Theme