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:
10
ui/package-lock.json
generated
10
ui/package-lock.json
generated
@@ -13,6 +13,7 @@
|
||||
"@logto/react": "^4.0.13",
|
||||
"@tanstack/react-query": "^5.90.0",
|
||||
"lucide-react": "^1.7.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router": "^7.13.0",
|
||||
@@ -2043,6 +2044,15 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode.react": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
|
||||
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/quick-lru": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"@logto/react": "^4.0.13",
|
||||
"@tanstack/react-query": "^5.90.0",
|
||||
"lucide-react": "^1.7.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router": "^7.13.0",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user