'use client'; import { useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; import { createSupabaseBrowserClient } from '@/lib/supabase/browser'; import { passkeyErrorMessage, stepUpWithPasskey, } from '@/lib/auth/webauthn-client'; /** * AAL2 step-up UI: verify with TOTP or passkey, or fall back to a single-use * recovery code (which resets MFA and bounces the user back to /login to set up * a fresh factor). */ export function ChallengeClient({ totpFactorId, passkeyFactorId, next, }: { totpFactorId: string | null; passkeyFactorId: string | null; next: string; }) { const router = useRouter(); const supabase = useMemo(() => createSupabaseBrowserClient(), []); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [code, setCode] = useState(''); const [showRecovery, setShowRecovery] = useState(false); const [recovery, setRecovery] = useState(''); function done() { router.replace(next); router.refresh(); } async function verifyTotp(e: React.FormEvent) { e.preventDefault(); if (!totpFactorId) return; setError(null); setBusy(true); try { const { data: ch, error: ce } = await supabase.auth.mfa.challenge({ factorId: totpFactorId, }); if (ce) throw ce; const { error: ve } = await supabase.auth.mfa.verify({ factorId: totpFactorId, challengeId: ch.id, code: code.trim(), }); if (ve) throw ve; done(); } catch (e) { setError((e as Error).message); setBusy(false); } } async function verifyPasskey() { if (!passkeyFactorId) return; setError(null); setBusy(true); try { await stepUpWithPasskey(supabase, passkeyFactorId); done(); } catch (e) { setError(passkeyErrorMessage(e)); setBusy(false); } } async function redeemRecovery(e: React.FormEvent) { e.preventDefault(); setError(null); setBusy(true); try { const res = await fetch('/api/security/recovery/redeem', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: recovery.trim() }), }); const json = (await res.json()) as { ok?: boolean; error?: string }; if (!res.ok || !json.ok) { throw new Error( json.error === 'invalid_code' ? 'That recovery code is invalid or already used.' : 'Recovery failed. Please try again.', ); } // Deleting verified factors logs the user out everywhere; clear local // cookies and return to login to re-enroll. await supabase.auth.signOut().catch(() => {}); router.replace('/login?mfa_reset=1'); router.refresh(); } catch (e) { setError((e as Error).message); setBusy(false); } } return (
{error &&

{error}

} {passkeyFactorId && (

Passkey

Verify with your device biometrics or security key.

)} {totpFactorId && (

Authenticator app

setCode(e.target.value)} />
)}

Lost access?

{showRecovery ? (

Enter a recovery code. This removes your current two-factor methods so you can set up new ones after signing in again.

setRecovery(e.target.value)} />
) : ( )}
); }