260 lines
8.9 KiB
TypeScript
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>
|
|
);
|
|
}
|