mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-21 23:56:39 +00:00
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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user