feat(web): MFA and passkey sign-in on the login page

This commit is contained in:
2026-09-16 09:22:18 +00:00
parent d8597ee3ae
commit 26825841fa
6 changed files with 825 additions and 132 deletions
+185
View File
@@ -0,0 +1,185 @@
"use client";
import { useEffect, useState } from "react";
import QRCode from "qrcode";
import { auth } from "@/lib/api";
import { isPasskeySupported, toCreateOptions, credentialToJSON } from "@/lib/webauthn";
import { Button } from "@/components/ui";
/**
* The calls a wizard step makes to set up a second factor. Defaults to the
* ticket-scoped `/auth/mfa/enrol/*` endpoints (an in-progress, unauthenticated
* sign-in identified by the server's ticket cookie) for `mode="ticket"`.
*
* `mode="session"` is for an already-authenticated user managing their own
* factors (the account security page) and has no safe default: that page's
* endpoints do not exist yet, so its caller must pass `endpoints` explicitly.
*/
export interface MfaEnrolEndpoints {
totpSetup: () => Promise<{ secret: string; otpauth_url: string }>;
totpConfirm: (code: string) => Promise<{ ok: true; recovery_codes?: string[] }>;
passkeyBegin: () => Promise<{ publicKey: any; ceremony_id: string }>;
passkeyFinish: (ceremonyId: string, credential: unknown, name?: string) => Promise<{ ok: true; recovery_codes?: string[] }>;
}
const TICKET_ENDPOINTS: MfaEnrolEndpoints = {
totpSetup: () => auth.enrolTotpSetup(),
totpConfirm: (code) => auth.enrolTotpConfirm(code),
passkeyBegin: () => auth.enrolPasskeyBegin(),
passkeyFinish: (ceremonyId, credential, name) => auth.enrolPasskeyFinish(ceremonyId, credential, name),
};
interface MfaEnrolWizardProps {
mode: "session" | "ticket";
/** Required for mode="session"; defaults to the ticket-scoped endpoints for mode="ticket". */
endpoints?: MfaEnrolEndpoints;
onComplete: (recoveryCodes: string[]) => void;
/** Omit to make the wizard mandatory, as in forced enrolment during sign-in. */
onCancel?: () => void;
}
type Step = "choose" | "totp" | "passkey" | "recovery";
export function MfaEnrolWizard({ mode, endpoints, onComplete, onCancel }: MfaEnrolWizardProps) {
const api = endpoints ?? (mode === "ticket" ? TICKET_ENDPOINTS : undefined);
const [step, setStep] = useState<Step>("choose");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
// TOTP setup state
const [secret, setSecret] = useState("");
const [qrDataUrl, setQrDataUrl] = useState("");
const [totpCode, setTotpCode] = useState("");
// Recovery codes state
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([]);
const [savedConfirmed, setSavedConfirmed] = useState(false);
if (!api) {
return <div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">This wizard was not given its endpoints for session mode.</div>;
}
async function startTotp() {
setError("");
setBusy(true);
try {
const { secret, otpauth_url } = await api!.totpSetup();
setSecret(secret);
setQrDataUrl(await QRCode.toDataURL(otpauth_url));
setStep("totp");
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
async function confirmTotp(e: React.FormEvent) {
e.preventDefault();
setError("");
setBusy(true);
try {
const res = await api!.totpConfirm(totpCode);
setRecoveryCodes(res.recovery_codes ?? []);
setStep("recovery");
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
async function startPasskey() {
setError("");
setBusy(true);
try {
const { publicKey, ceremony_id } = await api!.passkeyBegin();
const cred = (await navigator.credentials.create({
publicKey: toCreateOptions(publicKey),
})) as PublicKeyCredential;
const res = await api!.passkeyFinish(ceremony_id, credentialToJSON(cred));
setRecoveryCodes(res.recovery_codes ?? []);
setStep("recovery");
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
if (step === "choose") {
return (
<div className="space-y-4">
<p className="text-sm text-text-secondary">This instance requires a second sign-in factor. Set one up to continue.</p>
{error && <div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</div>}
<div className="space-y-2">
<Button type="button" variant="primary" className="w-full justify-center" loading={busy} onClick={startTotp}>
Use an authenticator app
</Button>
{isPasskeySupported() && (
<Button type="button" variant="secondary" className="w-full justify-center" loading={busy} onClick={startPasskey}>
Use a passkey
</Button>
)}
</div>
{onCancel && (
<Button type="button" variant="ghost" className="w-full justify-center" onClick={onCancel}>
Cancel
</Button>
)}
</div>
);
}
if (step === "totp") {
return (
<form onSubmit={confirmTotp} className="space-y-4">
<p className="text-sm text-text-secondary">Scan this code with your authenticator app, or enter the key manually.</p>
{qrDataUrl && <img src={qrDataUrl} alt="Authenticator QR code" className="mx-auto h-40 w-40" />}
<p className="break-all rounded-lg border border-border bg-surface-2 px-3 py-2 text-center font-mono text-xs text-text-secondary">{secret}</p>
<div>
<label htmlFor="totp-code" className="mb-1.5 block text-sm font-medium text-text-secondary">
6-digit code
</label>
<input
id="totp-code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
required
maxLength={6}
value={totpCode}
onChange={(e) => setTotpCode(e.target.value)}
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
</div>
{error && <div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</div>}
<Button type="submit" variant="primary" loading={busy} className="w-full justify-center">
Confirm
</Button>
<Button type="button" variant="ghost" className="w-full justify-center" onClick={() => setStep("choose")}>
Back
</Button>
</form>
);
}
// step === "recovery"
return (
<div className="space-y-4">
<p className="text-sm text-text-secondary">Save these recovery codes somewhere safe. Each can be used once if you lose access to your other factor.</p>
<div className="grid grid-cols-2 gap-2 rounded-lg border border-border bg-surface-2 p-3 font-mono text-xs text-text-primary">
{recoveryCodes.map((code) => (
<div key={code}>{code}</div>
))}
</div>
<label className="flex items-center gap-2 text-sm text-text-secondary">
<input type="checkbox" checked={savedConfirmed} onChange={(e) => setSavedConfirmed(e.target.checked)} className="h-4 w-4 rounded border-border" />
I have saved these codes
</label>
<Button type="button" variant="primary" className="w-full justify-center" disabled={!savedConfirmed} onClick={() => onComplete(recoveryCodes)}>
Continue
</Button>
</div>
);
}