Files
sencho/frontend/src/components/settings/AccountSection.tsx
T
SaelixCode 4475afd793 refactor(settings): replace modal with nested full-page route
Settings sections are now URL-addressable at /settings/:sectionId, rendered
nested inside EditorLayout alongside the stack sidebar. Browser back/forward
navigates between sections. Deep links (e.g. /settings/cloud-backup) load
the section directly on hard reload.

- Add react-router-dom v7; BrowserRouter wraps the full app tree
- New SettingsPage (scroll memory, Cmd+K palette), SettingsSidebar (NavLink
  active styling, back-arrow), SectionGate (visibility + tier lock card)
- Rename SectionId 'appstore' to 'app-store' so slug === SectionId
- Decouple SystemSection, DeveloperSection, AppStoreSection from modal-
  passed props; each fetches its own data on mount
- Replace onLabelsChanged prop chain with SENCHO_LABELS_CHANGED window event
- Drop onOpenSettings prop from UserProfileDropdown, HomeDashboard,
  ConfigurationStatus; each calls useNavigate directly
- Delete SettingsModal.tsx
2026-04-30 12:23:27 -04:00

250 lines
12 KiB
TypeScript

import { useCallback, useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { TogglePill } from '@/components/ui/toggle-pill';
import { Separator } from '@/components/ui/separator';
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';
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<MfaStatus | null>(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);
}
};
return (
<div className="space-y-6">
<div className="space-y-4 max-w-sm">
<div className="space-y-2">
<Label>Current Password</Label>
<Input
type="password"
value={authData.oldPassword}
onChange={(e) => setAuthData({ ...authData, oldPassword: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>New Password</Label>
<Input
type="password"
value={authData.newPassword}
onChange={(e) => setAuthData({ ...authData, newPassword: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Confirm New Password</Label>
<Input
type="password"
value={authData.confirmPassword}
onChange={(e) => setAuthData({ ...authData, confirmPassword: e.target.value })}
/>
</div>
<Button onClick={handlePasswordChange} disabled={isSaving} className="w-full">
{isSaving
? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Updating...</>
: 'Update Password'
}
</Button>
</div>
<Separator />
{/* Two-factor authentication card */}
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-5 max-w-lg">
<div className="flex items-start gap-3">
{mfa?.enabled
? <ShieldCheck className="w-5 h-5 mt-0.5 text-success" strokeWidth={1.5} />
: <Shield className="w-5 h-5 mt-0.5 text-muted-foreground" strokeWidth={1.5} />
}
<div className="flex-1">
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium">Two-factor authentication</h4>
{mfa?.enabled && <Badge variant="secondary">Enabled</Badge>}
</div>
<p className="text-sm text-muted-foreground mt-1">
{mfa?.enabled
? 'Sign-in requires a code from your authenticator app. Back up your codes somewhere safe.'
: 'Add a time-based one-time password to your account for an extra layer of security.'}
</p>
{mfaLoading ? (
<div className="mt-4 text-xs text-muted-foreground">Loading</div>
) : mfa?.enabled ? (
<div className="mt-4 space-y-3">
{mfa.backupCodesRemaining === 0 ? (
<div className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 p-3">
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0 text-destructive" strokeWidth={1.5} />
<div className="flex-1">
<div className="text-sm font-medium text-destructive">No backup codes left</div>
<div className="text-xs text-destructive/80 mt-0.5">
Regenerate a new set before you lose access to your authenticator app. Without codes, recovery needs an administrator.
</div>
<Button
variant="ghost"
size="sm"
className="mt-2 h-7 px-2 text-destructive hover:bg-destructive hover:text-destructive-foreground"
onClick={() => setRegenOpen(true)}
>
Regenerate now
</Button>
</div>
</div>
) : mfa.backupCodesRemaining <= 2 ? (
<div className="flex items-center gap-2 text-xs font-mono tabular-nums text-warning">
<AlertTriangle className="w-3.5 h-3.5 shrink-0" strokeWidth={1.5} />
<span>
{mfa.backupCodesRemaining} backup code{mfa.backupCodesRemaining === 1 ? '' : 's'} remaining, regenerate a fresh set
</span>
</div>
) : (
<div className="text-xs text-muted-foreground font-mono tabular-nums">
{mfa.backupCodesRemaining} backup codes remaining
</div>
)}
{hasSso && (
<div className="flex items-start justify-between gap-3 rounded-md border border-card-border bg-background/40 p-3">
<div>
<div className="text-sm">Require 2FA even when signing in via SSO</div>
<div className="text-xs text-muted-foreground mt-0.5">
SSO logins skip the second factor by default.
</div>
</div>
<TogglePill
checked={mfa.sso_enforce_mfa}
onChange={handleBypassToggle}
disabled={togglingBypass}
/>
</div>
)}
<div className="flex flex-wrap gap-2">
<Button variant="ghost" size="sm" onClick={() => setRegenOpen(true)}>
Regenerate backup codes
</Button>
<Button
variant="ghost"
size="sm"
className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
onClick={() => setDisableOpen(true)}
>
Disable 2FA
</Button>
</div>
</div>
) : (
<div className="mt-4">
<Button size="sm" onClick={() => setEnrollOpen(true)}>
Set up 2FA
</Button>
</div>
)}
</div>
</div>
</div>
<MfaEnrollDialog open={enrollOpen} onOpenChange={setEnrollOpen} onEnrolled={refreshMfa} />
<MfaDisableDialog open={disableOpen} onOpenChange={setDisableOpen} onDisabled={refreshMfa} />
<MfaBackupCodesDialog open={regenOpen} onOpenChange={setRegenOpen} onRegenerated={refreshMfa} />
</div>
);
}