SPB Git

Repository Public

Recovery.tsx 5.5 KB 91 lines tsx

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

Theme