Files
linumiq_net-web_app/app/security/challenge/challenge-client.tsx
T

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>
);
}