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
This commit is contained in:
SaelixCode
2026-04-30 10:13:19 -04:00
parent 3c30c2befe
commit 4475afd793
19 changed files with 828 additions and 621 deletions
+58
View File
@@ -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",
+1
View File
@@ -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",
+10 -7
View File
@@ -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 (
<AuthProvider>
<DeployFeedbackProvider>
<AppContent />
<DeployFeedbackPortal />
</DeployFeedbackProvider>
<ToastContainer />
</AuthProvider>
<BrowserRouter>
<AuthProvider>
<DeployFeedbackProvider>
<AppContent />
<DeployFeedbackPortal />
</DeployFeedbackProvider>
<ToastContainer />
</AuthProvider>
</BrowserRouter>
);
}
+24 -18
View File
@@ -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<Record<string, boolean>>({});
const isAdmiral = license?.variant === 'admiral';
// Notifications & Settings state
const navigate = useNavigate();
const isSettingsRoute = !!useMatch({ path: '/settings/*', end: false });
// Notifications state
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
const [tickerConnected, setTickerConnected] = useState(false);
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
const [settingsInitialSection, setSettingsInitialSection] = useState<SectionId>('account');
const [alertSheetOpen, setAlertSheetOpen] = useState(false);
const [alertSheetStack, setAlertSheetStack] = useState('');
const [autoHealStackName, setAutoHealStackName] = useState<string | null>(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={
<NodeSwitcher
onManageNodes={() => { setSettingsInitialSection('nodes'); setSettingsModalOpen(true); }}
onManageNodes={() => navigate('/settings/nodes')}
/>
}
createStackSlot={createStackSlot}
@@ -2328,12 +2335,19 @@ export default function EditorLayout() {
<UserProfileDropdown
theme={theme}
setTheme={setTheme}
onOpenSettings={() => setSettingsModalOpen(true)}
/>
}
/>
{/* Main Workspace */}
{isSettingsRoute ? (
<div className="flex-1 flex overflow-hidden">
<Routes>
<Route path="/settings/:sectionId" element={<SettingsPage />} />
<Route path="/settings" element={<Navigate to="/settings/account" replace />} />
</Routes>
</div>
) : (
<div key={activeView} className="flex-1 overflow-y-auto p-6 animate-fade-up">
{activeView === 'templates' ? (
<AppStoreView onDeploySuccess={(stackName) => { 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); }}
/>
)}
</div>
)}
</div>
{/* Delete Confirmation Dialog */}
@@ -3055,14 +3069,6 @@ export default function EditorLayout() {
)}
{/* Settings Modal */}
<SettingsModal
isOpen={settingsModalOpen}
onClose={() => { setSettingsModalOpen(false); setSettingsInitialSection('account'); }}
initialSection={settingsInitialSection}
onLabelsChanged={refreshLabels}
/>
{/* Stack Alert Sheet */}
<StackAlertSheet
isOpen={alertSheetOpen}
+2 -4
View File
@@ -1,6 +1,5 @@
import { useNodes } from '@/context/NodeContext';
import type { NotificationItem } from './dashboard/types';
import type { SectionId } from './settings/types';
import {
HealthStatusBar,
ResourceGauges,
@@ -15,12 +14,11 @@ interface HomeDashboardProps {
onNavigateToStack?: (stackFile: string) => void;
notifications: NotificationItem[];
onClearNotifications: () => void | Promise<void>;
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
/>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<ConfigurationStatus onOpenSettings={onOpenSettings} />
<ConfigurationStatus />
<RecentActivity />
</div>
-538
View File
@@ -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<SectionId>(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<HTMLDivElement | null>(null);
const scrollPositionsRef = useRef<Partial<Record<SectionId, number>>>({});
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<PatchableSettings>({ ...DEFAULT_SETTINGS });
const serverSettingsRef = useRef<PatchableSettings>({ ...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<Record<SectionId, boolean>> = {
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<string, string> = nodeRes.ok ? await nodeRes.json() : {};
const localData: Record<string, string> = (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 = <K extends keyof PatchableSettings>(key: K, value: PatchableSettings[K]) => {
setSettings(prev => ({ ...prev, [key]: value }));
};
const patchSettings = async (payload: PatchableSettings, setLoading: (v: boolean) => void, localOnly = false): Promise<boolean> => {
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<SenchoSettingsChangedDetail>(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 (
<AccountSection
authData={authData}
onAuthDataChange={setAuthData}
onPasswordChange={handlePasswordChange}
isSaving={isSavingPassword}
/>
);
case 'appearance': return <AppearanceSection />;
case 'license': return <LicenseSection />;
case 'users': return <UsersSection />;
case 'sso': return <SSOSection />;
case 'api-tokens': return <ApiTokensSection />;
case 'registries': return <RegistriesSection />;
case 'labels': return <LabelsSection onLabelsChanged={onLabelsChanged} />;
case 'system':
return (
<SystemSection
settings={settings}
onSettingChange={handleSettingChange}
onSave={saveSystemSettings}
isSaving={isSavingSystem}
isLoading={isSettingsLoading}
/>
);
case 'notifications': return <NotificationsSection />;
case 'notification-routing': return <NotificationRoutingSection />;
case 'webhooks': return <WebhooksSection isPaid={isPaid} />;
case 'security': return <SecuritySection isPaid={isPaid} />;
case 'cloud-backup': return <CloudBackupSection />;
case 'developer':
return (
<DeveloperSection
settings={settings}
onSettingChange={handleSettingChange}
onSave={saveDeveloperSettings}
isSaving={isSavingDeveloper}
isLoading={isSettingsLoading}
/>
);
case 'nodes': return <NodeManager />;
case 'appstore':
return (
<AppStoreSection
settings={settings}
onSettingChange={handleSettingChange}
isLoading={isSettingsLoading}
onSaved={handleRegistrySaved}
/>
);
case 'support': return <SupportSection />;
case 'about': return <AboutSection />;
default: return null;
}
};
return (
<>
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent
showClose={false}
onKeyDownCapture={handleDialogKeyDown}
className="sm:max-w-[960px] h-[min(780px,90vh)] flex p-0 font-sans shadow-lg bg-background border-border overflow-hidden gap-0"
>
<VisuallyHidden><DialogTitle>Settings Hub</DialogTitle></VisuallyHidden>
<VisuallyHidden><DialogDescription>Configure Sencho settings</DialogDescription></VisuallyHidden>
<aside className="w-[220px] bg-glass border-r border-glass-border flex flex-col shrink-0 min-h-0">
<div className="px-4 pt-4 pb-3">
<div className="font-display italic text-xl leading-none text-stat-value">Settings</div>
<div className="mt-1 font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">
{isRemote && activeNode ? `node · ${activeNode.name}` : 'control plane'}
</div>
</div>
<button
type="button"
onClick={() => setCommandOpen(true)}
className="mx-3 mb-3 flex items-center gap-2 rounded-md border border-card-border border-t-card-border-top bg-card px-2.5 py-1.5 shadow-card-bevel transition-colors hover:border-t-card-border-hover"
>
<Search className="h-3 w-3 text-stat-subtitle" strokeWidth={1.5} />
<span className="flex-1 text-left font-mono text-[11px] text-stat-subtitle">Filter settings</span>
<kbd className="font-mono text-[10px] text-stat-subtitle/70 tabular-nums">{`\u2318K`}</kbd>
</button>
<ScrollArea className="flex-1 px-3">
<nav className="flex flex-col gap-4 pb-4">
{visibleGroups.map(group => (
<div key={group.id} className="flex flex-col gap-0.5">
<div className="flex items-baseline gap-1.5 px-2 pb-1 font-mono text-[9px] uppercase tracking-[0.22em] text-stat-subtitle">
<span>{group.label}</span>
{group.kicker ? (
<span className="text-stat-subtitle/60">· {group.kicker}</span>
) : null}
</div>
{group.items.map(item => {
const locked = isItemLocked(item, visibility);
const isActive = activeSection === item.id;
const showDot = sectionDirtyFlags[item.id];
return (
<button
type="button"
key={item.id}
onClick={() => switchSection(item.id)}
className={cn(
'relative flex items-center gap-2 rounded-sm px-2 py-[var(--density-cell-y)] text-left transition-colors',
isActive
? 'bg-gradient-to-r from-brand/10 to-transparent text-stat-value'
: 'text-stat-subtitle hover:bg-accent/40 hover:text-stat-value',
)}
>
{isActive ? (
<span className="absolute inset-y-1 left-0 w-[2px] rounded-full bg-brand" />
) : null}
<span
aria-hidden="true"
className={cn(
'font-mono text-[11px] leading-none w-3 text-center',
isActive ? 'text-brand' : 'text-stat-subtitle/70',
)}
>
{group.glyph}
</span>
<span className="flex-1 truncate text-sm font-medium">{item.label}</span>
{item.tier ? (
<TierChip tier={item.tier} locked={locked} />
) : null}
{showDot ? (
<span className="h-1.5 w-1.5 rounded-full bg-warning" />
) : null}
</button>
);
})}
</div>
))}
</nav>
</ScrollArea>
</aside>
<div className="flex-1 flex flex-col min-h-0 min-w-0">
<header className="flex items-start justify-between gap-4 px-6 pt-5 pb-4 border-b border-border/60">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
<span>Settings</span>
<span className="text-stat-subtitle/50">{`\u203A`}</span>
<span>{activeGroup?.label ?? ''}</span>
<span className="text-stat-subtitle/50">{`\u203A`}</span>
<span className="text-stat-value">{activeItem?.label ?? ''}</span>
{activeItem?.scope === 'node' ? (
<span className="ml-2 flex items-center gap-1 text-brand">
<span className="text-stat-subtitle/50">·</span>
<span className="truncate max-w-[160px]">{activeNode?.name ?? 'local'}</span>
<span className="text-stat-subtitle/70">(node-scoped)</span>
</span>
) : null}
</div>
<h2 className="mt-1.5 font-display italic text-2xl leading-tight text-stat-value truncate">
{activeItem?.label ?? 'Settings'}
</h2>
{activeItem?.description ? (
<div className="mt-1 text-sm text-stat-subtitle/90 truncate">
{activeItem.description}
</div>
) : null}
</div>
<DialogClose className="mt-1 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" strokeWidth={1.5} />
<span className="sr-only">Close</span>
</DialogClose>
</header>
<ScrollArea block viewportRef={contentViewportRef} className="flex-1 min-w-0">
<div className="px-6 py-5 flex flex-col gap-6 min-w-0">
{renderSection()}
</div>
</ScrollArea>
</div>
</DialogContent>
</Dialog>
<CommandDialog open={commandOpen} onOpenChange={setCommandOpen}>
<CommandInput placeholder="Jump to a setting..." />
<CommandList>
<CommandEmpty>No matching settings.</CommandEmpty>
{visibleGroups.map(group => (
<CommandGroup key={group.id} heading={group.label}>
{group.items.map(item => (
<CommandSearchItem
key={item.id}
item={item}
glyph={group.glyph}
visibility={visibility}
onSelect={() => {
setCommandOpen(false);
switchSection(item.id);
}}
/>
))}
</CommandGroup>
))}
</CommandList>
</CommandDialog>
</>
);
}
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 (
<CommandItem value={searchValue} onSelect={onSelect}>
<span className="font-mono text-[11px] w-3 text-center text-stat-subtitle/70">{glyph}</span>
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
<span className="text-sm font-medium text-stat-value truncate">{item.label}</span>
<span className="text-xs text-stat-subtitle truncate">{item.description}</span>
</div>
{item.tier ? <TierChip tier={item.tier} locked={locked} /> : null}
</CommandItem>
);
}
function TierChip({ tier, locked }: { tier: NonNullable<SettingsItemMeta['tier']>; locked: boolean }) {
const label = tier === 'admiral' ? 'ADMIRAL' : 'SKIPPER';
if (locked) {
return (
<span className="flex items-center gap-1 rounded-sm border border-card-border bg-card px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-[0.18em] text-stat-subtitle/80">
<Lock className="h-2.5 w-2.5" strokeWidth={1.5} />
{label}
</span>
);
}
return (
<span className="font-mono text-[9px] uppercase tracking-[0.18em] text-brand/70">
{label}
</span>
);
}
@@ -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 */}
<div className="border-t border-card-border/60">
<MenuRow icon={Settings} label="Settings" onClick={onOpenSettings} />
<MenuRow icon={Settings} label="Settings" onClick={() => navigate('/settings')} />
{showBilling ? (
<MenuRow
icon={CreditCard}
@@ -2,12 +2,9 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Bell, Zap, Shield, HardDrive, ChevronRight } from 'lucide-react';
import { formatCount } from '@/lib/utils';
import { useConfigurationStatus } from './useConfigurationStatus';
import { useNavigate } from 'react-router-dom';
import type { SectionId } from '@/components/settings/types';
interface ConfigurationStatusProps {
onOpenSettings?: (section: SectionId) => 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 (
@@ -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<void>;
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<MfaStatus | null>(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
<Input
type="password"
value={authData.oldPassword}
onChange={(e) => onAuthDataChange({ ...authData, oldPassword: e.target.value })}
onChange={(e) => setAuthData({ ...authData, oldPassword: e.target.value })}
/>
</div>
<div className="space-y-2">
@@ -97,7 +126,7 @@ export function AccountSection({ authData, onAuthDataChange, onPasswordChange, i
<Input
type="password"
value={authData.newPassword}
onChange={(e) => onAuthDataChange({ ...authData, newPassword: e.target.value })}
onChange={(e) => setAuthData({ ...authData, newPassword: e.target.value })}
/>
</div>
<div className="space-y-2">
@@ -105,10 +134,10 @@ export function AccountSection({ authData, onAuthDataChange, onPasswordChange, i
<Input
type="password"
value={authData.confirmPassword}
onChange={(e) => onAuthDataChange({ ...authData, confirmPassword: e.target.value })}
onChange={(e) => setAuthData({ ...authData, confirmPassword: e.target.value })}
/>
</div>
<Button onClick={onPasswordChange} disabled={isSaving} className="w-full">
<Button onClick={handlePasswordChange} disabled={isSaving} className="w-full">
{isSaving
? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Updating...</>
: 'Update Password'
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useRef, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -6,15 +6,6 @@ import { Skeleton } from '@/components/ui/skeleton';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { RefreshCw } from 'lucide-react';
import type { PatchableSettings } from './types';
interface AppStoreSectionProps {
settings: PatchableSettings;
onSettingChange: <K extends keyof PatchableSettings>(key: K, value: PatchableSettings[K]) => void;
isLoading: boolean;
/** Called after a successful save so the parent can update serverSettingsRef */
onSaved: (key: keyof PatchableSettings, value: string) => void;
}
function SettingsSkeleton() {
return (
@@ -30,22 +21,50 @@ function SettingsSkeleton() {
);
}
export function AppStoreSection({ settings, onSettingChange, isLoading, onSaved }: AppStoreSectionProps) {
export function AppStoreSection() {
const [templateRegistryUrl, setTemplateRegistryUrl] = useState('');
const serverUrl = useRef('');
const [isLoading, setIsLoading] = useState(false);
const [isSavingRegistry, setIsSavingRegistry] = useState(false);
useEffect(() => {
const fetchSettings = async () => {
setIsLoading(true);
try {
const res = await apiFetch('/settings');
if (res.ok) {
const data: Record<string, string> = await res.json();
const url = data.template_registry_url ?? '';
setTemplateRegistryUrl(url);
serverUrl.current = url;
}
} catch (e) {
console.error('Failed to fetch app store settings', e);
} finally {
setIsLoading(false);
}
};
fetchSettings();
}, []);
const saveRegistrySettings = async () => {
const trimmedUrl = templateRegistryUrl.trim();
if (trimmedUrl && !/^https?:\/\/./.test(trimmedUrl)) {
toast.error('Registry URL must start with http:// or https://');
return;
}
setIsSavingRegistry(true);
try {
const res = await apiFetch('/settings', {
method: 'PATCH',
body: JSON.stringify({ template_registry_url: settings.template_registry_url ?? '' }),
body: JSON.stringify({ template_registry_url: trimmedUrl }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Failed to save registry settings.');
return;
}
onSaved('template_registry_url', settings.template_registry_url ?? '');
serverUrl.current = templateRegistryUrl;
await apiFetch('/templates/refresh-cache', { method: 'POST' });
toast.success('Registry saved. App Store will reload from the new source.');
} catch (e: unknown) {
@@ -77,8 +96,8 @@ export function AppStoreSection({ settings, onSettingChange, isLoading, onSaved
</div>
<Input
placeholder="https://example.com/templates.json"
value={settings.template_registry_url ?? ''}
onChange={(e) => onSettingChange('template_registry_url', e.target.value)}
value={templateRegistryUrl}
onChange={(e) => setTemplateRegistryUrl(e.target.value)}
/>
<p className="text-xs text-muted-foreground">Leave empty to use the default LinuxServer.io registry.</p>
</div>
@@ -88,8 +107,8 @@ export function AppStoreSection({ settings, onSettingChange, isLoading, onSaved
<Button
variant="outline"
size="sm"
onClick={() => onSettingChange('template_registry_url', '')}
disabled={isSavingRegistry || !settings.template_registry_url}
onClick={() => setTemplateRegistryUrl('')}
disabled={isSavingRegistry || !templateRegistryUrl}
>
Reset to Default
</Button>
@@ -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: <K extends keyof PatchableSettings>(key: K, value: PatchableSettings[K]) => void;
onSave: () => Promise<void>;
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<PatchableSettings, 'developer_mode' | 'metrics_retention_hours' | 'log_retention_days' | 'audit_retention_days'>;
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<DeveloperFields>({ ...DEFAULT_DEVELOPER });
const serverSettingsRef = useRef<DeveloperFields>({ ...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<string, string> = nodeRes.ok ? await nodeRes.json() : {};
const localData: Record<string, string> = (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 = <K extends keyof DeveloperFields>(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<SenchoSettingsChangedDetail>(SENCHO_SETTINGS_CHANGED, {
detail: { changedKeys: Object.keys(payload) },
}));
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Something went wrong.');
} finally {
setIsSaving(false);
}
};
return (
<div className="space-y-6">
@@ -116,7 +207,7 @@ export function DeveloperSection({ settings, onSettingChange, onSave, isSaving,
</div>
<div className="flex justify-end">
<Button onClick={onSave} disabled={isSaving}>
<Button onClick={saveSettings} disabled={isSaving}>
{isSaving
? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Saving...</>
: 'Save Developer Settings'
@@ -21,6 +21,7 @@ import {
import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { SENCHO_LABELS_CHANGED } from '@/lib/events';
import { PaidGate } from '../PaidGate';
import { CapabilityGate } from '../CapabilityGate';
import { LabelDot } from '../LabelPill';
@@ -103,6 +104,7 @@ export function LabelsSection({ onLabelsChanged }: LabelsSectionProps = {}) {
setDialogOpen(false);
fetchLabels();
onLabelsChanged?.();
window.dispatchEvent(new Event(SENCHO_LABELS_CHANGED));
} catch (err: unknown) {
toast.error((err as Error)?.message || 'Something went wrong.');
} finally {
@@ -122,6 +124,7 @@ export function LabelsSection({ onLabelsChanged }: LabelsSectionProps = {}) {
setDeleteTarget(null);
fetchLabels();
onLabelsChanged?.();
window.dispatchEvent(new Event(SENCHO_LABELS_CHANGED));
} catch (err: unknown) {
toast.error((err as Error)?.message || 'Something went wrong.');
}
@@ -0,0 +1,65 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
import { Lock } from 'lucide-react';
import { useAuth } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
import { useNodes } from '@/context/NodeContext';
import { SETTINGS_ITEMS, getSettingsItem, isItemVisible, isItemLocked } from './registry';
import type { VisibilityContext } from './registry';
import type { SectionId } from './types';
interface TierLockedCardProps {
tier: 'skipper' | 'admiral';
}
function TierLockedCard({ tier }: TierLockedCardProps) {
const title = tier === 'admiral' ? 'Admiral feature' : 'Skipper feature';
return (
<div className="flex flex-1 items-center justify-center p-8">
<div className="flex flex-col items-center gap-4 rounded-xl border border-glass-border bg-glass px-10 py-8 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full border border-glass-border bg-glass">
<Lock className="h-5 w-5 text-stat-subtitle" />
</div>
<div className="flex flex-col gap-1">
<p className="text-sm font-semibold text-stat-value">{title}</p>
<p className="text-sm text-stat-subtitle">Upgrade to unlock more features.</p>
</div>
</div>
</div>
);
}
interface SectionGateProps {
sectionId: SectionId;
children: React.ReactNode;
}
export function SectionGate({ sectionId, children }: SectionGateProps) {
const { isAdmin } = useAuth();
const { isPaid, license } = useLicense();
const { activeNode } = useNodes();
const isAdmiral = isPaid && license?.variant === 'admiral';
const isRemote = activeNode?.type === 'remote';
const visibility: VisibilityContext = {
isAdmin,
isPaid,
isAdmiral,
isRemote,
};
const item = getSettingsItem(sectionId);
if (!item || !isItemVisible(item, visibility)) {
const fallback = SETTINGS_ITEMS.find(i => isItemVisible(i, visibility));
return <Navigate to={`/settings/${fallback?.id ?? 'appearance'}`} replace />;
}
if (isItemLocked(item, visibility) && (item.tier === 'skipper' || item.tier === 'admiral')) {
return <TierLockedCard tier={item.tier} />;
}
return <>{children}</>;
}
@@ -0,0 +1,246 @@
import { useLayoutEffect, useRef, useState, useCallback, useMemo } from 'react';
import { useParams, Navigate, useNavigate } from 'react-router-dom';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { Lock } from 'lucide-react';
import { useAuth } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
import { useNodes } from '@/context/NodeContext';
import { NodeManager } from '../NodeManager';
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,
SETTINGS_ITEMS,
SETTINGS_GROUPS,
getSettingsItem,
getSettingsGroup,
isItemVisible,
isItemLocked,
} from './index';
import type { SectionId, SettingsItemMeta, VisibilityContext } from './index';
import { SectionGate } from './SectionGate';
import { SettingsSidebar } from './SettingsSidebar';
import { cn } from '@/lib/utils';
export function SettingsPage() {
const { sectionId } = useParams<{ sectionId: string }>();
const currentSection = (sectionId ?? 'account') as SectionId;
const contentViewportRef = useRef<HTMLDivElement | null>(null);
const scrollPositionsRef = useRef<Partial<Record<SectionId, number>>>({});
const [commandOpen, setCommandOpen] = useState(false);
const [dirtyFlags, setDirtyFlags] = useState<Partial<Record<SectionId, boolean>>>({});
const handleDirtyChange = useCallback((section: SectionId, dirty: boolean) => {
setDirtyFlags(prev => {
if (prev[section] === dirty) return prev;
return { ...prev, [section]: dirty };
});
}, []);
useLayoutEffect(() => {
if (contentViewportRef.current) {
contentViewportRef.current.scrollTop = scrollPositionsRef.current[currentSection] ?? 0;
}
}, [currentSection]);
const saveScrollPosition = useCallback(() => {
if (contentViewportRef.current) {
scrollPositionsRef.current[currentSection] = contentViewportRef.current.scrollTop;
}
}, [currentSection]);
const { isAdmin } = useAuth();
const { isPaid, license } = useLicense();
const { activeNode } = useNodes();
const isRemote = activeNode?.type === 'remote';
const isAdmiral = isPaid && license?.variant === 'admiral';
const visibility: VisibilityContext = useMemo(
() => ({ isRemote, isAdmin, isPaid, isAdmiral }),
[isRemote, isAdmin, isPaid, isAdmiral],
);
const activeItem = getSettingsItem(currentSection);
const activeGroup = activeItem ? getSettingsGroup(activeItem.group) : undefined;
const visibleItems = useMemo(
() => SETTINGS_ITEMS.filter(item => isItemVisible(item, visibility)),
[visibility],
);
const visibleGroups = useMemo(() =>
SETTINGS_GROUPS
.map(group => ({
...group,
items: visibleItems.filter(item => item.group === group.id),
}))
.filter(group => group.items.length > 0),
[visibleItems],
);
const navigate = useNavigate();
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
setCommandOpen(open => !open);
}
}, []);
const sectionElement = useMemo(() => {
switch (currentSection) {
case 'account': return <AccountSection />;
case 'appearance': return <AppearanceSection />;
case 'license': return <LicenseSection />;
case 'users': return <UsersSection />;
case 'sso': return <SSOSection />;
case 'api-tokens': return <ApiTokensSection />;
case 'registries': return <RegistriesSection />;
case 'labels': return <LabelsSection />;
case 'system': return <SystemSection onDirtyChange={(d) => handleDirtyChange('system', d)} />;
case 'notifications': return <NotificationsSection />;
case 'notification-routing': return <NotificationRoutingSection />;
case 'webhooks': return <WebhooksSection isPaid={isPaid} />;
case 'security': return <SecuritySection isPaid={isPaid} />;
case 'cloud-backup': return <CloudBackupSection />;
case 'developer': return <DeveloperSection onDirtyChange={(d) => handleDirtyChange('developer', d)} />;
case 'nodes': return <NodeManager />;
case 'app-store': return <AppStoreSection />;
case 'support': return <SupportSection />;
case 'about': return <AboutSection />;
default: return <Navigate to="/settings/appearance" replace />;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentSection, isPaid]);
return (
<div
className="flex flex-1 overflow-hidden min-h-0"
onKeyDown={handleKeyDown}
>
<SettingsSidebar
dirtyFlags={dirtyFlags}
onOpenPalette={() => setCommandOpen(true)}
/>
<div className="flex-1 flex flex-col min-h-0 min-w-0">
<header className="flex items-start justify-between gap-4 px-6 pt-5 pb-4 border-b border-border/60 shrink-0">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
<span>Settings</span>
<span className="text-stat-subtitle/50"></span>
<span>{activeGroup?.label ?? ''}</span>
<span className="text-stat-subtitle/50"></span>
<span className="text-stat-value">{activeItem?.label ?? ''}</span>
{activeItem?.scope === 'node' ? (
<span className="ml-2 flex items-center gap-1 text-brand">
<span className="text-stat-subtitle/50">·</span>
<span className="truncate max-w-[160px]">{activeNode?.name ?? 'local'}</span>
<span className="text-stat-subtitle/70">(node-scoped)</span>
</span>
) : null}
</div>
<h2 className="mt-1.5 font-display italic text-2xl leading-tight text-stat-value truncate">
{activeItem?.label ?? 'Settings'}
</h2>
{activeItem?.description ? (
<p className="mt-1 text-sm text-stat-subtitle/90 truncate">
{activeItem.description}
</p>
) : null}
</div>
</header>
<ScrollArea
block
viewportRef={contentViewportRef}
className="flex-1 min-w-0"
onScrollCapture={saveScrollPosition}
>
<div className="px-6 py-5 flex flex-col gap-6 min-w-0">
<SectionGate sectionId={currentSection}>
{sectionElement}
</SectionGate>
</div>
</ScrollArea>
</div>
<CommandDialog open={commandOpen} onOpenChange={setCommandOpen}>
<CommandInput placeholder="Jump to a setting..." />
<CommandList>
<CommandEmpty>No matching settings.</CommandEmpty>
{visibleGroups.map(group => (
<CommandGroup key={group.id} heading={group.label}>
{group.items.map(item => (
<SettingsCommandItem
key={item.id}
item={item}
glyph={group.glyph}
visibility={visibility}
onSelect={() => {
setCommandOpen(false);
navigate(`/settings/${item.id}`);
}}
/>
))}
</CommandGroup>
))}
</CommandList>
</CommandDialog>
</div>
);
}
function SettingsCommandItem({
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 (
<CommandItem value={searchValue} onSelect={onSelect}>
<span className="font-mono text-[11px] w-3 text-center text-stat-subtitle/70">{glyph}</span>
<div className={cn('flex flex-col gap-0.5 min-w-0 flex-1', locked && 'opacity-60')}>
<span className="text-sm font-medium text-stat-value truncate">{item.label}</span>
<span className="text-xs text-stat-subtitle truncate">{item.description}</span>
</div>
{item.tier && locked ? (
<span className="flex items-center gap-1 rounded-sm border border-card-border bg-card px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-[0.18em] text-stat-subtitle/80">
<Lock className="h-2.5 w-2.5" strokeWidth={1.5} />
{item.tier === 'admiral' ? 'ADMIRAL' : 'SKIPPER'}
</span>
) : null}
</CommandItem>
);
}
@@ -0,0 +1,148 @@
import { NavLink, useNavigate } from 'react-router-dom';
import { ChevronLeft, Lock, Search } from 'lucide-react';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useAuth } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
import { useNodes } from '@/context/NodeContext';
import { SETTINGS_GROUPS, SETTINGS_ITEMS, isItemVisible, isItemLocked } from './registry';
import type { VisibilityContext, SettingsItemMeta } from './registry';
import type { SectionId } from './types';
import { cn } from '@/lib/utils';
interface TierChipProps {
tier: 'skipper' | 'admiral';
}
function TierChip({ tier }: TierChipProps) {
return (
<span
className={cn(
'ml-auto shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
tier === 'admiral'
? 'bg-warning/15 text-warning'
: 'bg-brand/15 text-brand',
)}
>
{tier === 'admiral' ? 'Admiral' : 'Skipper'}
</span>
);
}
interface SettingsSidebarProps {
dirtyFlags?: Partial<Record<SectionId, boolean>>;
onOpenPalette: () => void;
}
export function SettingsSidebar({ dirtyFlags, onOpenPalette }: SettingsSidebarProps) {
const navigate = useNavigate();
const { isAdmin } = useAuth();
const { isPaid, license } = useLicense();
const { activeNode } = useNodes();
const isAdmiral = isPaid && license?.variant === 'admiral';
const isRemote = activeNode?.type === 'remote';
const visibility: VisibilityContext = {
isAdmin,
isPaid,
isAdmiral,
isRemote,
};
const nodeName = activeNode?.name ?? 'control plane';
function handleBack() {
if (window.history.length <= 1) {
navigate('/');
} else {
navigate(-1);
}
}
function isVisible(item: SettingsItemMeta): boolean {
return isItemVisible(item, visibility);
}
return (
<aside className="w-[220px] bg-glass border-r border-glass-border flex flex-col shrink-0 min-h-0">
<div className="flex items-center gap-2 px-4 pt-5 pb-3">
<button
onClick={handleBack}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-stat-subtitle transition-colors hover:bg-accent/40 hover:text-stat-value"
aria-label="Go back"
>
<ChevronLeft className="h-3.5 w-3.5" strokeWidth={2} />
</button>
<div className="min-w-0 flex-1">
<p className="font-display italic text-xl leading-tight text-stat-value">Settings</p>
<p className="truncate text-[11px] text-stat-subtitle">{nodeName}</p>
</div>
</div>
<div className="px-3 pb-2">
<button
onClick={onOpenPalette}
className="flex w-full items-center gap-2 rounded-md border border-glass-border bg-glass px-3 py-1.5 text-xs text-stat-subtitle transition-colors hover:border-brand/30 hover:text-stat-value"
>
<Search className="h-3 w-3 shrink-0" />
<span className="flex-1 text-left">Filter settings</span>
</button>
</div>
<ScrollArea className="flex-1 px-3">
<nav className="pb-4">
{SETTINGS_GROUPS.map(group => {
const items = SETTINGS_ITEMS.filter(
item => item.group === group.id && isVisible(item),
);
if (items.length === 0) return null;
return (
<div key={group.id} className="mb-1 mt-3">
<p className="mb-1 px-2 text-[10px] font-semibold uppercase tracking-widest text-stat-subtitle/60">
{group.label}
</p>
{items.map(item => {
const locked = isItemLocked(item, visibility);
const isDirty = dirtyFlags?.[item.id] ?? false;
return (
<NavLink
key={item.id}
to={`/settings/${item.id}`}
className={({ isActive }) =>
cn(
'relative flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-xs transition-colors',
isActive
? 'bg-gradient-to-r from-brand/10 to-transparent text-stat-value'
: 'text-stat-subtitle hover:bg-accent/40 hover:text-stat-value',
)
}
>
{({ isActive }) => (
<>
{isActive && (
<span className="absolute inset-y-1 left-0 w-[2px] rounded-full bg-brand" />
)}
<span className="flex-1 truncate">{item.label}</span>
{isDirty && (
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-warning" />
)}
{locked && <Lock className="h-3 w-3 shrink-0 text-stat-subtitle/60" />}
{item.tier && !locked && (
<TierChip tier={item.tier} />
)}
</>
)}
</NavLink>
);
})}
</div>
);
})}
</nav>
</ScrollArea>
</aside>
);
}
@@ -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: <K extends keyof PatchableSettings>(key: K, value: PatchableSettings[K]) => void;
onSave: () => Promise<void>;
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<PatchableSettings, 'host_cpu_limit' | 'host_ram_limit' | 'host_disk_limit' | 'docker_janitor_gb' | 'global_crash'>;
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<SystemFields>({ ...DEFAULT_SYSTEM });
const serverSettingsRef = useRef<SystemFields>({ ...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<string, string> = 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 = <K extends keyof SystemFields>(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 <SettingsSkeleton />;
return (
@@ -229,7 +306,7 @@ export function SystemSection({ settings, onSettingChange, onSave, isSaving, isL
</div>
<div className="flex justify-end">
<Button onClick={onSave} disabled={isSaving}>
<Button onClick={saveSettings} disabled={isSaving}>
{isSaving
? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Saving...</>
: 'Save limits'
+1 -1
View File
@@ -194,7 +194,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
scope: 'node',
},
{
id: 'appstore',
id: 'app-store',
group: 'advanced',
label: 'App Store',
description: 'Template registry URL and featured-catalog source.',
+1 -1
View File
@@ -40,7 +40,7 @@ export type SectionId =
| 'cloud-backup'
| 'developer'
| 'nodes'
| 'appstore'
| 'app-store'
| 'notification-routing'
| 'support'
| 'about';
+2
View File
@@ -12,3 +12,5 @@ export const SENCHO_SETTINGS_CHANGED = 'sencho-settings-changed';
export interface SenchoSettingsChangedDetail {
changedKeys: string[];
}
export const SENCHO_LABELS_CHANGED = 'sencho-labels-changed';