186 lines
5.3 KiB
TypeScript
186 lines
5.3 KiB
TypeScript
'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<string | null>(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 (
|
|
<div className="stack">
|
|
{error && <p className="error">{error}</p>}
|
|
|
|
{passkeyFactorId && (
|
|
<div className="card">
|
|
<h2>Passkey</h2>
|
|
<p className="muted">
|
|
Verify with your device biometrics or security key.
|
|
</p>
|
|
<button type="button" onClick={verifyPasskey} disabled={busy}>
|
|
{busy ? 'Waiting…' : 'Use a passkey'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{totpFactorId && (
|
|
<div className="card">
|
|
<h2>Authenticator app</h2>
|
|
<form onSubmit={verifyTotp}>
|
|
<label htmlFor="challenge-code">6-digit code</label>
|
|
<input
|
|
id="challenge-code"
|
|
type="text"
|
|
inputMode="numeric"
|
|
autoComplete="one-time-code"
|
|
pattern="[0-9]*"
|
|
maxLength={6}
|
|
required
|
|
value={code}
|
|
onChange={(e) => setCode(e.target.value)}
|
|
/>
|
|
<div className="row" style={{ marginTop: '1rem' }}>
|
|
<button type="submit" disabled={busy || code.trim().length < 6}>
|
|
{busy ? 'Verifying…' : 'Verify'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
)}
|
|
|
|
<div className="card">
|
|
<h2>Lost access?</h2>
|
|
{showRecovery ? (
|
|
<form onSubmit={redeemRecovery}>
|
|
<p className="muted">
|
|
Enter a recovery code. This removes your current two-factor
|
|
methods so you can set up new ones after signing in again.
|
|
</p>
|
|
<label htmlFor="recovery-code">Recovery code</label>
|
|
<input
|
|
id="recovery-code"
|
|
type="text"
|
|
autoComplete="off"
|
|
placeholder="xxxx-xxxx-xxxx"
|
|
required
|
|
value={recovery}
|
|
onChange={(e) => setRecovery(e.target.value)}
|
|
/>
|
|
<div className="row" style={{ marginTop: '1rem' }}>
|
|
<button
|
|
type="submit"
|
|
className="btn-danger"
|
|
disabled={busy || recovery.trim().length === 0}
|
|
>
|
|
{busy ? 'Working…' : 'Use recovery code'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className="secondary"
|
|
onClick={() => setShowRecovery(true)}
|
|
disabled={busy}
|
|
>
|
|
Use a recovery code
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|