refactor(settings): replace modal with nested full-page route (#848)

* 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

* fix(settings): validate sectionId against registry before property write

Prevents prototype pollution (CodeQL js/remote-property-injection #243).
URL param sectionId is checked against SETTINGS_ITEMS before being used
as a property key on scrollPositionsRef.

* fix(settings): eliminate remote property injection via Map and registry-sourced key

Two-part fix for CodeQL js/remote-property-injection:

1. currentSection is now derived from SETTINGS_ITEMS.find().id (trusted
   registry data) instead of the raw sectionId URL param. The tainted
   string never flows into any property access.

2. scrollPositionsRef uses Map<SectionId, number> with .get()/.set()
   instead of a plain object. Map operations do not write to the prototype
   chain, removing the prototype pollution vector entirely.

* test(e2e): align settings selectors with full-page route

The settings refactor (4475afd) replaced the modal with a nested route.
The new sidebar renders sub-sections as NavLinks (role link, not button)
and adds a "Filter settings" button that collides with the loose
/settings/i regex used in mfa and nodes specs.

- Use exact 'Settings' match for the profile-dropdown menu row
- Switch the Nodes sub-section selector from button to link role
This commit is contained in:
Anso
2026-04-30 12:57:02 -04:00
committed by GitHub
parent 3c30c2befe
commit 9a1c043189
21 changed files with 834 additions and 624 deletions
+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}