import { useCallback, useEffect, useMemo, useState } from 'react'; import { Input } from '@/components/ui/input'; import { TogglePill } from '@/components/ui/toggle-pill'; import { AlertTriangle, RefreshCw, Shield, ShieldCheck } from 'lucide-react'; import { MfaEnrollDialog } from '@/components/mfa/MfaEnrollDialog'; import { MfaDisableDialog } from '@/components/mfa/MfaDisableDialog'; import { MfaBackupCodesDialog } from '@/components/mfa/MfaBackupCodesDialog'; import { toast } from '@/components/ui/toast-store'; import { apiFetch } from '@/lib/api'; import { Button } from '@/components/ui/button'; import { SettingsSection } from './SettingsSection'; import { SettingsField } from './SettingsField'; import { SettingsCallout } from './SettingsCallout'; import { SettingsActions, SettingsPrimaryButton } from './SettingsActions'; import { useMastheadStats } from './MastheadStatsContext'; interface MfaStatus { enabled: boolean; backupCodesRemaining: number; sso_enforce_mfa: boolean; } interface SSOProvider { provider: string; type: 'ldap' | 'oidc'; } export function AccountSection() { const [authData, setAuthData] = useState({ oldPassword: '', newPassword: '', confirmPassword: '' }); const [isSaving, setIsSaving] = useState(false); const [mfa, setMfa] = useState(null); const [mfaLoading, setMfaLoading] = useState(true); const [hasSso, setHasSso] = useState(false); const [enrollOpen, setEnrollOpen] = useState(false); const [disableOpen, setDisableOpen] = useState(false); const [regenOpen, setRegenOpen] = useState(false); const [togglingBypass, setTogglingBypass] = useState(false); const refreshMfa = useCallback(async () => { setMfaLoading(true); try { const res = await apiFetch('/auth/mfa/status', { localOnly: true }); if (res.ok) setMfa(await res.json()); } catch { // Non-fatal; surface as disabled card. } finally { setMfaLoading(false); } }, []); useEffect(() => { refreshMfa(); apiFetch('/auth/sso/providers', { localOnly: true }) .then((r) => (r.ok ? r.json() : [])) .then((providers: SSOProvider[]) => setHasSso(providers.length > 0)) .catch(() => setHasSso(false)); }, [refreshMfa]); const handlePasswordChange = async () => { if (!authData.oldPassword || !authData.newPassword || !authData.confirmPassword) { toast.error('All fields are required'); return; } if (authData.newPassword !== authData.confirmPassword) { toast.error('New passwords do not match'); return; } if (authData.newPassword.length < 8) { toast.error('New password must be at least 8 characters'); return; } setIsSaving(true); try { const res = await apiFetch('/auth/password', { method: 'PUT', body: JSON.stringify({ oldPassword: authData.oldPassword, newPassword: authData.newPassword }), }); if (res.ok) { toast.success('Password updated successfully'); setAuthData({ oldPassword: '', newPassword: '', confirmPassword: '' }); } else { const data = await res.json().catch(() => ({})); toast.error(data?.error || 'Failed to update password'); } } catch (e: unknown) { toast.error((e as Error)?.message || 'Network error during password change'); } finally { setIsSaving(false); } }; const handleBypassToggle = async (enforce: boolean) => { setTogglingBypass(true); try { const res = await apiFetch('/auth/mfa/sso-bypass', { method: 'PUT', localOnly: true, body: JSON.stringify({ enforce }), }); if (!res.ok) { const data = await res.json().catch(() => ({})); toast.error(data?.error || data?.message || 'Could not update SSO preference'); return; } setMfa((prev) => (prev ? { ...prev, sso_enforce_mfa: enforce } : prev)); } catch (err) { const e = err as { message?: string; error?: string } | undefined; toast.error(e?.message || e?.error || 'Could not update SSO preference'); } finally { setTogglingBypass(false); } }; const passwordStrengthHelper = useMemo(() => { const pwd = authData.newPassword; if (!pwd) return '12+ chars · mixed case · one number'; if (pwd.length < 8) return 'Too short. At least 8 characters.'; if (pwd.length < 12) return 'Acceptable. 12 or more recommended.'; return 'Strong'; }, [authData.newPassword]); const newPasswordTone = authData.newPassword ? authData.newPassword.length >= 12 ? 'success' : authData.newPassword.length < 8 ? 'error' : 'warn' : 'default'; const confirmHelper = useMemo(() => { if (!authData.confirmPassword) return 'Re-enter the new password to confirm.'; return authData.confirmPassword === authData.newPassword ? 'Match' : 'Does not match the new password'; }, [authData.confirmPassword, authData.newPassword]); const confirmTone = authData.confirmPassword ? authData.confirmPassword === authData.newPassword ? 'success' : 'error' : 'default'; useMastheadStats( mfaLoading ? null : [ { label: '2FA', value: mfa?.enabled ? 'on' : 'off', tone: mfa?.enabled ? 'value' : 'warn', }, ...(mfa?.enabled && mfa.backupCodesRemaining <= 2 ? [{ label: 'BACKUP', value: `${mfa.backupCodesRemaining} left`, tone: mfa.backupCodesRemaining === 0 ? ('error' as const) : ('warn' as const), }] : []), ], ); return (
setAuthData({ ...authData, oldPassword: e.target.value })} /> setAuthData({ ...authData, newPassword: e.target.value })} /> setAuthData({ ...authData, confirmPassword: e.target.value })} /> {isSaving ? ( <> Updating ) : ( 'Update password' )} {mfaLoading ? (
Loading…
) : mfa?.enabled ? ( <>
enrolled
{mfa.backupCodesRemaining} remaining
{hasSso ? ( ) : null} ) : (
} title="Two-factor is off" subtitle="Add a time-based code from your authenticator app, every sign-in." action={ setEnrollOpen(true)}> Set up 2FA } />
)} {mfa?.enabled && mfa.backupCodesRemaining === 0 ? (
} title="No backup codes left" subtitle="Without codes, recovery needs an administrator if you lose your authenticator." action={ } />
) : null}
); }