Repository Public
1'use client';2import { useEffect, useRef, useState } from 'react';3import QRCode from 'qrcode';4import { startRegistration, browserSupportsWebAuthn, type PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/browser';5import { KeyRound, Smartphone } from 'lucide-react';6import { api, errorMessage } from '@/lib/api';7import { Button } from '@/components/ui/button';8import { Field, Input } from '@/components/ui/input';910/** Server may return the WebAuthn options bare or wrapped as `{options}`. */11export function unwrapOptions<T>(res: unknown): T {12 return (res && typeof res === 'object' && 'options' in res ? (res as { options: T }).options : res) as T;13}1415export function TotpEnroll({ onDone }: { onDone: () => void }) {16 const [data, setData] = useState<{ secret: string; otpauthUrl: string } | null>(null);17 const [code, setCode] = useState('');18 const [busy, setBusy] = useState(false);19 const [error, setError] = useState<string | null>(null);20 const canvas = useRef<HTMLCanvasElement>(null);21 const start = async () => {22 setBusy(true);23 setError(null);24 try {25 setData(await api.post('/auth/totp/enroll/start'));26 } catch (e) {27 setError(errorMessage(e));28 } finally {29 setBusy(false);30 }31 };32 useEffect(() => {33 // The QR code is drawn locally on a canvas; the secret never leaves this page.34 if (data && canvas.current) void QRCode.toCanvas(canvas.current, data.otpauthUrl, { width: 184, margin: 2, color: { dark: '#17181C', light: '#FFFFFF' } });35 }, [data]);36 const finish = async () => {37 setBusy(true);38 setError(null);39 try {40 await api.post('/auth/totp/enroll/finish', { code });41 api.clearCsrf(); // the session was upgraded: its CSRF token rotated42 onDone();43 } catch (e) {44 setError(errorMessage(e));45 } finally {46 setBusy(false);47 }48 };49 if (!data)50 return (51 <div className="flex flex-col gap-3">52 <p className="text-xs leading-relaxed text-text-2">Use an authenticator app (1Password, Authy, Google Authenticator…) to generate 6-digit codes.</p>53 {error && <p role="alert" className="text-xs text-error">{error}</p>}54 <Button variant="brand" size="lg" className="rounded-full" loading={busy} onClick={start} data-testid="totp-start"><Smartphone className="size-4" /> Set up authenticator app</Button>55 </div>56 );57 return (58 <form className="flex flex-col gap-4" onSubmit={(e) => { e.preventDefault(); void finish(); }}>59 <div className="flex flex-col items-center gap-3">60 <canvas ref={canvas} className="rounded-md border border-line-2" aria-label="QR code for the authenticator app" role="img" />61 <div className="w-full text-center">62 <p className="text-[11px] text-muted">Or enter this secret manually</p>63 <code className="mt-1 block select-all break-all rounded-sm bg-surface-2 px-2 py-2 font-mono text-xs tracking-wider text-text" data-testid="totp-secret" data-secret={data.secret}>64 {data.secret.replace(/(.{4})/g, '$1 ').trim()}65 </code>66 </div>67 </div>68 <Field label="6-digit code from the app" error={error}>69 <Input value={code} onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} inputMode="numeric" autoComplete="one-time-code" pattern="\d{6}" className="h-14 rounded-[14px] text-center font-mono text-2xl tracking-[0.4em]" data-testid="totp-enroll-code" />70 </Field>71 <Button type="submit" variant="primary" size="lg" loading={busy} disabled={code.length !== 6}>Verify and enable</Button>72 </form>73 );74}7576export function PasskeyEnroll({ onDone }: { onDone: () => void }) {77 const [label, setLabel] = useState('');78 const [busy, setBusy] = useState(false);79 const [error, setError] = useState<string | null>(null);80 const supported = typeof window !== 'undefined' && browserSupportsWebAuthn();81 const register = async () => {82 setBusy(true);83 setError(null);84 try {85 const optionsJSON = unwrapOptions<PublicKeyCredentialCreationOptionsJSON>(await api.post('/auth/passkey/register/options', {}));86 const response = await startRegistration({ optionsJSON });87 await api.post('/auth/passkey/register/verify', { credential: response, label: label.trim() || 'Passkey' });88 api.clearCsrf();89 onDone();90 } catch (e) {91 setError(e instanceof Error && e.name === 'NotAllowedError' ? 'The passkey prompt was cancelled or timed out.' : errorMessage(e));92 } finally {93 setBusy(false);94 }95 };96 if (!supported) return <p className="text-xs text-text-2">This browser does not support passkeys.</p>;97 return (98 <form className="flex flex-col gap-3" onSubmit={(e) => { e.preventDefault(); void register(); }}>99 <p className="text-xs leading-relaxed text-text-2">A passkey uses Touch ID, Face ID or a security key. It is bound to this site and cannot be phished.</p>100 <Field label="Label" hint="For example “MacBook Touch ID” or “iPhone”." error={error}>101 <Input value={label} onChange={(e) => setLabel(e.target.value)} maxLength={60} />102 </Field>103 <Button type="submit" variant="primary" size="lg" loading={busy}><KeyRound className="size-4" /> Register passkey</Button>104 </form>105 );106}107108/** Mandatory second-factor enrolment (after setup, or when the account has none). */109export function SecondFactorEnroll({ onDone }: { onDone: () => void }) {110 const [method, setMethod] = useState<'totp' | 'passkey'>('totp');111 return (112 <div className="flex flex-col gap-4" data-testid="second-factor-enroll">113 <div role="tablist" aria-label="Second factor" className="grid grid-cols-2 gap-1 rounded-full bg-surface-2 p-1">114 {(['totp', 'passkey'] as const).map((m) => (115 <button key={m} role="tab" aria-selected={method === m} onClick={() => setMethod(m)} className={method === m ? 'h-10 rounded-full bg-surface text-[13px] font-semibold text-text shadow-card' : 'h-10 rounded-full text-[13px] text-muted hover:text-text-2'}>116 {m === 'totp' ? 'Authenticator app' : 'Passkey'}117 </button>118 ))}119 </div>120 {method === 'totp' ? <TotpEnroll onDone={onDone} /> : <PasskeyEnroll onDone={onDone} />}121 </div>122 );123}124