mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 03:06:54 +00:00
9a1c043189
* 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
251 lines
7.7 KiB
TypeScript
251 lines
7.7 KiB
TypeScript
import type { SectionId } from './types';
|
|
|
|
export type SettingsGroupId = 'identity' | 'system' | 'alerts' | 'advanced';
|
|
|
|
export interface SettingsGroupMeta {
|
|
id: SettingsGroupId;
|
|
label: string;
|
|
kicker?: string;
|
|
glyph: string;
|
|
}
|
|
|
|
export const SETTINGS_GROUPS: readonly SettingsGroupMeta[] = [
|
|
{ id: 'identity', label: 'Identity', glyph: '\u25C8' },
|
|
{ id: 'system', label: 'System', kicker: 'node-scoped', glyph: '\u25C6' },
|
|
{ id: 'alerts', label: 'Alerts', glyph: '\u25C7' },
|
|
{ id: 'advanced', label: 'Advanced', glyph: '\u25C7' },
|
|
];
|
|
|
|
export type TierGate = 'skipper' | 'admiral' | null;
|
|
export type Scope = 'global' | 'node';
|
|
|
|
export interface SettingsItemMeta {
|
|
id: SectionId;
|
|
group: SettingsGroupId;
|
|
label: string;
|
|
description: string;
|
|
keywords: string[];
|
|
tier: TierGate;
|
|
scope: Scope;
|
|
adminOnly?: boolean;
|
|
hiddenOnRemote?: boolean;
|
|
}
|
|
|
|
export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
|
|
{
|
|
id: 'account',
|
|
group: 'identity',
|
|
label: 'Account',
|
|
description: 'Password, MFA, and session controls for the signed-in operator.',
|
|
keywords: ['password', 'mfa', 'two-factor', 'session', 'profile'],
|
|
tier: null,
|
|
scope: 'global',
|
|
hiddenOnRemote: true,
|
|
},
|
|
{
|
|
id: 'appearance',
|
|
group: 'identity',
|
|
label: 'Appearance',
|
|
description: 'Density and display preferences saved to this browser.',
|
|
keywords: ['density', 'comfortable', 'compact', 'spacing', 'display', 'theme'],
|
|
tier: null,
|
|
scope: 'global',
|
|
},
|
|
{
|
|
id: 'license',
|
|
group: 'identity',
|
|
label: 'License',
|
|
description: 'Activation key, plan tier, and seat allocation.',
|
|
keywords: ['key', 'activation', 'tier', 'plan', 'seats', 'billing'],
|
|
tier: null,
|
|
scope: 'global',
|
|
hiddenOnRemote: true,
|
|
},
|
|
{
|
|
id: 'users',
|
|
group: 'identity',
|
|
label: 'Users',
|
|
description: 'Operators, role assignments, and access scopes.',
|
|
keywords: ['operators', 'team', 'rbac', 'roles', 'permissions'],
|
|
tier: 'skipper',
|
|
scope: 'global',
|
|
adminOnly: true,
|
|
hiddenOnRemote: true,
|
|
},
|
|
{
|
|
id: 'sso',
|
|
group: 'identity',
|
|
label: 'SSO',
|
|
description: 'Single sign-on via SAML or OIDC identity providers.',
|
|
keywords: ['saml', 'oidc', 'okta', 'entra', 'azure', 'login'],
|
|
tier: null,
|
|
scope: 'global',
|
|
adminOnly: true,
|
|
hiddenOnRemote: true,
|
|
},
|
|
{
|
|
id: 'api-tokens',
|
|
group: 'identity',
|
|
label: 'API Tokens',
|
|
description: 'Long-lived bearer tokens for CI, scripts, and remote nodes.',
|
|
keywords: ['bearer', 'automation', 'ci', 'scripts', 'scopes'],
|
|
tier: 'admiral',
|
|
scope: 'global',
|
|
adminOnly: true,
|
|
hiddenOnRemote: true,
|
|
},
|
|
{
|
|
id: 'system',
|
|
group: 'system',
|
|
label: 'System Limits',
|
|
description: 'Threshold percentages for host CPU, RAM, disk, and crash-loop alerts.',
|
|
keywords: ['cpu', 'ram', 'disk', 'limits', 'thresholds', 'alerts'],
|
|
tier: null,
|
|
scope: 'node',
|
|
},
|
|
{
|
|
id: 'registries',
|
|
group: 'system',
|
|
label: 'Registries',
|
|
description: 'Private Docker registries and pull credentials.',
|
|
keywords: ['docker', 'ghcr', 'ecr', 'private', 'pull', 'auth'],
|
|
tier: 'admiral',
|
|
scope: 'global',
|
|
adminOnly: true,
|
|
hiddenOnRemote: true,
|
|
},
|
|
{
|
|
id: 'cloud-backup',
|
|
group: 'system',
|
|
label: 'Cloud Backup',
|
|
description: 'Mirror fleet snapshots to Sencho Cloud Backup or any S3-compatible storage.',
|
|
keywords: ['cloud', 'backup', 'snapshot', 's3', 'r2', 'minio', 'storage', 'offsite'],
|
|
tier: 'admiral',
|
|
scope: 'global',
|
|
adminOnly: true,
|
|
hiddenOnRemote: true,
|
|
},
|
|
{
|
|
id: 'nodes',
|
|
group: 'system',
|
|
label: 'Nodes',
|
|
description: 'Remote Sencho instances proxied through this control plane.',
|
|
keywords: ['fleet', 'remote', 'proxy', 'node', 'cluster'],
|
|
tier: null,
|
|
scope: 'global',
|
|
hiddenOnRemote: true,
|
|
},
|
|
{
|
|
id: 'notifications',
|
|
group: 'alerts',
|
|
label: 'Notifications',
|
|
description: 'In-app toasts and browser push for stack, container, and system events.',
|
|
keywords: ['toasts', 'push', 'events', 'alerts', 'inbox'],
|
|
tier: null,
|
|
scope: 'node',
|
|
},
|
|
{
|
|
id: 'notification-routing',
|
|
group: 'alerts',
|
|
label: 'Routing',
|
|
description: 'Rules that steer alerts to the right channel based on severity or label.',
|
|
keywords: ['rules', 'routing', 'channels', 'severity', 'labels'],
|
|
tier: 'admiral',
|
|
scope: 'global',
|
|
adminOnly: true,
|
|
hiddenOnRemote: true,
|
|
},
|
|
{
|
|
id: 'webhooks',
|
|
group: 'alerts',
|
|
label: 'Webhooks',
|
|
description: 'Outbound HTTP hooks to Slack, Discord, Teams, or custom endpoints.',
|
|
keywords: ['slack', 'discord', 'teams', 'webhook', 'outbound'],
|
|
tier: 'skipper',
|
|
scope: 'global',
|
|
hiddenOnRemote: true,
|
|
},
|
|
{
|
|
id: 'labels',
|
|
group: 'advanced',
|
|
label: 'Labels',
|
|
description: 'Per-node labels for stacks and containers.',
|
|
keywords: ['labels', 'tags', 'palette', 'organisation'],
|
|
tier: 'skipper',
|
|
scope: 'node',
|
|
},
|
|
{
|
|
id: 'security',
|
|
group: 'advanced',
|
|
label: 'Security',
|
|
description: 'Image scanning, suppressions, and posture defaults.',
|
|
keywords: ['scan', 'cve', 'trivy', 'suppressions', 'hardening'],
|
|
tier: 'skipper',
|
|
scope: 'node',
|
|
adminOnly: true,
|
|
},
|
|
{
|
|
id: 'developer',
|
|
group: 'advanced',
|
|
label: 'Developer',
|
|
description: 'Retention windows and debug modes.',
|
|
keywords: ['retention', 'logs', 'metrics', 'debug', 'developer'],
|
|
tier: null,
|
|
scope: 'node',
|
|
},
|
|
{
|
|
id: 'app-store',
|
|
group: 'advanced',
|
|
label: 'App Store',
|
|
description: 'Template registry URL and featured-catalog source.',
|
|
keywords: ['templates', 'registry', 'catalog', 'featured'],
|
|
tier: null,
|
|
scope: 'node',
|
|
},
|
|
{
|
|
id: 'support',
|
|
group: 'advanced',
|
|
label: 'Support',
|
|
description: 'Diagnostics bundle, docs links, and contact channels.',
|
|
keywords: ['help', 'diagnostics', 'bundle', 'docs', 'contact'],
|
|
tier: null,
|
|
scope: 'global',
|
|
},
|
|
{
|
|
id: 'about',
|
|
group: 'advanced',
|
|
label: 'About',
|
|
description: 'Build metadata, release notes, and licence attributions.',
|
|
keywords: ['version', 'build', 'release', 'attributions'],
|
|
tier: null,
|
|
scope: 'global',
|
|
},
|
|
];
|
|
|
|
export function getSettingsItem(id: SectionId): SettingsItemMeta | undefined {
|
|
return SETTINGS_ITEMS.find(item => item.id === id);
|
|
}
|
|
|
|
export function getSettingsGroup(id: SettingsGroupId): SettingsGroupMeta | undefined {
|
|
return SETTINGS_GROUPS.find(group => group.id === id);
|
|
}
|
|
|
|
export interface VisibilityContext {
|
|
isRemote: boolean;
|
|
isAdmin: boolean;
|
|
isPaid: boolean;
|
|
isAdmiral: boolean;
|
|
}
|
|
|
|
export function isItemVisible(item: SettingsItemMeta, ctx: VisibilityContext): boolean {
|
|
if (ctx.isRemote && item.hiddenOnRemote) return false;
|
|
if (item.adminOnly && !ctx.isAdmin) return false;
|
|
return true;
|
|
}
|
|
|
|
export function isItemLocked(item: SettingsItemMeta, ctx: VisibilityContext): boolean {
|
|
if (item.tier === 'skipper') return !ctx.isPaid;
|
|
if (item.tier === 'admiral') return !ctx.isAdmiral;
|
|
return false;
|
|
}
|