import { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback } from 'react'; import { Dialog, DialogClose, DialogContent, DialogTitle, DialogDescription, } from '@/components/ui/dialog'; import { ScrollArea } from '@/components/ui/scroll-area'; import { VisuallyHidden } from '@radix-ui/react-visually-hidden'; import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from '@/components/ui/command'; import { toast } from '@/components/ui/toast-store'; import { apiFetch } from '@/lib/api'; import { cn } from '@/lib/utils'; import { SENCHO_SETTINGS_CHANGED } from '@/lib/events'; import type { SenchoSettingsChangedDetail } from '@/lib/events'; import { Search, X, Lock } from 'lucide-react'; import { NodeManager } from './NodeManager'; import { useNodes } from '@/context/NodeContext'; import { useAuth } from '@/context/AuthContext'; import { useLicense } from '@/context/LicenseContext'; import { SSOSection } from './SSOSection'; import { ApiTokensSection } from './ApiTokensSection'; import { RegistriesSection } from './RegistriesSection'; import { AccountSection, AppearanceSection, LicenseSection, UsersSection, SystemSection, NotificationsSection, NotificationRoutingSection, WebhooksSection, SecuritySection, CloudBackupSection, DeveloperSection, AppStoreSection, SupportSection, AboutSection, LabelsSection, DEFAULT_SETTINGS, SETTINGS_GROUPS, SETTINGS_ITEMS, getSettingsItem, getSettingsGroup, isItemVisible, isItemLocked, } from './settings'; import type { PatchableSettings, SectionId, SettingsItemMeta, VisibilityContext, } from './settings'; interface SettingsModalProps { isOpen: boolean; onClose: () => void; initialSection?: SectionId; onLabelsChanged?: () => void; } export function SettingsModal({ isOpen, onClose, initialSection, onLabelsChanged }: SettingsModalProps) { const { activeNode } = useNodes(); const { isAdmin } = useAuth(); const { license, isPaid } = useLicense(); const isRemote = activeNode?.type === 'remote'; const isAdmiral = isPaid && license?.variant === 'admiral'; const [activeSection, setActiveSection] = useState(initialSection || 'account'); const [commandOpen, setCommandOpen] = useState(false); const visibility: VisibilityContext = useMemo( () => ({ isRemote, isAdmin, isPaid, isAdmiral }), [isRemote, isAdmin, isPaid, isAdmiral], ); const visibleItems = useMemo( () => SETTINGS_ITEMS.filter(item => isItemVisible(item, visibility)), [visibility], ); const visibleGroups = useMemo(() => { return SETTINGS_GROUPS .map(group => ({ ...group, items: visibleItems.filter(item => item.group === group.id), })) .filter(group => group.items.length > 0); }, [visibleItems]); const contentViewportRef = useRef(null); const scrollPositionsRef = useRef>>({}); const switchSection = useCallback((next: SectionId) => { if (contentViewportRef.current) { scrollPositionsRef.current[activeSection] = contentViewportRef.current.scrollTop; } setActiveSection(next); }, [activeSection]); useLayoutEffect(() => { if (contentViewportRef.current) { contentViewportRef.current.scrollTop = scrollPositionsRef.current[activeSection] ?? 0; } }, [activeSection]); useEffect(() => { if (isOpen && initialSection) setActiveSection(initialSection); }, [isOpen, initialSection]); useEffect(() => { const current = getSettingsItem(activeSection); if (!current) return; if (!isItemVisible(current, visibility) && visibleItems.length > 0) { setActiveSection(visibleItems[0].id); } }, [visibility, visibleItems, activeSection]); const handleDialogKeyDown = useCallback((event: React.KeyboardEvent) => { if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { event.preventDefault(); event.stopPropagation(); setCommandOpen(open => !open); } }, []); const [authData, setAuthData] = useState({ oldPassword: '', newPassword: '', confirmPassword: '' }); const [isSavingPassword, setIsSavingPassword] = useState(false); const [settings, setSettings] = useState({ ...DEFAULT_SETTINGS }); const serverSettingsRef = useRef({ ...DEFAULT_SETTINGS }); const [isSettingsLoading, setIsSettingsLoading] = useState(false); const [isSavingSystem, setIsSavingSystem] = useState(false); const [isSavingDeveloper, setIsSavingDeveloper] = useState(false); const hasSystemChanges = settings.host_cpu_limit !== serverSettingsRef.current.host_cpu_limit || settings.host_ram_limit !== serverSettingsRef.current.host_ram_limit || settings.host_disk_limit !== serverSettingsRef.current.host_disk_limit || settings.docker_janitor_gb !== serverSettingsRef.current.docker_janitor_gb || settings.global_crash !== serverSettingsRef.current.global_crash; const hasDeveloperChanges = settings.developer_mode !== serverSettingsRef.current.developer_mode || settings.metrics_retention_hours !== serverSettingsRef.current.metrics_retention_hours || settings.log_retention_days !== serverSettingsRef.current.log_retention_days || settings.audit_retention_days !== serverSettingsRef.current.audit_retention_days; const sectionDirtyFlags: Partial> = { system: hasSystemChanges, developer: hasDeveloperChanges, }; useEffect(() => { if (isOpen) fetchSettings(); }, [isOpen, activeNode?.id]); // eslint-disable-line react-hooks/exhaustive-deps const fetchSettings = async () => { setIsSettingsLoading(true); try { const nodeRes = await apiFetch('/settings'); const localRes = isRemote ? await apiFetch('/settings', { localOnly: true }) : nodeRes; const nodeData: Record = nodeRes.ok ? await nodeRes.json() : {}; const localData: Record = (isRemote && localRes.ok) ? await localRes.json() : nodeData; const safe: PatchableSettings = { host_cpu_limit: nodeData.host_cpu_limit ?? DEFAULT_SETTINGS.host_cpu_limit, host_ram_limit: nodeData.host_ram_limit ?? DEFAULT_SETTINGS.host_ram_limit, host_disk_limit: nodeData.host_disk_limit ?? DEFAULT_SETTINGS.host_disk_limit, docker_janitor_gb: nodeData.docker_janitor_gb ?? DEFAULT_SETTINGS.docker_janitor_gb, global_crash: (nodeData.global_crash as '0' | '1') ?? DEFAULT_SETTINGS.global_crash, template_registry_url: nodeData.template_registry_url ?? '', developer_mode: (localData.developer_mode as '0' | '1') ?? DEFAULT_SETTINGS.developer_mode, metrics_retention_hours: localData.metrics_retention_hours ?? DEFAULT_SETTINGS.metrics_retention_hours, log_retention_days: localData.log_retention_days ?? DEFAULT_SETTINGS.log_retention_days, audit_retention_days: localData.audit_retention_days ?? DEFAULT_SETTINGS.audit_retention_days, }; setSettings(safe); serverSettingsRef.current = { ...safe }; } catch (e) { console.error('Failed to fetch settings', e); } finally { setIsSettingsLoading(false); } }; const handleSettingChange = (key: K, value: PatchableSettings[K]) => { setSettings(prev => ({ ...prev, [key]: value })); }; const patchSettings = async (payload: PatchableSettings, setLoading: (v: boolean) => void, localOnly = false): Promise => { setLoading(true); try { const res = await apiFetch('/settings', { method: 'PATCH', body: JSON.stringify(payload), localOnly, }); if (!res.ok) { const err = await res.json().catch(() => ({})); toast.error(err?.error || err?.message || 'Failed to save settings.'); return false; } serverSettingsRef.current = { ...serverSettingsRef.current, ...payload }; return true; } catch (e: unknown) { toast.error((e as Error)?.message || 'Something went wrong.'); return false; } finally { setLoading(false); } }; const saveSystemSettings = async () => { const ok = await patchSettings({ host_cpu_limit: settings.host_cpu_limit, host_ram_limit: settings.host_ram_limit, host_disk_limit: settings.host_disk_limit, docker_janitor_gb: settings.docker_janitor_gb, global_crash: settings.global_crash, }, setIsSavingSystem); if (ok) toast.success('System limits saved.'); }; const saveDeveloperSettings = async () => { const payload = { developer_mode: settings.developer_mode, metrics_retention_hours: settings.metrics_retention_hours, log_retention_days: settings.log_retention_days, audit_retention_days: settings.audit_retention_days, }; const ok = await patchSettings(payload, setIsSavingDeveloper, true); if (ok) { toast.success('Developer settings saved.'); window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED, { detail: { changedKeys: Object.keys(payload) }, })); } }; 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; } setIsSavingPassword(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 { setIsSavingPassword(false); } }; const handleRegistrySaved = (key: keyof PatchableSettings, value: string) => { serverSettingsRef.current = { ...serverSettingsRef.current, [key]: value }; }; const activeItem = getSettingsItem(activeSection); const activeGroup = activeItem ? getSettingsGroup(activeItem.group) : undefined; const renderSection = () => { switch (activeSection) { case 'account': return ( ); case 'appearance': return ; case 'license': return ; case 'users': return ; case 'sso': return ; case 'api-tokens': return ; case 'registries': return ; case 'labels': return ; case 'system': return ( ); case 'notifications': return ; case 'notification-routing': return ; case 'webhooks': return ; case 'security': return ; case 'cloud-backup': return ; case 'developer': return ( ); case 'nodes': return ; case 'appstore': return ( ); case 'support': return ; case 'about': return ; default: return null; } }; return ( <> !open && onClose()}> Settings Hub Configure Sencho settings
Settings {`\u203A`} {activeGroup?.label ?? ''} {`\u203A`} {activeItem?.label ?? ''} {activeItem?.scope === 'node' ? ( · {activeNode?.name ?? 'local'} (node-scoped) ) : null}

{activeItem?.label ?? 'Settings'}

{activeItem?.description ? (
{activeItem.description}
) : null}
Close
{renderSection()}
No matching settings. {visibleGroups.map(group => ( {group.items.map(item => ( { setCommandOpen(false); switchSection(item.id); }} /> ))} ))} ); } function CommandSearchItem({ item, glyph, visibility, onSelect, }: { item: SettingsItemMeta; glyph: string; visibility: VisibilityContext; onSelect: () => void; }) { const locked = isItemLocked(item, visibility); const searchValue = [item.label, item.description, ...item.keywords].join(' ').toLowerCase(); return ( {glyph}
{item.label} {item.description}
{item.tier ? : null}
); } function TierChip({ tier, locked }: { tier: NonNullable; locked: boolean }) { const label = tier === 'admiral' ? 'ADMIRAL' : 'SKIPPER'; if (locked) { return ( {label} ); } return ( {label} ); }