From 7e7407b137e94e49a54a80c8082b135fd24b9969 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 26 Apr 2026 14:04:28 +0200 Subject: [PATCH] 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) --- ui/package-lock.json | 10 + ui/package.json | 1 + ui/src/pages/tenant/SettingsPage.tsx | 321 ++++++++++++++++++++++++++- 3 files changed, 331 insertions(+), 1 deletion(-) diff --git a/ui/package-lock.json b/ui/package-lock.json index e394ac8..5a070b2 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -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", diff --git a/ui/package.json b/ui/package.json index ad48743..b0cd5bd 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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", diff --git a/ui/src/pages/tenant/SettingsPage.tsx b/ui/src/pages/tenant/SettingsPage.tsx index d97c675..87286f1 100644 --- a/ui/src/pages/tenant/SettingsPage.tsx +++ b/ui/src/pages/tenant/SettingsPage.tsx @@ -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(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 ( + +
+ +
+
+ ); + } + + // Backup codes display + if (codes) { + return ( + + + 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. + +
+ {codes.map((code) => ( + {code} + ))} +
+
+ + +
+ +
+ +
+
+ ); + } + + // Setup flow — QR code + verification + if (setupData) { + return ( + +

+ Scan this QR code with your authenticator app (Google Authenticator, Authy, 1Password, etc.), then enter the 6-digit code below. +

+
+ +
+
+ {setupData.secret} +
+
+ + setVerifyCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + placeholder="Enter 6-digit code" + required + autoComplete="one-time-code" + /> + +
+ + +
+
+
+ ); + } + + // Main view — enrolled or not + return ( + +
+ Status: + {mfaStatus?.enrolled ? ( + + ) : ( + + )} +
+ {mfaStatus?.enrolled ? ( + <> +

+ Your account is protected with a TOTP authenticator app. +

+
+ + {confirmRemove ? ( +
+ + + +
+ ) : ( + + )} +
+ + ) : ( + <> +

+ Add an extra layer of security to your account by enabling multi-factor authentication with an authenticator app. +

+
+ +
+ + )} +
+ ); +} + +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 ( + +

+ When enabled, all team members will be required to set up multi-factor authentication before accessing this tenant. +

+
+ Require MFA for all members + +
+ {confirmEnable ? ( +
+ + All team members who have not enrolled in MFA will need to set it up on their next login. Are you sure? + +
+ + +
+
+ ) : ( +
+ +
+ )} +
+ ); +} + export function SettingsPage() { const { data, isLoading, isError } = useTenantSettings(); const changePassword = useChangeOwnPassword(); @@ -182,6 +498,9 @@ export function SettingsPage() { + + + ); }