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

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