mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 10:49:35 +00:00
4475afd793
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
250 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|