'use client'; import { useState } from 'react'; import type { SupabaseClient } from '@supabase/supabase-js'; import { enrollPasskey, passkeyErrorMessage, } from '@/lib/auth/webauthn-client'; /** * Remove any leftover *unverified* factors of a given type before starting a * fresh enrollment. GoTrue rejects a second enroll with a duplicate friendly * name, and abandoned/unverified factors would otherwise block re-enrollment * (e.g. after a refresh mid-flow). Unenrolling unverified factors does not * require aal2. */ async function cleanupUnverified( supabase: SupabaseClient, factorType: 'totp' | 'webauthn', ): Promise { const { data } = await supabase.auth.mfa.listFactors(); const stale = data?.all.filter( (f) => f.factor_type === factorType && f.status === 'unverified', ) ?? []; for (const f of stale) { await supabase.auth.mfa.unenroll({ factorId: f.id }); } } /** TOTP authenticator-app enrollment: QR + manual secret, then code verify. */ export function TotpEnrollPanel({ supabase, onVerified, }: { supabase: SupabaseClient; onVerified: () => void; }) { const [stage, setStage] = useState<'idle' | 'pending'>('idle'); const [qr, setQr] = useState(''); const [secret, setSecret] = useState(''); const [factorId, setFactorId] = useState(''); const [code, setCode] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); async function start() { setError(null); setBusy(true); try { await cleanupUnverified(supabase, 'totp'); const { data, error } = await supabase.auth.mfa.enroll({ factorType: 'totp', friendlyName: 'Authenticator app', }); if (error) throw error; if (!data || !('totp' in data)) { throw new Error('Unexpected enrollment response.'); } setFactorId(data.id); setQr(data.totp.qr_code); setSecret(data.totp.secret); setStage('pending'); } catch (e) { setError((e as Error).message); } finally { setBusy(false); } } async function verify(e: React.FormEvent) { e.preventDefault(); setError(null); setBusy(true); try { const { data: ch, error: ce } = await supabase.auth.mfa.challenge({ factorId, }); if (ce) throw ce; const { error: ve } = await supabase.auth.mfa.verify({ factorId, challengeId: ch.id, code: code.trim(), }); if (ve) throw ve; onVerified(); } catch (e) { setError((e as Error).message); } finally { setBusy(false); } } if (stage === 'idle') { return (

Authenticator app

Use an app like Google Authenticator, 1Password, or Authy to generate 6-digit codes.

{error &&

{error}

}
); } return (

Scan the QR code

Scan this with your authenticator app, then enter the 6-digit code to confirm.

{qr && ( // eslint-disable-next-line @next/next/no-img-element TOTP QR code )}

Can't scan? Enter this secret manually:

{secret}
setCode(e.target.value)} /> {error &&

{error}

}
); } /** Passkey (WebAuthn) enrollment via the simplewebauthn ceremony. */ export function PasskeyEnrollPanel({ supabase, onVerified, }: { supabase: SupabaseClient; onVerified: () => void; }) { const [busy, setBusy] = useState(false); const [error, setError] = useState(null); async function start() { setError(null); setBusy(true); try { await cleanupUnverified(supabase, 'webauthn'); await enrollPasskey(supabase, 'Passkey'); onVerified(); } catch (e) { setError(passkeyErrorMessage(e)); } finally { setBusy(false); } } return (

Passkey

Use your device's biometrics, screen lock, or a hardware security key.

{error &&

{error}

}
); } /** One-time display of freshly generated recovery codes. */ export function RecoveryCodesPanel({ codes }: { codes: string[] }) { const [copied, setCopied] = useState(false); function copy() { navigator.clipboard .writeText(codes.join('\n')) .then(() => setCopied(true)) .catch(() => setCopied(false)); } return (

Save your recovery codes

Store these somewhere safe. Each code can be used once to recover access if you lose your authenticator and passkeys. They will not be shown again.

{codes.map((c) => ( {c} ))}
); }