import { useCallback, useEffect, useRef, useState, type FormEvent } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { Loader2, Mail, Fingerprint } from "lucide-react"; import { useTranslation } from "react-i18next"; import { AuthLayout } from "@/components/AuthLayout"; import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { api, humanizeError, type SetupState } from "@/lib/api"; import { base64urlToBytes, bytesToBase64url } from "@/lib/utils"; import { useTier } from "@/lib/tier"; // Setup is the Owner's first-run onboarding wizard (spec §B setup bootstrap). The // `felis setup` MC-bind flow prints https://op.console./setup?token= — // the Owner is staff, so onboarding lands on the operator console, not the player // panel; this page redeems that one-time token (minting a lockdown session), then drives the // two remaining steps — record an email, enroll a passkey — before handing off to the // dashboard. It sits OUTSIDE RequireAuth (like /login): the visitor arrives without // a session, and the redeem is what creates one. // // The email is only RECORDED, not verified: the bootstrap has no SMTP, so there is no // code to mail. Passkey is the Owner's only pre-SMTP login credential and is // mandatory. Reload-safe: the token is single-use, so a refresh mid-wizard re-reads // progress from /auth/setup/status (the surviving session) rather than dead-ending on // a spent token. The step endpoints and /me are all SetupAllowed, so the lockdown // session can complete the wizard; the backend lifts the lockdown once a passkey is // enrolled, and we hand off to / once nothing remains. export function Setup() { const [params] = useSearchParams(); const navigate = useNavigate(); const { refresh } = useTier(); const { t } = useTranslation("auth"); const [state, setState] = useState(null); const [booting, setBooting] = useState(true); const [fatal, setFatal] = useState(null); const finishing = useRef(false); // Boot: redeem the URL token, or resume from the session if the token is already // spent (a reload). No token + no session → a dead link. useEffect(() => { let alive = true; (async () => { const token = params.get("token"); try { let st: SetupState; if (token) { try { st = await api.setupRedeem(token); } catch (redeemErr) { // The token may already be consumed (a reload). If a session survived, // resume from status; otherwise surface the original redeem error. try { st = await api.setupStatus(); } catch { throw redeemErr; } } } else { st = await api.setupStatus(); } if (alive) setState(st); } catch (e) { if (alive) setFatal(humanizeError(e)); } finally { if (alive) setBooting(false); } })(); return () => { alive = false; }; }, [params]); // reload re-reads progress after a wizard step so the view advances to the next. const reload = useCallback(async () => { setState(await api.setupStatus()); }, []); // finish re-reads /me (so RequireAuth sees the authenticated session) and hands // off to the dashboard. Idempotent — guarded so the completion effect fires once. const finish = useCallback(async () => { if (finishing.current) return; finishing.current = true; await refresh(); navigate("/", { replace: true }); }, [refresh, navigate]); // Once nothing remains (email recorded AND a passkey enrolled), hand off. useEffect(() => { if (state && !state.setup_required) void finish(); }, [state, finish]); if (booting) { return (
{t("setup_preparing")}
); } if (fatal) { return (

{fatal}

{t("setup_invalid_hint_prefix")} felis setup {t("setup_invalid_hint_suffix")}

); } // booting/fatal cover every other branch; state is set here. if (!state) return null; return ( {!state.email ? ( ) : !state.has_passkey ? ( ) : (
{t("setup_entering")}
)}
); } /** EmailStep is the §B setup Step 1: record the Owner's email. The bootstrap has no * SMTP, so there is no code to send — the address is stored UNVERIFIED (a later * Settings/SMTP flow verifies it). On success it calls onRecorded (a status re-read) * so the wizard advances to the passkey step. */ function EmailStep({ initialEmail, onRecorded, }: { initialEmail: string | null; onRecorded: () => Promise; }) { const { t } = useTranslation("auth"); const [email, setEmail] = useState(initialEmail ?? ""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); async function save(e: FormEvent) { e.preventDefault(); const addr = email.trim(); if (!addr || busy) return; setBusy(true); setError(null); try { await api.setEmail(addr); await onRecorded(); // advances (unmounts this step) — no need to clear busy } catch (err) { setError(humanizeError(err)); setBusy(false); } } return (
{t("setup_email_step")}

{t("setup_email_desc")}

setEmail(e.target.value)} autoComplete="email" autoCapitalize="none" autoCorrect="off" spellCheck={false} placeholder="you@example.com" disabled={busy} autoFocus aria-invalid={error ? true : undefined} />
{error &&

{error}

}
); } /** PasskeyStep enrolls the Owner's first passkey against the SetupAllowed register * endpoints — the same ceremony as the Account page. Passkey is the Owner's ONLY * login credential before SMTP exists (email-OTP login refuses admins; op-login * needs SMTP + a second admin), so it is mandatory: there is no skip, and the * backend lockdown lifts only once a passkey is enrolled. */ function PasskeyStep({ onEnrolled, }: { onEnrolled: () => Promise; }) { const { t } = useTranslation("auth"); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); async function enroll() { if (busy) return; setBusy(true); setError(null); try { const options = await api.passkeyRegisterBegin(); const publicKey: PublicKeyCredentialCreationOptions = { ...options, challenge: base64urlToBytes(options.challenge), user: { ...options.user, id: base64urlToBytes(options.user.id), }, excludeCredentials: options.excludeCredentials?.map((cred: any) => ({ ...cred, id: base64urlToBytes(cred.id), })), }; const credential = (await navigator.credentials.create({ publicKey, })) as PublicKeyCredential; if (!credential) { throw new Error("Failed to create credential"); } const response = credential.response as AuthenticatorAttestationResponse; const attestation = { id: credential.id, rawId: bytesToBase64url(credential.rawId), type: credential.type, response: { clientDataJSON: bytesToBase64url(response.clientDataJSON), attestationObject: bytesToBase64url(response.attestationObject), transports: typeof response.getTransports === "function" ? response.getTransports() : [], }, }; await api.passkeyRegisterFinish(t("setup_default_passkey_name"), attestation); await onEnrolled(); } catch (err: any) { setError(humanizeError(err)); setBusy(false); } } return (
{t("setup_passkey_step")}

{t("setup_passkey_desc")}

{error &&

{error}

}
); }