Files
cameleer-saas/ui/src/components/account/MfaSection.tsx
2026-04-27 14:53:05 +02:00

260 lines
8.9 KiB
TypeScript

import { useState } from 'react';
import { QRCodeSVG } from 'qrcode.react';
import { errorMessage } from '../../api/client';
import {
Alert,
Badge,
Button,
Card,
FormField,
Input,
Spinner,
useToast,
} from '@cameleer/design-system';
import {
useAccountMfaStatus,
useAccountMfaSetup,
useAccountMfaVerify,
useAccountBackupCodes,
useAccountMfaRemove,
} from '../../api/account-hooks';
import styles from '../../styles/platform.module.css';
export function MfaSection() {
const { toast } = useToast();
const { data: mfaStatus, isLoading: statusLoading } = useAccountMfaStatus();
const setup = useAccountMfaSetup();
const verify = useAccountMfaVerify();
const backupCodes = useAccountBackupCodes();
const remove = useAccountMfaRemove();
const [setupData, setSetupData] = useState<{ secret: string; secretQrCode: string } | null>(null);
const [verifyCode, setVerifyCode] = useState('');
const [codes, setCodes] = useState<string[] | null>(null);
const [codesSaved, setCodesSaved] = useState(false);
const [confirmRemove, setConfirmRemove] = useState(false);
async function handleStartSetup() {
try {
const data = await setup.mutateAsync();
setSetupData(data);
setVerifyCode('');
} catch (err) {
toast({ title: 'Failed to start MFA setup', description: errorMessage(err), variant: 'error' });
}
}
async function handleVerify(e: React.FormEvent) {
e.preventDefault();
if (!setupData) return;
try {
const result = await verify.mutateAsync({ secret: setupData.secret, code: verifyCode });
if (result.verified) {
const bc = await backupCodes.mutateAsync();
setCodes(bc.codes);
setSetupData(null);
setVerifyCode('');
setCodesSaved(false);
toast({ title: 'MFA enabled successfully', variant: 'success' });
} else {
toast({ title: 'Invalid code. Please try again.', variant: 'error' });
}
} catch (err) {
toast({ title: 'Verification failed', description: errorMessage(err), variant: 'error' });
}
}
async function handleRegenerateCodes() {
try {
const bc = await backupCodes.mutateAsync();
setCodes(bc.codes);
setCodesSaved(false);
toast({ title: 'Backup codes regenerated', variant: 'success' });
} catch (err) {
toast({ title: 'Failed to regenerate backup codes', description: errorMessage(err), variant: 'error' });
}
}
async function handleRemove() {
try {
await remove.mutateAsync();
setConfirmRemove(false);
setCodes(null);
setSetupData(null);
toast({ title: 'MFA removed', variant: 'success' });
} catch (err) {
toast({ title: 'Failed to remove MFA', description: errorMessage(err), variant: 'error' });
}
}
function handleCopyAll() {
if (!codes) return;
navigator.clipboard.writeText(codes.join('\n'));
toast({ title: 'Backup codes copied to clipboard', variant: 'success' });
}
function handleDownload() {
if (!codes) return;
const blob = new Blob([codes.join('\n')], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'cameleer-mfa-backup-codes.txt';
a.click();
URL.revokeObjectURL(url);
}
if (statusLoading) {
return (
<Card title="Multi-Factor Authentication">
<div style={{ display: 'flex', justifyContent: 'center', padding: 24 }}>
<Spinner />
</div>
</Card>
);
}
// Backup codes display
if (codes) {
return (
<Card title="Multi-Factor Authentication">
<Alert variant="warning" title="Save your backup codes">
These codes can be used to sign in if you lose access to your authenticator app. Each code can only be used once. Store them in a safe place.
</Alert>
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '8px 24px',
marginTop: 16,
padding: '16px',
background: 'var(--bg-inset)',
border: '1px solid var(--border)',
borderRadius: 6,
fontFamily: 'var(--font-mono, monospace)',
fontSize: '0.875rem',
}}>
{codes.map((code) => (
<span key={code}>{code}</span>
))}
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<Button variant="secondary" onClick={handleCopyAll}>Copy all</Button>
<Button variant="secondary" onClick={handleDownload}>Download .txt</Button>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 16, fontSize: '0.875rem', cursor: 'pointer' }}>
<input type="checkbox" checked={codesSaved} onChange={(e) => setCodesSaved(e.target.checked)} />
I've saved my backup codes
</label>
<div style={{ marginTop: 12 }}>
<Button variant="primary" disabled={!codesSaved} onClick={() => setCodes(null)}>
Done
</Button>
</div>
</Card>
);
}
// Setup flow — QR code + verification
if (setupData) {
return (
<Card title="Multi-Factor Authentication">
<p className={styles.description} style={{ marginTop: 0 }}>
Scan this QR code with your authenticator app (Google Authenticator, Authy, 1Password, etc.), then enter the 6-digit code below.
</p>
<div style={{ display: 'flex', justifyContent: 'center', padding: '16px 0' }}>
<QRCodeSVG value={setupData.secretQrCode} size={200} />
</div>
<div style={{
textAlign: 'center',
padding: '8px 12px',
background: 'var(--bg-inset)',
border: '1px solid var(--border)',
borderRadius: 6,
fontFamily: 'var(--font-mono, monospace)',
fontSize: '0.75rem',
wordBreak: 'break-all',
marginBottom: 16,
}}>
{setupData.secret}
</div>
<form onSubmit={handleVerify} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<FormField label="Verification code" htmlFor="mfa-code">
<Input
id="mfa-code"
type="text"
inputMode="numeric"
maxLength={6}
pattern="[0-9]{6}"
value={verifyCode}
onChange={(e) => setVerifyCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="Enter 6-digit code"
required
autoComplete="one-time-code"
/>
</FormField>
<div style={{ display: 'flex', gap: 8 }}>
<Button type="submit" variant="primary" loading={verify.isPending || backupCodes.isPending} disabled={verifyCode.length !== 6}>
Verify & Enable
</Button>
<Button type="button" variant="secondary" onClick={() => { setSetupData(null); setVerifyCode(''); }}>
Cancel
</Button>
</div>
</form>
</Card>
);
}
// Main view — enrolled or not
return (
<Card title="Multi-Factor Authentication">
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<span className={styles.description} style={{ margin: 0 }}>Status:</span>
{mfaStatus?.enrolled ? (
<Badge label="Enrolled" color="success" />
) : (
<Badge label="Not enrolled" color="auto" />
)}
</div>
{mfaStatus?.enrolled ? (
<>
<p className={styles.description} style={{ marginTop: 0 }}>
Your account is protected with a TOTP authenticator app.
</p>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<Button variant="secondary" onClick={handleRegenerateCodes} loading={backupCodes.isPending}>
Regenerate backup codes
</Button>
{confirmRemove ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Alert variant="error" title="This will disable MFA on your account." />
<Button variant="danger" onClick={handleRemove} loading={remove.isPending}>
Confirm removal
</Button>
<Button variant="secondary" onClick={() => setConfirmRemove(false)}>
Cancel
</Button>
</div>
) : (
<Button variant="danger" onClick={() => setConfirmRemove(true)}>
Remove MFA
</Button>
)}
</div>
</>
) : (
<>
<p className={styles.description} style={{ marginTop: 0 }}>
Add an extra layer of security to your account by enabling multi-factor authentication with an authenticator app.
</p>
<div>
<Button variant="primary" onClick={handleStartSetup} loading={setup.isPending}>
Set up authenticator app
</Button>
</div>
</>
)}
</Card>
);
}