diff --git a/ui/src/api/tenant-hooks.ts b/ui/src/api/tenant-hooks.ts index c4dd3a5..d5ea2fe 100644 --- a/ui/src/api/tenant-hooks.ts +++ b/ui/src/api/tenant-hooks.ts @@ -1,6 +1,19 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { api } from './client'; -import type { DashboardData, TenantLicenseData, TenantSettings, AuditLogPage, AuditLogFilters, SsoConnector, CreateSsoConnectorRequest, SsoTestResult, MfaStatus, MfaSetupResponse, BackupCodesResponse, PasskeyCredential, AuthPolicy } from '../types/api'; +import type { DashboardData, TenantLicenseData, TenantSettings, AuditLogPage, AuditLogFilters, SsoConnector, CreateSsoConnectorRequest, SsoTestResult, AuthPolicy } from '../types/api'; + +// Re-export account hooks for backward compatibility +export { + useAccountMfaStatus as useMfaStatus, + useAccountMfaSetup as useMfaSetup, + useAccountMfaVerify as useMfaVerify, + useAccountBackupCodes as useMfaBackupCodes, + useAccountMfaRemove as useMfaRemove, + useAccountPasskeyList as usePasskeyList, + useAccountRenamePasskey as useRenamePasskey, + useAccountDeletePasskey as useDeletePasskey, + useAccountMfaPreference as useUpdateMfaMethodPreference, +} from './account-hooks'; export function useTenantDashboard() { return useQuery({ @@ -121,6 +134,14 @@ export function useResetTeamMemberPassword() { }); } +export function useResetTeamMemberMfa() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (userId) => api.delete(`/tenant/users/${userId}/mfa`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'team'] }), + }); +} + export function useTenantSettings() { return useQuery({ queryKey: ['tenant', 'settings'], @@ -128,6 +149,14 @@ export function useTenantSettings() { }); } +export function useUpdateTenantSettings() { + const qc = useQueryClient(); + return useMutation>({ + mutationFn: (updates) => api.patch('/tenant/settings', updates), + onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'settings'] }), + }); +} + export function useTenantAuditLog(filters: Omit) { const params = new URLSearchParams(); if (filters.action) params.set('action', filters.action); @@ -144,90 +173,6 @@ export function useTenantAuditLog(filters: Omit) { }); } -// MFA hooks -export function useMfaStatus() { - return useQuery({ - queryKey: ['tenant', 'mfa', 'status'], - queryFn: () => api.get('/tenant/mfa/status'), - }); -} - -export function useMfaSetup() { - return useMutation({ - mutationFn: () => api.post('/tenant/mfa/totp/setup'), - }); -} - -export function useMfaVerify() { - const qc = useQueryClient(); - return useMutation<{ verified: boolean }, Error, { secret: string; code: string }>({ - mutationFn: (body) => api.post('/tenant/mfa/totp/verify', body), - onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'mfa'] }), - }); -} - -export function useMfaBackupCodes() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: () => api.post('/tenant/mfa/backup-codes'), - onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'mfa'] }), - }); -} - -export function useMfaRemove() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: () => api.delete('/tenant/mfa/totp'), - onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'mfa'] }), - }); -} - -export function useResetTeamMemberMfa() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (userId) => api.delete(`/tenant/users/${userId}/mfa`), - onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'team'] }), - }); -} - -export function useUpdateTenantSettings() { - const qc = useQueryClient(); - return useMutation>({ - mutationFn: (updates) => api.patch('/tenant/settings', updates), - onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'settings'] }), - }); -} - -// Passkey hooks -export function usePasskeyList() { - return useQuery({ - queryKey: ['tenant', 'mfa', 'webauthn'], - queryFn: () => api.get('/tenant/mfa/webauthn'), - }); -} - -export function useRenamePasskey() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ id, name }) => api.patch(`/tenant/mfa/webauthn/${id}/name`, { name }), - onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'mfa'] }), - }); -} - -export function useDeletePasskey() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (id) => api.delete(`/tenant/mfa/webauthn/${id}`), - onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'mfa'] }), - }); -} - -export function useUpdateMfaMethodPreference() { - return useMutation({ - mutationFn: (preference) => api.post('/tenant/mfa/method-preference', { preference }), - }); -} - // Auth settings hooks export function useTenantAuthSettings() { return useQuery({ diff --git a/ui/src/pages/tenant/SettingsPage.tsx b/ui/src/pages/tenant/SettingsPage.tsx index 842881b..3a22e7c 100644 --- a/ui/src/pages/tenant/SettingsPage.tsx +++ b/ui/src/pages/tenant/SettingsPage.tsx @@ -1,5 +1,4 @@ import { useState } from 'react'; -import { QRCodeSVG } from 'qrcode.react'; import { errorMessage } from '../../api/client'; import { Alert, @@ -12,11 +11,14 @@ import { useToast, } from '@cameleer/design-system'; import { - useTenantSettings, useChangeOwnPassword, useResetServerAdminPassword, - useMfaStatus, useMfaSetup, useMfaVerify, useMfaBackupCodes, useMfaRemove, - useUpdateTenantSettings, usePasskeyList, useRenamePasskey, useDeletePasskey, + useTenantSettings, + useResetServerAdminPassword, + useUpdateTenantSettings, useTenantAuthSettings, useUpdateTenantAuthSettings, } from '../../api/tenant-hooks'; +import { MfaSection } from '../../components/account/MfaSection'; +import { PasskeyNudgeBanner, PasskeySection } from '../../components/account/PasskeySection'; +import { PasswordChangeSection } from '../../components/account/PasswordChangeSection'; import { useScopes } from '../../auth/useScopes'; import { tierColor } from '../../utils/tier'; import styles from '../../styles/platform.module.css'; @@ -31,244 +33,6 @@ 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: 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 ( - -
- -
-
- ); - } - - // 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(); @@ -341,126 +105,6 @@ function MfaEnforcementToggle() { ); } -function PasskeyNudgeBanner() { - const { data: status } = useMfaStatus(); - const [dismissed, setDismissed] = useState(false); - - const lastDismissed = localStorage.getItem('passkey_nudge_dismissed'); - const recentlyDismissed = lastDismissed && (Date.now() - Number(lastDismissed)) < 30 * 24 * 60 * 60 * 1000; - - if (dismissed || recentlyDismissed || !status || status.passkeyEnrolled) return null; - - function handleDismiss() { - localStorage.setItem('passkey_nudge_dismissed', String(Date.now())); - setDismissed(true); - } - - return ( - -

