183 lines
5.2 KiB
TypeScript
183 lines
5.2 KiB
TypeScript
'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>
|
|
);
|
|
}
|