feat: add MFA enrollment and enforcement toggle to Settings page

Adds two new sections to the tenant Settings page:
- MfaSection: TOTP authenticator setup with QR code, 6-digit verification,
  backup code display (2-column grid with copy/download), and MFA removal
- MfaEnforcementToggle: tenant admin control to require MFA for all members,
  with confirmation dialog before enabling

Installs qrcode.react for QR code rendering. Uses existing MFA hooks from
tenant-hooks.ts and design-system components.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-26 14:04:28 +02:00
parent 0a77080bca
commit 7e7407b137
3 changed files with 331 additions and 1 deletions

View File

@@ -1,4 +1,5 @@
import { useState } from 'react';
import { QRCodeSVG } from 'qrcode.react';
import {
Alert,
Badge,
@@ -9,7 +10,12 @@ import {
Spinner,
useToast,
} from '@cameleer/design-system';
import { useTenantSettings, useChangeOwnPassword, useResetServerAdminPassword } from '../../api/tenant-hooks';
import {
useTenantSettings, useChangeOwnPassword, useResetServerAdminPassword,
useMfaStatus, useMfaSetup, useMfaVerify, useMfaBackupCodes, useMfaRemove,
useUpdateTenantSettings,
} from '../../api/tenant-hooks';
import { useScopes } from '../../auth/useScopes';
import { tierColor } from '../../utils/tier';
import styles from '../../styles/platform.module.css';
@@ -23,6 +29,316 @@ function statusColor(status: string): 'success' | 'error' | 'warning' | 'auto' {
}
}
function MfaSection() {
const { toast } = useToast();
const { data: mfaStatus, isLoading: statusLoading } = useMfaStatus();
const setup = useMfaSetup();
const verify = useMfaVerify();
const backupCodes = useMfaBackupCodes();
const remove = useMfaRemove();
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: String(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: String(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: String(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: String(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>
);
}
function MfaEnforcementToggle() {
const scopes = useScopes();
const { toast } = useToast();
const { data: settings } = useTenantSettings();
const updateSettings = useUpdateTenantSettings();
const [confirmEnable, setConfirmEnable] = useState(false);
if (!scopes.has('tenant:manage')) return null;
const mfaRequired = settings?.mfaRequired ?? false;
async function handleToggle() {
if (!mfaRequired) {
setConfirmEnable(true);
return;
}
try {
await updateSettings.mutateAsync({ mfaRequired: false });
toast({ title: 'MFA requirement disabled for all members', variant: 'success' });
} catch (err) {
toast({ title: 'Failed to update MFA setting', description: String(err), variant: 'error' });
}
}
async function handleConfirmEnable() {
try {
await updateSettings.mutateAsync({ mfaRequired: true });
setConfirmEnable(false);
toast({ title: 'MFA is now required for all members', variant: 'success' });
} catch (err) {
toast({ title: 'Failed to update MFA setting', description: String(err), variant: 'error' });
}
}
return (
<Card title="MFA Enforcement">
<p className={styles.description} style={{ marginTop: 0 }}>
When enabled, all team members will be required to set up multi-factor authentication before accessing this tenant.
</p>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: '0.875rem' }}>Require MFA for all members</span>
<Badge label={mfaRequired ? 'Required' : 'Optional'} color={mfaRequired ? 'success' : 'auto'} />
</div>
{confirmEnable ? (
<div style={{ marginTop: 12 }}>
<Alert variant="warning" title="Confirm MFA requirement">
All team members who have not enrolled in MFA will need to set it up on their next login. Are you sure?
</Alert>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<Button variant="primary" onClick={handleConfirmEnable} loading={updateSettings.isPending}>
Yes, require MFA
</Button>
<Button variant="secondary" onClick={() => setConfirmEnable(false)}>
Cancel
</Button>
</div>
</div>
) : (
<div style={{ marginTop: 12 }}>
<Button
variant={mfaRequired ? 'danger' : 'primary'}
onClick={handleToggle}
loading={updateSettings.isPending}
>
{mfaRequired ? 'Disable MFA requirement' : 'Enable MFA requirement'}
</Button>
</div>
)}
</Card>
);
}
export function SettingsPage() {
const { data, isLoading, isError } = useTenantSettings();
const changePassword = useChangeOwnPassword();
@@ -182,6 +498,9 @@ export function SettingsPage() {
</div>
</form>
</Card>
<MfaSection />
<MfaEnforcementToggle />
</div>
);
}