Repository Public
1// Lost every passkey: an e-mail address and a recovery code (plus the authenticator code when enabled) open a short2// session whose only use is to enrol a new passkey.3import { startRegistration } from '@simplewebauthn/browser';4import { type FormEvent, useState } from 'react';5import { Link, Redirect, useLocation } from 'wouter';6import { post } from '../api.ts';7import { errorText, useT } from '../i18n.ts';8import { deviceLabel, passkeysSupported, useAuth, useRefreshAuth } from '../session.ts';9import { Alert, AuthFrame, linkCls, useSignOut } from '../shell/account-ui.tsx';10import { Button, ErrorState, Loading, TextField } from '../ui.tsx';1112function Enrol({ email }: { email?: string }) {13 const t = useT();14 const refresh = useRefreshAuth();15 const signOut = useSignOut();16 const [, navigate] = useLocation();17 const [label, setLabel] = useState(deviceLabel());18 const [busy, setBusy] = useState(false);19 const [failure, setFailure] = useState<string | null>(null);20 const [cancelled, setCancelled] = useState(false);21 const supported = passkeysSupported();22 const submit = async (e: FormEvent) => {23 e.preventDefault();24 setBusy(true); setFailure(null); setCancelled(false);25 try {26 // A recovery session needs no re-authentication, so the endpoints are called directly.27 const o = await post<{ challengeId: string; options: Parameters<typeof startRegistration>[0]['optionsJSON'] }>('/account/passkeys/options');28 const response = await startRegistration({ optionsJSON: o.options });29 await post('/account/passkeys/verify', { challengeId: o.challengeId, response, label: label.trim() });30 await refresh();31 navigate('/', { replace: true });32 } catch (err) {33 const name = (err as Error)?.name;34 if (name === 'NotAllowedError' || name === 'AbortError') setCancelled(true);35 else if (name === 'InvalidStateError') setFailure(t('account.recovery.alreadyRegistered'));36 else setFailure(errorText(err));37 setBusy(false);38 }39 };40 return (41 <AuthFrame title={t('account.recovery.enrolTitle')} subtitle={<>{t('account.recovery.enrolText')}{email ? <> <strong className="font-semibold break-all text-ink">{email}</strong></> : null}</>}42 footer={<button type="button" className={linkCls} onClick={() => void signOut()}>{t('account.recovery.abandon')}</button>}>43 <form onSubmit={submit} className="flex flex-col gap-4" noValidate>44 {!supported ? <Alert tone="warn">{t('account.login.unsupported')}</Alert> : null}45 {failure ? <Alert>{failure}</Alert> : null}46 <TextField label={t('account.passkey.name')} hint={t('account.passkey.nameHint')} value={label} onChange={(e) => setLabel(e.currentTarget.value)} maxLength={60} autoComplete="off" />47 <Button type="submit" variant="primary" size="lg" icon="key" busy={busy} disabled={!supported} className="w-full">{t('account.recovery.enrolButton')}</Button>48 {cancelled ? <p role="status" className="text-center text-sm text-ink-2">{t('account.login.cancelled')}</p> : null}49 <p className="text-xs leading-relaxed text-ink-3">{t('account.recovery.enrolNote')}</p>50 </form>51 </AuthFrame>52 );53}5455export function Recovery() {56 const t = useT();57 const { state, loading, error, refetch } = useAuth();58 const refresh = useRefreshAuth();59 const [email, setEmail] = useState('');60 const [code, setCode] = useState('');61 const [totp, setTotp] = useState('');62 const [busy, setBusy] = useState(false);63 const [failure, setFailure] = useState<string | null>(null);64 if (loading) return <Loading />;65 if (error && !state) return <ErrorState error={error} onRetry={() => void refetch()} />;66 if (state?.authenticated && state.recovery) return <Enrol email={state.user?.email} />;67 if (state?.authenticated) return <Redirect to="/" replace />;68 const ready = email.trim().length > 2 && code.trim().length >= 8;69 const submit = async (e: FormEvent) => {70 e.preventDefault();71 if (!ready) return;72 setBusy(true); setFailure(null);73 try {74 await post('/auth/recovery', { email: email.trim(), code: code.trim(), ...(totp.trim() ? { totp: totp.trim() } : {}) });75 await refresh();76 } catch (err) { setFailure(errorText(err)); }77 setBusy(false);78 };79 return (80 <AuthFrame title={t('account.recovery.title')} subtitle={t('account.recovery.subtitle')} footer={<Link href="/login" className={linkCls}>{t('account.recovery.backToLogin')}</Link>}>81 <form onSubmit={submit} className="flex flex-col gap-4" noValidate>82 {failure ? <Alert>{failure}</Alert> : null}83 <TextField label={t('account.recovery.email')} type="email" value={email} onChange={(e) => setEmail(e.currentTarget.value)} autoComplete="email" inputMode="email" autoCapitalize="off" maxLength={200} required autoFocus />84 <TextField label={t('account.recovery.code')} hint={t('account.recovery.codeHint')} value={code} onChange={(e) => setCode(e.currentTarget.value)} autoComplete="off" autoCapitalize="characters" spellCheck={false} maxLength={40} required className="font-mono tracking-wide" placeholder="XXXX-XXXX-XXXX-XXXX" />85 <TextField label={t('account.recovery.totp')} hint={t('account.recovery.totpHint')} value={totp} onChange={(e) => setTotp(e.currentTarget.value.replace(/\D/g, '').slice(0, 6))} autoComplete="one-time-code" inputMode="numeric" maxLength={6} className="font-mono tracking-widest" />86 <Button type="submit" variant="primary" size="lg" busy={busy} disabled={!ready} className="w-full">{t('account.recovery.button')}</Button>87 </form>88 </AuthFrame>89 );90}91