- Use your fingerprint, face, or security key instead of typing a code every time. -

- -
- ); -} - -function PasskeySection() { - const { toast } = useToast(); - const { data: passkeys, isLoading } = usePasskeyList(); - const renamePasskey = useRenamePasskey(); - const deletePasskey = useDeletePasskey(); - const [editingId, setEditingId] = useState(null); - const [editName, setEditName] = useState(''); - const [confirmDeleteId, setConfirmDeleteId] = useState(null); - - function parseAgent(agent: string | null): string { - if (!agent) return 'Unknown device'; - if (agent.includes('Chrome')) return agent.includes('Windows') ? 'Chrome on Windows' : agent.includes('Mac') ? 'Chrome on macOS' : agent.includes('Android') ? 'Chrome on Android' : 'Chrome'; - if (agent.includes('Safari') && !agent.includes('Chrome')) return agent.includes('iPhone') ? 'Safari on iPhone' : 'Safari on macOS'; - if (agent.includes('Firefox')) return 'Firefox'; - if (agent.includes('Edge')) return 'Edge'; - return 'Browser'; - } - - function startRename(id: string, currentName: string | null) { - setEditingId(id); - setEditName(currentName ?? ''); - } - - async function handleRename(id: string) { - try { - await renamePasskey.mutateAsync({ id, name: editName }); - setEditingId(null); - toast({ title: 'Passkey renamed', variant: 'success' }); - } catch (err) { - toast({ title: 'Failed to rename passkey', description: errorMessage(err), variant: 'error' }); - } - } - - async function handleDelete(id: string) { - try { - await deletePasskey.mutateAsync(id); - setConfirmDeleteId(null); - toast({ title: 'Passkey removed', variant: 'success' }); - } catch (err) { - toast({ title: 'Failed to remove passkey', description: errorMessage(err), variant: 'error' }); - } - } - - if (isLoading) return null; - const credentials = passkeys ?? []; - - return ( - -

- Use your fingerprint, face, or security key to sign in faster. -

- {credentials.length === 0 ? ( -

- No passkeys registered. Passkeys can be registered during sign-in when prompted. -

- ) : ( -
- {credentials.map((pk) => ( -
-
- {editingId === pk.id ? ( -
- setEditName(e.target.value)} placeholder="Passkey name" style={{ maxWidth: 200 }} /> - - -
- ) : ( - <> -
{pk.name || 'Unnamed passkey'}
-
- {parseAgent(pk.agent)} · Added {pk.createdAt ? new Date(pk.createdAt).toLocaleDateString() : 'unknown'} -
- - )} -
- {editingId !== pk.id && ( -
- - {confirmDeleteId === pk.id ? ( - <> - - - - ) : ( - - )} -
- )} -
- ))} -
- )} -
- ); -} - function AuthPolicySection() { const scopes = useScopes(); const { toast } = useToast(); @@ -544,34 +188,11 @@ function AuthPolicySection() { export function SettingsPage() { const { data, isLoading, isError } = useTenantSettings(); - const changePassword = useChangeOwnPassword(); const resetServerAdmin = useResetServerAdminPassword(); const { toast } = useToast(); - const [newPassword, setNewPassword] = useState(''); - const [confirmPassword, setConfirmPassword] = useState(''); const [serverAdminPw, setServerAdminPw] = useState(''); - async function handleChangePassword(e: React.FormEvent) { - e.preventDefault(); - if (newPassword.length < 8) { - toast({ title: 'Password must be at least 8 characters', variant: 'error' }); - return; - } - if (newPassword !== confirmPassword) { - toast({ title: 'Passwords do not match', variant: 'error' }); - return; - } - try { - await changePassword.mutateAsync(newPassword); - toast({ title: 'Password changed successfully', variant: 'success' }); - setNewPassword(''); - setConfirmPassword(''); - } catch (err) { - toast({ title: 'Failed to change password', description: errorMessage(err), variant: 'error' }); - } - } - if (isLoading) { return (
@@ -628,40 +249,7 @@ export function SettingsPage() {

- -

- Update your login password. Minimum 8 characters. -

-
- - setNewPassword(e.target.value)} - placeholder="Enter new password" - required - minLength={8} - /> - - - setConfirmPassword(e.target.value)} - placeholder="Confirm new password" - required - minLength={8} - /> - -
- -
-
-
+