diff --git a/e2e/mfa.spec.ts b/e2e/mfa.spec.ts index 61104811..e7c05015 100644 --- a/e2e/mfa.spec.ts +++ b/e2e/mfa.spec.ts @@ -25,7 +25,7 @@ async function logout(page: Page) { async function openAccountSettings(page: Page) { await page.getByRole('button', { name: /profile/i }).click(); - await page.getByRole('button', { name: /settings/i }).click(); + await page.getByRole('button', { name: 'Settings', exact: true }).click(); await expect(page.getByRole('heading', { name: /^Account$/i })).toBeVisible(); } diff --git a/e2e/nodes.spec.ts b/e2e/nodes.spec.ts index a6582619..8efcd3d9 100644 --- a/e2e/nodes.spec.ts +++ b/e2e/nodes.spec.ts @@ -10,8 +10,8 @@ test.describe('Node management', () => { await loginAs(page); // Settings is inside the User Profile Dropdown - open it first await page.getByRole('button', { name: /profile/i }).click(); - await page.getByRole('button', { name: /settings/i }).click(); - await page.getByRole('button', { name: /^nodes$/i }).click(); + await page.getByRole('button', { name: 'Settings', exact: true }).click(); + await page.getByRole('link', { name: /^nodes$/i }).click(); }); /** diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 506dc403..bbd0001f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -46,6 +46,7 @@ "react-day-picker": "^9.14.0", "react-dom": "^19.2.5", "react-is": "^19.2.5", + "react-router-dom": "^7.14.2", "react-use-measure": "^2.1.7", "recharts": "^3.8.1", "tailwind-merge": "^3.5.0", @@ -4345,6 +4346,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cronstrue": { "version": "3.14.0", "resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-3.14.0.tgz", @@ -6508,6 +6522,44 @@ } } }, + "node_modules/react-router": { + "version": "7.14.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz", + "integrity": "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.14.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.2.tgz", + "integrity": "sha512-YZcM5ES8jJSM+KrJ9BdvHHqlnGTg5tH3sC5ChFRj4inosKctdyzBDhOyyHdGk597q2OT6NTrCA1OvB/YDwfekQ==", + "license": "MIT", + "dependencies": { + "react-router": "7.14.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/react-style-singleton": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", @@ -6734,6 +6786,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 01682882..4a485f7a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -50,6 +50,7 @@ "react-day-picker": "^9.14.0", "react-dom": "^19.2.5", "react-is": "^19.2.5", + "react-router-dom": "^7.14.2", "react-use-measure": "^2.1.7", "recharts": "^3.8.1", "tailwind-merge": "^3.5.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f028bc7d..03b4610d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,3 +1,4 @@ +import { BrowserRouter } from 'react-router-dom'; import { AuthProvider, useAuth } from './context/AuthContext'; import { NodeProvider } from './context/NodeContext'; import { LicenseProvider } from './context/LicenseContext'; @@ -44,13 +45,15 @@ import { ToastContainer } from './components/ui/toast'; function App() { return ( - - - - - - - + + + + + + + + + ); } diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 4a761179..1a18c3c8 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -7,7 +7,6 @@ import TerminalComponent from './Terminal'; import ErrorBoundary from './ErrorBoundary'; import HomeDashboard from './HomeDashboard'; import type { NotificationItem } from './dashboard/types'; -import type { SectionId } from './settings/types'; import BashExecModal from './BashExecModal'; import HostConsole from './HostConsole'; import { AdmiralGate } from './AdmiralGate'; @@ -37,7 +36,8 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSepara import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { TopBar } from './TopBar'; import { cn } from '@/lib/utils'; -import { SettingsModal } from './SettingsModal'; +import { Routes, Route, Navigate, useMatch, useNavigate } from 'react-router-dom'; +import { SettingsPage } from './settings/SettingsPage'; import { StackAlertSheet } from './StackAlertSheet'; import { StackAutoHealSheet } from '@/components/StackAutoHealSheet'; import { GitSourcePanel } from './stack/GitSourcePanel'; @@ -61,7 +61,7 @@ import { GlobalCommandPaletteTrigger, } from './GlobalCommandPalette'; import { useCrossNodeStackSearch } from '@/hooks/useCrossNodeStackSearch'; -import { SENCHO_OPEN_LOGS_EVENT } from '@/lib/events'; +import { SENCHO_OPEN_LOGS_EVENT, SENCHO_LABELS_CHANGED } from '@/lib/events'; import type { SenchoOpenLogsDetail } from '@/lib/events'; import { useNodes } from '@/context/NodeContext'; import type { Node } from '@/context/NodeContext'; @@ -359,11 +359,12 @@ export default function EditorLayout() { const [autoUpdateSettings, setAutoUpdateSettings] = useState>({}); const isAdmiral = license?.variant === 'admiral'; - // Notifications & Settings state + const navigate = useNavigate(); + const isSettingsRoute = !!useMatch({ path: '/settings/*', end: false }); + + // Notifications state const [notifications, setNotifications] = useState([]); const [tickerConnected, setTickerConnected] = useState(false); - const [settingsModalOpen, setSettingsModalOpen] = useState(false); - const [settingsInitialSection, setSettingsInitialSection] = useState('account'); const [alertSheetOpen, setAlertSheetOpen] = useState(false); const [alertSheetStack, setAlertSheetStack] = useState(''); const [autoHealStackName, setAutoHealStackName] = useState(null); @@ -573,6 +574,12 @@ export default function EditorLayout() { } }, [isPaid]); + useEffect(() => { + const handler = () => refreshLabels(); + window.addEventListener(SENCHO_LABELS_CHANGED, handler); + return () => window.removeEventListener(SENCHO_LABELS_CHANGED, handler); + }, [refreshLabels]); + /** * Populate the per-stack "pending git source update" map. Runs on mount and * whenever a git-source change is signalled by the panel. Backend failure @@ -2040,7 +2047,7 @@ export default function EditorLayout() { toast.dismiss(loadingId); } }, - openLabelManager: () => { setSettingsInitialSection('labels'); setSettingsModalOpen(true); }, + openLabelManager: () => navigate('/settings/labels'), openScheduleTask: () => { const stackName = file.replace(/\.(yml|yaml)$/, ''); setSchedulePrefill({ stackName, nodeId: activeNode?.id ?? null }); @@ -2266,7 +2273,7 @@ export default function EditorLayout() { isDarkMode={isDarkMode} nodeSwitcherSlot={ { setSettingsInitialSection('nodes'); setSettingsModalOpen(true); }} + onManageNodes={() => navigate('/settings/nodes')} /> } createStackSlot={createStackSlot} @@ -2328,12 +2335,19 @@ export default function EditorLayout() { setSettingsModalOpen(true)} /> } /> {/* Main Workspace */} + {isSettingsRoute ? ( +
+ + } /> + } /> + +
+ ) : (
{activeView === 'templates' ? ( { refreshStacks(); loadFile(stackName); }} /> @@ -2917,10 +2931,10 @@ export default function EditorLayout() { onNavigateToStack={(stackFile) => { loadFile(stackFile); }} notifications={notifications} onClearNotifications={clearAllNotifications} - onOpenSettings={(section) => { setSettingsInitialSection(section); setSettingsModalOpen(true); }} /> )}
+ )} {/* Delete Confirmation Dialog */} @@ -3055,14 +3069,6 @@ export default function EditorLayout() { )} - {/* Settings Modal */} - { setSettingsModalOpen(false); setSettingsInitialSection('account'); }} - initialSection={settingsInitialSection} - onLabelsChanged={refreshLabels} - /> - {/* Stack Alert Sheet */} void; notifications: NotificationItem[]; onClearNotifications: () => void | Promise; - onOpenSettings?: (section: SectionId) => void; } const NOOP = () => {}; -export default function HomeDashboard({ onNavigateToStack, notifications, onClearNotifications, onOpenSettings }: HomeDashboardProps) { +export default function HomeDashboard({ onNavigateToStack, notifications, onClearNotifications }: HomeDashboardProps) { const { activeNode, nodes } = useNodes(); const data = useDashboardData(); const activeNodeName = activeNode?.name || 'Local'; @@ -52,7 +50,7 @@ export default function HomeDashboard({ onNavigateToStack, notifications, onClea />
- +
diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx deleted file mode 100644 index 255f9ed4..00000000 --- a/frontend/src/components/SettingsModal.tsx +++ /dev/null @@ -1,538 +0,0 @@ -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} - - ); -} diff --git a/frontend/src/components/UserProfileDropdown.tsx b/frontend/src/components/UserProfileDropdown.tsx index 6f9e2780..7581d35a 100644 --- a/frontend/src/components/UserProfileDropdown.tsx +++ b/frontend/src/components/UserProfileDropdown.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { Settings, LogOut, @@ -28,7 +29,6 @@ type Theme = 'light' | 'dark' | 'auto'; interface UserProfileDropdownProps { theme: Theme; setTheme: (theme: Theme) => void; - onOpenSettings: () => void; } const THEME_OPTIONS = [ @@ -48,7 +48,8 @@ function getInitials(username: string | undefined): string { return trimmed.slice(0, 2).toUpperCase(); } -export function UserProfileDropdown({ theme, setTheme, onOpenSettings }: UserProfileDropdownProps) { +export function UserProfileDropdown({ theme, setTheme }: UserProfileDropdownProps) { + const navigate = useNavigate(); const { logout, user, isAdmin } = useAuth(); const { license } = useLicense(); const [billingLoading, setBillingLoading] = useState(false); @@ -131,7 +132,7 @@ export function UserProfileDropdown({ theme, setTheme, onOpenSettings }: UserPro {/* Navigation strip */}
- + navigate('/settings')} /> {showBilling ? ( void; -} - function StatusBadge({ value, locked, requiredTier }: { value: string; locked?: boolean; @@ -94,10 +91,11 @@ function SkeletonRow() { ); } -export function ConfigurationStatus({ onOpenSettings }: ConfigurationStatusProps) { +export function ConfigurationStatus() { + const navigate = useNavigate(); const { status, loading } = useConfigurationStatus(); - const open = (section: SectionId) => () => onOpenSettings?.(section); + const open = (section: SectionId) => () => navigate(`/settings/${section}`); if (loading) { return ( diff --git a/frontend/src/components/settings/AccountSection.tsx b/frontend/src/components/settings/AccountSection.tsx index af4571a1..0cdcee17 100644 --- a/frontend/src/components/settings/AccountSection.tsx +++ b/frontend/src/components/settings/AccountSection.tsx @@ -12,13 +12,6 @@ import { MfaBackupCodesDialog } from '@/components/mfa/MfaBackupCodesDialog'; import { toast } from '@/components/ui/toast-store'; import { apiFetch } from '@/lib/api'; -interface AccountSectionProps { - authData: { oldPassword: string; newPassword: string; confirmPassword: string }; - onAuthDataChange: (data: { oldPassword: string; newPassword: string; confirmPassword: string }) => void; - onPasswordChange: () => Promise; - isSaving: boolean; -} - interface MfaStatus { enabled: boolean; backupCodesRemaining: number; @@ -30,7 +23,10 @@ interface SSOProvider { type: 'ldap' | 'oidc'; } -export function AccountSection({ authData, onAuthDataChange, onPasswordChange, isSaving }: AccountSectionProps) { +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); @@ -59,6 +55,39 @@ export function AccountSection({ authData, onAuthDataChange, onPasswordChange, i .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 { @@ -89,7 +118,7 @@ export function AccountSection({ authData, onAuthDataChange, onPasswordChange, i onAuthDataChange({ ...authData, oldPassword: e.target.value })} + onChange={(e) => setAuthData({ ...authData, oldPassword: e.target.value })} />
@@ -97,7 +126,7 @@ export function AccountSection({ authData, onAuthDataChange, onPasswordChange, i onAuthDataChange({ ...authData, newPassword: e.target.value })} + onChange={(e) => setAuthData({ ...authData, newPassword: e.target.value })} />
@@ -105,10 +134,10 @@ export function AccountSection({ authData, onAuthDataChange, onPasswordChange, i onAuthDataChange({ ...authData, confirmPassword: e.target.value })} + onChange={(e) => setAuthData({ ...authData, confirmPassword: e.target.value })} />
- diff --git a/frontend/src/components/settings/DeveloperSection.tsx b/frontend/src/components/settings/DeveloperSection.tsx index 5f24c428..2d51f287 100644 --- a/frontend/src/components/settings/DeveloperSection.tsx +++ b/frontend/src/components/settings/DeveloperSection.tsx @@ -1,3 +1,4 @@ +import { useState, useRef, useEffect } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -5,14 +6,16 @@ import { TogglePill } from '@/components/ui/toggle-pill'; import { Skeleton } from '@/components/ui/skeleton'; import { useLicense } from '@/context/LicenseContext'; import { RefreshCw, Database } from 'lucide-react'; +import { apiFetch } from '@/lib/api'; +import { toast } from '@/components/ui/toast-store'; +import { useNodes } from '@/context/NodeContext'; +import { SENCHO_SETTINGS_CHANGED } from '@/lib/events'; +import type { SenchoSettingsChangedDetail } from '@/lib/events'; +import { DEFAULT_SETTINGS } from './types'; import type { PatchableSettings } from './types'; interface DeveloperSectionProps { - settings: PatchableSettings; - onSettingChange: (key: K, value: PatchableSettings[K]) => void; - onSave: () => Promise; - isSaving: boolean; - isLoading: boolean; + onDirtyChange?: (dirty: boolean) => void; } function SettingsSkeleton() { @@ -29,8 +32,96 @@ function SettingsSkeleton() { ); } -export function DeveloperSection({ settings, onSettingChange, onSave, isSaving, isLoading }: DeveloperSectionProps) { +type DeveloperFields = Pick; + +const DEFAULT_DEVELOPER: DeveloperFields = { + developer_mode: DEFAULT_SETTINGS.developer_mode, + metrics_retention_hours: DEFAULT_SETTINGS.metrics_retention_hours, + log_retention_days: DEFAULT_SETTINGS.log_retention_days, + audit_retention_days: DEFAULT_SETTINGS.audit_retention_days, +}; + +export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) { const { isPaid, license } = useLicense(); + const { activeNode } = useNodes(); + const [settings, setSettings] = useState({ ...DEFAULT_DEVELOPER }); + const serverSettingsRef = useRef({ ...DEFAULT_DEVELOPER }); + const [isLoading, setIsLoading] = useState(false); + const [isSaving, setIsSaving] = useState(false); + + const hasChanges = + 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; + + useEffect(() => { + onDirtyChange?.(hasChanges); + }, [hasChanges, onDirtyChange]); + + useEffect(() => { + const fetchSettings = async () => { + setIsLoading(true); + try { + const isRemote = activeNode?.type === 'remote'; + 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: DeveloperFields = { + 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 developer settings', e); + } finally { + setIsLoading(false); + } + }; + fetchSettings(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeNode?.id]); + + const onSettingChange = (key: K, value: DeveloperFields[K]) => { + setSettings(prev => ({ ...prev, [key]: value })); + }; + + const saveSettings = 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, + }; + setIsSaving(true); + try { + const res = await apiFetch('/settings', { + method: 'PATCH', + body: JSON.stringify(payload), + localOnly: true, + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + toast.error(err?.error || err?.message || 'Failed to save settings.'); + return; + } + serverSettingsRef.current = { ...settings }; + toast.success('Developer settings saved.'); + window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED, { + detail: { changedKeys: Object.keys(payload) }, + })); + } catch (e: unknown) { + toast.error((e as Error)?.message || 'Something went wrong.'); + } finally { + setIsSaving(false); + } + }; return (
@@ -116,7 +207,7 @@ export function DeveloperSection({ settings, onSettingChange, onSave, isSaving,
- +
+

Settings

+

{nodeName}

+
+
+ +
+ +
+ + + + + + ); +} diff --git a/frontend/src/components/settings/SystemSection.tsx b/frontend/src/components/settings/SystemSection.tsx index fff33e34..e4e1dd33 100644 --- a/frontend/src/components/settings/SystemSection.tsx +++ b/frontend/src/components/settings/SystemSection.tsx @@ -3,14 +3,14 @@ import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; import { RefreshCw } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { apiFetch } from '@/lib/api'; +import { toast } from '@/components/ui/toast-store'; +import { useNodes } from '@/context/NodeContext'; +import { DEFAULT_SETTINGS } from './types'; import type { PatchableSettings } from './types'; interface SystemSectionProps { - settings: PatchableSettings; - onSettingChange: (key: K, value: PatchableSettings[K]) => void; - onSave: () => Promise; - isSaving: boolean; - isLoading: boolean; + onDirtyChange?: (dirty: boolean) => void; } interface NumberChipProps { @@ -153,7 +153,84 @@ function SettingsSkeleton() { ); } -export function SystemSection({ settings, onSettingChange, onSave, isSaving, isLoading }: SystemSectionProps) { +type SystemFields = Pick; + +const DEFAULT_SYSTEM: SystemFields = { + host_cpu_limit: DEFAULT_SETTINGS.host_cpu_limit, + host_ram_limit: DEFAULT_SETTINGS.host_ram_limit, + host_disk_limit: DEFAULT_SETTINGS.host_disk_limit, + docker_janitor_gb: DEFAULT_SETTINGS.docker_janitor_gb, + global_crash: DEFAULT_SETTINGS.global_crash, +}; + +export function SystemSection({ onDirtyChange }: SystemSectionProps) { + const { activeNode } = useNodes(); + const [settings, setSettings] = useState({ ...DEFAULT_SYSTEM }); + const serverSettingsRef = useRef({ ...DEFAULT_SYSTEM }); + const [isLoading, setIsLoading] = useState(false); + const [isSaving, setIsSaving] = useState(false); + + const hasChanges = + 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; + + useEffect(() => { + onDirtyChange?.(hasChanges); + }, [hasChanges, onDirtyChange]); + + useEffect(() => { + const fetchSettings = async () => { + setIsLoading(true); + try { + const nodeRes = await apiFetch('/settings'); + const nodeData: Record = nodeRes.ok ? await nodeRes.json() : {}; + const safe: SystemFields = { + 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, + }; + setSettings(safe); + serverSettingsRef.current = { ...safe }; + } catch (e) { + console.error('Failed to fetch system settings', e); + } finally { + setIsLoading(false); + } + }; + fetchSettings(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeNode?.id]); + + const onSettingChange = (key: K, value: SystemFields[K]) => { + setSettings(prev => ({ ...prev, [key]: value })); + }; + + const saveSettings = async () => { + setIsSaving(true); + try { + const res = await apiFetch('/settings', { + method: 'PATCH', + body: JSON.stringify(settings), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + toast.error(err?.error || err?.message || 'Failed to save settings.'); + return; + } + serverSettingsRef.current = { ...settings }; + toast.success('System limits saved.'); + } catch (e: unknown) { + toast.error((e as Error)?.message || 'Something went wrong.'); + } finally { + setIsSaving(false); + } + }; + if (isLoading) return ; return ( @@ -229,7 +306,7 @@ export function SystemSection({ settings, onSettingChange, onSave, isSaving, isL
-