SPB Git

Repository Public

second-factor.tsx 6.1 KB 124 lines tsx

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

Theme