Files
linumiq_net-web_app/app/security/mfa-components.tsx
T

230 lines
6.3 KiB
TypeScript

'use client';
import { useState } from 'react';
import type { SupabaseClient } from '@supabase/supabase-js';
import {
enrollPasskey,
passkeyErrorMessage,
} from '@/lib/auth/webauthn-client';
/**
* Remove any leftover *unverified* factors of a given type before starting a
* fresh enrollment. GoTrue rejects a second enroll with a duplicate friendly
* name, and abandoned/unverified factors would otherwise block re-enrollment
* (e.g. after a refresh mid-flow). Unenrolling unverified factors does not
* require aal2.
*/
async function cleanupUnverified(
supabase: SupabaseClient,
factorType: 'totp' | 'webauthn',
): Promise<void> {
const { data } = await supabase.auth.mfa.listFactors();
const stale =
data?.all.filter(
(f) => f.factor_type === factorType && f.status === 'unverified',
) ?? [];
for (const f of stale) {
await supabase.auth.mfa.unenroll({ factorId: f.id });
}
}
/** TOTP authenticator-app enrollment: QR + manual secret, then code verify. */
export function TotpEnrollPanel({
supabase,
onVerified,
}: {
supabase: SupabaseClient;
onVerified: () => void;
}) {
const [stage, setStage] = useState<'idle' | 'pending'>('idle');
const [qr, setQr] = useState('');
const [secret, setSecret] = useState('');
const [factorId, setFactorId] = useState('');
const [code, setCode] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function start() {
setError(null);
setBusy(true);
try {
await cleanupUnverified(supabase, 'totp');
const { data, error } = await supabase.auth.mfa.enroll({
factorType: 'totp',
friendlyName: 'Authenticator app',
});
if (error) throw error;
if (!data || !('totp' in data)) {
throw new Error('Unexpected enrollment response.');
}
setFactorId(data.id);
setQr(data.totp.qr_code);
setSecret(data.totp.secret);
setStage('pending');
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
async function verify(e: React.FormEvent) {
e.preventDefault();
setError(null);
setBusy(true);
try {
const { data: ch, error: ce } = await supabase.auth.mfa.challenge({
factorId,
});
if (ce) throw ce;
const { error: ve } = await supabase.auth.mfa.verify({
factorId,
challengeId: ch.id,
code: code.trim(),
});
if (ve) throw ve;
onVerified();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
if (stage === 'idle') {
return (
<div className="card">
<h2>Authenticator app</h2>
<p className="muted">
Use an app like Google Authenticator, 1Password, or Authy to generate
6-digit codes.
</p>
{error && <p className="error">{error}</p>}
<button type="button" onClick={start} disabled={busy}>
{busy ? 'Preparing…' : 'Set up authenticator app'}
</button>
</div>
);
}
return (
<div className="card">
<h2>Scan the QR code</h2>
<p className="muted">
Scan this with your authenticator app, then enter the 6-digit code to
confirm.
</p>
{qr && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={qr}
alt="TOTP QR code"
width={200}
height={200}
style={{ background: '#fff', padding: 8, borderRadius: 8 }}
/>
)}
<p className="muted" style={{ marginTop: '0.75rem' }}>
Can&apos;t scan? Enter this secret manually:
</p>
<div className="token">{secret}</div>
<form onSubmit={verify}>
<label htmlFor="totp-code">6-digit code</label>
<input
id="totp-code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
pattern="[0-9]*"
maxLength={6}
required
value={code}
onChange={(e) => setCode(e.target.value)}
/>
{error && <p className="error">{error}</p>}
<div className="row" style={{ marginTop: '1rem' }}>
<button type="submit" disabled={busy || code.trim().length < 6}>
{busy ? 'Verifying…' : 'Verify and enable'}
</button>
</div>
</form>
</div>
);
}
/** Passkey (WebAuthn) enrollment via the simplewebauthn ceremony. */
export function PasskeyEnrollPanel({
supabase,
onVerified,
}: {
supabase: SupabaseClient;
onVerified: () => void;
}) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function start() {
setError(null);
setBusy(true);
try {
await cleanupUnverified(supabase, 'webauthn');
await enrollPasskey(supabase, 'Passkey');
onVerified();
} catch (e) {
setError(passkeyErrorMessage(e));
} finally {
setBusy(false);
}
}
return (
<div className="card">
<h2>Passkey</h2>
<p className="muted">
Use your device&apos;s biometrics, screen lock, or a hardware security
key.
</p>
{error && <p className="error">{error}</p>}
<button type="button" onClick={start} disabled={busy}>
{busy ? 'Waiting for passkey…' : 'Set up a passkey'}
</button>
</div>
);
}
/** One-time display of freshly generated recovery codes. */
export function RecoveryCodesPanel({ codes }: { codes: string[] }) {
const [copied, setCopied] = useState(false);
function copy() {
navigator.clipboard
.writeText(codes.join('\n'))
.then(() => setCopied(true))
.catch(() => setCopied(false));
}
return (
<div className="card">
<h2>Save your recovery codes</h2>
<p className="muted">
Store these somewhere safe. Each code can be used once to recover access
if you lose your authenticator and passkeys. They will not be shown
again.
</p>
<div
className="token"
style={{ display: 'grid', gap: '0.25rem', lineHeight: 1.8 }}
>
{codes.map((c) => (
<span key={c}>{c}</span>
))}
</div>
<div className="row" style={{ marginTop: '1rem' }}>
<button type="button" className="secondary" onClick={copy}>
{copied ? 'Copied' : 'Copy codes'}
</button>
</div>
</div>
);
}