feat(auth): mandatory 2FA (TOTP + WebAuthn passkeys) with hard enrollment gate, AAL2 step-up, and single-use recovery codes
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { createSupabaseBrowserClient } from '@/lib/supabase/browser';
|
||||
import {
|
||||
PasskeyEnrollPanel,
|
||||
RecoveryCodesPanel,
|
||||
TotpEnrollPanel,
|
||||
} from './mfa-components';
|
||||
|
||||
type FactorView = {
|
||||
id: string;
|
||||
type: string;
|
||||
friendlyName: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
totp: 'Authenticator app',
|
||||
webauthn: 'Passkey',
|
||||
phone: 'Phone',
|
||||
};
|
||||
|
||||
/**
|
||||
* Security settings client: list verified factors, add new ones, remove
|
||||
* factors (never the last one — mandatory MFA), and regenerate recovery codes.
|
||||
*/
|
||||
export function SecurityClient({
|
||||
initialFactors,
|
||||
}: {
|
||||
initialFactors: FactorView[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const supabase = useMemo(() => createSupabaseBrowserClient(), []);
|
||||
const [factors] = useState<FactorView[]>(initialFactors);
|
||||
const [adding, setAdding] = useState<null | 'totp' | 'webauthn'>(null);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [codes, setCodes] = useState<string[] | null>(null);
|
||||
const [regenBusy, setRegenBusy] = useState(false);
|
||||
|
||||
async function remove(id: string) {
|
||||
if (factors.length <= 1) {
|
||||
setError('You must keep at least one two-factor method enabled.');
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
setBusyId(id);
|
||||
try {
|
||||
const { error } = await supabase.auth.mfa.unenroll({ factorId: id });
|
||||
if (error) throw error;
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function onAdded() {
|
||||
setAdding(null);
|
||||
setNotice('Two-factor method added.');
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function regenerate() {
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
setCodes(null);
|
||||
setRegenBusy(true);
|
||||
try {
|
||||
const res = await fetch('/api/security/recovery/generate', {
|
||||
method: 'POST',
|
||||
});
|
||||
const json = (await res.json()) as { codes?: string[]; error?: string };
|
||||
if (!res.ok || !json.codes) {
|
||||
throw new Error(json.error || 'Could not generate recovery codes.');
|
||||
}
|
||||
setCodes(json.codes);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setRegenBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
{error && <p className="error">{error}</p>}
|
||||
{notice && <p className="success">{notice}</p>}
|
||||
|
||||
<div className="card">
|
||||
<h2>Your two-factor methods</h2>
|
||||
{factors.length === 0 ? (
|
||||
<p className="muted">No methods enabled.</p>
|
||||
) : (
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Method</th>
|
||||
<th>Name</th>
|
||||
<th>Added</th>
|
||||
<th aria-label="actions" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{factors.map((f) => (
|
||||
<tr key={f.id}>
|
||||
<td>{TYPE_LABEL[f.type] ?? f.type}</td>
|
||||
<td>{f.friendlyName ?? '—'}</td>
|
||||
<td>{new Date(f.createdAt).toLocaleDateString()}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-sm btn-danger"
|
||||
onClick={() => remove(f.id)}
|
||||
disabled={busyId === f.id || factors.length <= 1}
|
||||
title={
|
||||
factors.length <= 1
|
||||
? 'At least one method is required'
|
||||
: 'Remove this method'
|
||||
}
|
||||
>
|
||||
{busyId === f.id ? 'Removing…' : 'Remove'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{adding === 'totp' && (
|
||||
<TotpEnrollPanel supabase={supabase} onVerified={onAdded} />
|
||||
)}
|
||||
{adding === 'webauthn' && (
|
||||
<PasskeyEnrollPanel supabase={supabase} onVerified={onAdded} />
|
||||
)}
|
||||
|
||||
{adding === null && (
|
||||
<div className="card">
|
||||
<h2>Add a method</h2>
|
||||
<div className="row">
|
||||
<button type="button" onClick={() => setAdding('totp')}>
|
||||
Add authenticator app
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => setAdding('webauthn')}
|
||||
>
|
||||
Add passkey
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<h2>Recovery codes</h2>
|
||||
<p className="muted">
|
||||
Generate a new set of single-use recovery codes. This invalidates any
|
||||
codes you were issued before.
|
||||
</p>
|
||||
{codes && <RecoveryCodesPanel codes={codes} />}
|
||||
<div className="row" style={{ marginTop: '1rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={regenerate}
|
||||
disabled={regenBusy}
|
||||
>
|
||||
{regenBusy ? 'Generating…' : 'Regenerate recovery codes'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user