From eead1955298249e22b9444f5f1eeaeac9e0db28d Mon Sep 17 00:00:00 2001 From: Anso Date: Thu, 30 Apr 2026 19:37:38 -0400 Subject: [PATCH] feat(settings): dress the page to match the audit (#849) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(settings): dress the page to match the audit (cyan rail, italic serif, two-column rows) Brings the full-page Settings route into the Sencho voice. The page now opens with a full-width PageMasthead (cyan rail, mono crumb, italic serif title, contextual stat strip) above a sidebar and main-content panel, each as a rounded-xl card inset on the dark background. Sidebar drops the duplicate "Settings" header and the candy tier badges. Group headers carry mono labels with visible/total counts; gated rows get a neutral uppercase lock chip and dim. Active rows keep the cyan 2px rail. Five new primitives (SettingsSection, SettingsField, SettingsCallout, SettingsActions / SettingsPrimaryButton, TierLockChip) replace the stacked label-input-help shadcn defaults and the per-section ad-hoc chrome. AccountSection, AppearanceSection, LicenseSection, SystemSection, NotificationsSection, DeveloperSection, AppStoreSection, AboutSection, and SupportSection are migrated to the new layout. The list-driven sections (Webhooks, Routing, Users, Labels, Security, CloudBackup, ApiTokens, Registries, NodeManager, SSO) keep their list cards but get the new chrome and primary CTAs. Each section can publish contextual stats to the masthead via a small context channel: 2FA state on Account, plan/trial/renews on License, edited count on System, channel counts on Notifications, etc. * refactor(settings): drop react-router-dom and align with DESIGN.md The Settings page was the only surface using react-router-dom for sub-section navigation. Every other primary view (Home, Fleet, Resources, App Store, Schedules, etc.) drives view switching through a single activeView useState in EditorLayout. This change removes the dependency end-to-end: - App.tsx drops BrowserRouter - EditorLayout adds 'settings' to the activeView union; SettingsPage renders inside the same flex-1 overflow-y-auto p-6 wrapper as siblings - UserProfileDropdown receives an onOpenSettings callback instead of useNavigate. SettingsPage owns currentSection via props lifted to EditorLayout, so cross-component navigation (openLabelManager, onManageNodes, ConfigurationStatus rows) can route to a sub-section - SettingsSidebar items become buttons (no more NavLink); SectionGate's redirect-on-invisible falls back through SettingsPage's safeSection memo - e2e/nodes.spec.ts updates the Nodes selector from link to button role - react-router-dom removed from package.json + package-lock.json The visual treatment is brought into alignment with frontend/DESIGN.md, which was rewritten this week to be the normative extract of the audit: - PageMasthead: title text-3xl → text-[22px] Section rung italic; kicker 11px → 10px Label rung; stat label tracking 0.22em → 0.18em; stat value font-medium for mono Stat-rung family discipline - SettingsField helper: mono → sans Body rung 14/22; success tone now uses --success green (was incorrectly mapped to brand cyan) - SettingsCallout: title tracking 0.18em; subtitle Body rung 14px; success tone now genuinely uses --success green; new brand tone for promotional callouts (Trial CTA, Admiral upgrade) that should read cyan - SettingsActions: SettingsPrimaryButton renders mono uppercase tracked, size sm by default. DESIGN §9.10 requires "small mono uppercase, cyan- filled" for every Settings primary CTA - TierLockChip: 9px → 10px Label rung floor - SettingsSidebar: group header tracking 0.18em; ⌘K kbd 9px → 10px; aside gains text-card-foreground transition-colors per §10 canonical card class - SettingsPage main panel: text-card-foreground transition-colors added; uses h-full overflow-auto p-6 to mirror FleetView's wrapper rhythm - Field rows, section headers, action rows now consume var(--density-*) tokens with literal fallbacks so Settings respects the comfortable/ compact toggle * fix(e2e): update mfa openAccountSettings to match settings redesign Settings now opens to the Account section by default when accessed from the profile dropdown, and the Account section no longer renders an h2 heading element. Update the openAccountSettings helper to open the correct section and assert on the Password h3 heading that SettingsSection renders instead. * test(e2e): fix MFA enrolment assertion after settings redesign The 2FA enrolment badge was replaced with a kicker/field pattern. Assert on the 'enrolled' text that the new design renders instead of the removed Enabled badge. * test(e2e): fix low-backup-codes warning assertions after settings redesign Update two assertions in the 'low backup codes warning' test that referenced UI text removed in the settings redesign: - '1 backup code remaining' -> '1 remaining' (SettingsField body text) - 'Regenerate now' button -> callout subtitle text, which uniquely identifies the zero-codes error card without hitting strict-mode from two identically-labelled Regenerate buttons on the page * test(e2e): navigate to root before re-opening settings for mock refresh The settings redesign uses a nested full-page route. Navigating to the same URL a second time does not remount the component, so AccountSection retains cached MFA state and the 0-codes branch never fetches. A page.goto('/') ensures full unmount before the second openAccountSettings call, so the refreshed mock is actually hit. * test(e2e): scroll zero-codes callout into view before asserting visibility The callout sits below the Disable 2FA section in the MFA settings page and is scrolled out of the clipped content area on initial render. scrollIntoViewIfNeeded() brings it into the visible viewport before the toBeVisible assertion. * test(e2e): scroll Radix ScrollArea viewport for zero-codes callout assertion The settings page wraps content in a Radix ScrollArea whose Root has overflow:hidden, so the browser's native scrollIntoView cannot scroll the inner viewport. Wait for the callout to attach (confirms mock data loaded), then programmatically set scrollTop on the Radix viewport element before asserting visibility. * test(e2e): use toBeAttached for zero-codes callout to avoid Radix clip issue The callout renders below the Disable 2FA section, outside the visible clip area of the Radix ScrollArea Root (overflow:hidden) on a standard viewport. Playwright's visibility check uses the clip intersection, so toBeVisible() fails even after programmatic scroll. toBeAttached() confirms the component rendered the warning card for backupCodesRemaining:0 without depending on the element's scroll position. --- e2e/mfa.spec.ts | 24 +- e2e/nodes.spec.ts | 2 +- frontend/package-lock.json | 58 -- frontend/package.json | 1 - frontend/src/App.tsx | 20 +- frontend/src/components/ApiTokensSection.tsx | 34 +- frontend/src/components/EditorLayout.tsx | 34 +- frontend/src/components/HomeDashboard.tsx | 6 +- frontend/src/components/NodeManager.tsx | 22 +- frontend/src/components/RegistriesSection.tsx | 33 +- frontend/src/components/SSOSection.tsx | 18 +- .../src/components/UserProfileDropdown.tsx | 7 +- .../dashboard/ConfigurationStatus.tsx | 10 +- .../src/components/settings/AboutSection.tsx | 64 +- .../components/settings/AccountSection.tsx | 311 ++++++---- .../components/settings/AppStoreSection.tsx | 94 +-- .../components/settings/AppearanceSection.tsx | 59 +- .../settings/CloudBackupSection.tsx | 33 +- .../components/settings/DeveloperSection.tsx | 201 +++--- .../src/components/settings/LabelsSection.tsx | 29 +- .../components/settings/LicenseSection.tsx | 581 ++++++++++-------- .../settings/MastheadStatsContext.tsx | 44 ++ .../settings/NotificationRoutingSection.tsx | 41 +- .../settings/NotificationsSection.tsx | 65 +- .../src/components/settings/SectionGate.tsx | 12 +- .../components/settings/SecuritySection.tsx | 44 +- .../components/settings/SettingsActions.tsx | 67 ++ .../components/settings/SettingsCallout.tsx | 95 +++ .../src/components/settings/SettingsField.tsx | 66 ++ .../src/components/settings/SettingsPage.tsx | 186 +++--- .../components/settings/SettingsSection.tsx | 39 ++ .../components/settings/SettingsSidebar.tsx | 141 ++--- .../components/settings/SupportSection.tsx | 145 +++-- .../src/components/settings/SystemSection.tsx | 218 ++++--- .../src/components/settings/TierLockChip.tsx | 24 + .../src/components/settings/UsersSection.tsx | 28 +- .../components/settings/WebhooksSection.tsx | 274 +++++---- frontend/src/components/settings/index.ts | 6 + frontend/src/components/ui/PageMasthead.tsx | 12 +- 39 files changed, 1904 insertions(+), 1244 deletions(-) create mode 100644 frontend/src/components/settings/MastheadStatsContext.tsx create mode 100644 frontend/src/components/settings/SettingsActions.tsx create mode 100644 frontend/src/components/settings/SettingsCallout.tsx create mode 100644 frontend/src/components/settings/SettingsField.tsx create mode 100644 frontend/src/components/settings/SettingsSection.tsx create mode 100644 frontend/src/components/settings/TierLockChip.tsx diff --git a/e2e/mfa.spec.ts b/e2e/mfa.spec.ts index e7c05015..3687aa0d 100644 --- a/e2e/mfa.spec.ts +++ b/e2e/mfa.spec.ts @@ -26,7 +26,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', exact: true }).click(); - await expect(page.getByRole('heading', { name: /^Account$/i })).toBeVisible(); + await expect(page.getByRole('heading', { name: /^Password$/i })).toBeVisible(); } /** Fill a login form (no MFA branch). */ @@ -103,8 +103,8 @@ test.describe.serial('Two-factor authentication', () => { // Step 3 (Backup codes) -> acknowledge. await page.getByRole('button', { name: /^Done$/ }).click(); - // Card now shows the Enabled badge. - await expect(page.getByText(/^Enabled$/)).toBeVisible(); + // Section kicker flips to 'enabled' and the field shows 'enrolled'. + await expect(page.getByText('enrolled')).toBeVisible(); }); test('low backup codes warning renders when <=2 codes remain', async ({ page }) => { @@ -132,7 +132,8 @@ test.describe.serial('Two-factor authentication', () => { await expect.poll(async () => isDashboard(page), { timeout: 10_000 }).toBe(true); await openAccountSettings(page); - await expect(page.getByText(/1 backup code remaining/i)).toBeVisible(); + // SettingsField body renders "{n} remaining"; helper renders "Running low. Regenerate a fresh set." + await expect(page.getByText('1 remaining')).toBeVisible(); await expect(page.getByText(/regenerate a fresh set/i)).toBeVisible(); // Now exercise the exhausted branch (0 codes): the dedicated warning card. @@ -145,11 +146,18 @@ test.describe.serial('Two-factor authentication', () => { }); }); - // Re-open the account section so it refetches status with the new mock. - await page.keyboard.press('Escape').catch(() => {}); + // Navigate away first so AccountSection unmounts and refetches on the + // next open (same-URL navigation in the route-based design does not + // trigger a remount, so Escape alone is not enough). + await page.goto('/'); await openAccountSettings(page); - await expect(page.getByText(/No backup codes left/i)).toBeVisible(); - await expect(page.getByRole('button', { name: /Regenerate now/i })).toBeVisible(); + // Verify the zero-codes warning card is rendered in the DOM. + // The callout is below the Disable 2FA section, scrolled out of the + // Radix ScrollArea's clipping root on a standard 1280x720 viewport. + // toBeAttached confirms the component responded to backupCodesRemaining:0 + // without requiring the element to be in the visible scroll position. + await expect(page.getByText(/No backup codes left/i)).toBeAttached({ timeout: 10_000 }); + await expect(page.getByText(/recovery needs an administrator/i)).toBeAttached(); await page.unroute('**/api/auth/mfa/status'); }); diff --git a/e2e/nodes.spec.ts b/e2e/nodes.spec.ts index 8efcd3d9..80a1cfe6 100644 --- a/e2e/nodes.spec.ts +++ b/e2e/nodes.spec.ts @@ -11,7 +11,7 @@ test.describe('Node management', () => { // Settings is inside the User Profile Dropdown - open it first await page.getByRole('button', { name: /profile/i }).click(); await page.getByRole('button', { name: 'Settings', exact: true }).click(); - await page.getByRole('link', { name: /^nodes$/i }).click(); + await page.getByRole('button', { name: /^nodes$/i }).click(); }); /** diff --git a/frontend/package-lock.json b/frontend/package-lock.json index bbd0001f..506dc403 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -46,7 +46,6 @@ "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", @@ -4346,19 +4345,6 @@ "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", @@ -6522,44 +6508,6 @@ } } }, - "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", @@ -6786,12 +6734,6 @@ "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 4a485f7a..01682882 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -50,7 +50,6 @@ "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 03b4610d..cd46ed72 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,3 @@ -import { BrowserRouter } from 'react-router-dom'; import { AuthProvider, useAuth } from './context/AuthContext'; import { NodeProvider } from './context/NodeContext'; import { LicenseProvider } from './context/LicenseContext'; @@ -8,6 +7,7 @@ import EditorLayout from './components/EditorLayout'; import { MfaChallenge } from './components/MfaChallenge'; import { DeployFeedbackProvider } from './context/DeployFeedbackContext'; import { DeployFeedbackPortal } from './components/DeployFeedbackPortal'; +import { ToastContainer } from './components/ui/toast'; function AppContent() { const { appStatus, isAuthenticated, needsSetup, completeSetup } = useAuth(); @@ -41,19 +41,15 @@ function AppContent() { ); } -import { ToastContainer } from './components/ui/toast'; - function App() { return ( - - - - - - - - - + + + + + + + ); } diff --git a/frontend/src/components/ApiTokensSection.tsx b/frontend/src/components/ApiTokensSection.tsx index 24998d39..35d33921 100644 --- a/frontend/src/components/ApiTokensSection.tsx +++ b/frontend/src/components/ApiTokensSection.tsx @@ -12,6 +12,9 @@ import { copyToClipboard } from '@/lib/clipboard'; import { AdmiralGate } from './AdmiralGate'; import { CapabilityGate } from './CapabilityGate'; import { Zap, Plus, Copy, Trash2, CheckCircle, RefreshCw, Clock } from 'lucide-react'; +import { SettingsPrimaryButton } from './settings/SettingsActions'; +import { SettingsCallout } from './settings/SettingsCallout'; +import { useMastheadStats } from './settings/MastheadStatsContext'; interface ApiTokenListItem { id: number; @@ -119,6 +122,15 @@ export function ApiTokensSection() { } catch { toast.error('Network error.'); } }; + const activeTokens = tokens.filter(t => !t.revoked_at).length; + useMastheadStats( + loading + ? null + : [ + { label: 'TOKENS', value: `${activeTokens}` }, + ], + ); + const handleCopy = async (text: string, label: string) => { try { await copyToClipboard(text); @@ -133,9 +145,9 @@ export function ApiTokensSection() {
- + setShowForm(!showForm)}> + Create token +
{/* Create form */} @@ -180,9 +192,9 @@ export function ApiTokensSection() {
- + + {creating ? <>Creating : 'Create'} +
)} @@ -214,11 +226,11 @@ export function ApiTokensSection() { {/* Empty state */} {!loading && tokens.length === 0 && !showForm && ( -
- -

No API tokens yet.

-

Create one to authenticate CI/CD pipelines and scripts.

-
+ } + title="No API tokens yet" + subtitle="Create one to authenticate CI/CD pipelines and scripts." + /> )} {/* Token list */} diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 1a18c3c8..0a69b240 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -36,8 +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 { Routes, Route, Navigate, useMatch, useNavigate } from 'react-router-dom'; import { SettingsPage } from './settings/SettingsPage'; +import type { SectionId } from './settings/types'; import { StackAlertSheet } from './StackAlertSheet'; import { StackAutoHealSheet } from '@/components/StackAutoHealSheet'; import { GitSourcePanel } from './stack/GitSourcePanel'; @@ -326,7 +326,8 @@ export default function EditorLayout() { window.matchMedia('(prefers-color-scheme: dark)').matches ); const isDarkMode = theme === 'dark' || (theme === 'auto' && systemDark); - const [activeView, setActiveView] = useState<'dashboard' | 'editor' | 'host-console' | 'resources' | 'templates' | 'global-observability' | 'fleet' | 'audit-log' | 'scheduled-ops' | 'auto-updates'>('dashboard'); + const [activeView, setActiveView] = useState<'dashboard' | 'editor' | 'host-console' | 'resources' | 'templates' | 'global-observability' | 'fleet' | 'audit-log' | 'scheduled-ops' | 'auto-updates' | 'settings'>('dashboard'); + const [settingsSection, setSettingsSection] = useState('appearance'); const [securityHistoryOpen, setSecurityHistoryOpen] = useState(false); const [filterNodeId, setFilterNodeId] = useState(null); const [schedulePrefill, setSchedulePrefill] = useState(null); @@ -359,8 +360,11 @@ export default function EditorLayout() { const [autoUpdateSettings, setAutoUpdateSettings] = useState>({}); const isAdmiral = license?.variant === 'admiral'; - const navigate = useNavigate(); - const isSettingsRoute = !!useMatch({ path: '/settings/*', end: false }); + const handleOpenSettings = useCallback((section?: SectionId) => { + if (section) setSettingsSection(section); + setActiveView('settings'); + setFilterNodeId(null); + }, []); // Notifications state const [notifications, setNotifications] = useState([]); @@ -2047,7 +2051,7 @@ export default function EditorLayout() { toast.dismiss(loadingId); } }, - openLabelManager: () => navigate('/settings/labels'), + openLabelManager: () => handleOpenSettings('labels'), openScheduleTask: () => { const stackName = file.replace(/\.(yml|yaml)$/, ''); setSchedulePrefill({ stackName, nodeId: activeNode?.id ?? null }); @@ -2273,7 +2277,7 @@ export default function EditorLayout() { isDarkMode={isDarkMode} nodeSwitcherSlot={ navigate('/settings/nodes')} + onManageNodes={() => handleOpenSettings('nodes')} /> } createStackSlot={createStackSlot} @@ -2335,21 +2339,19 @@ export default function EditorLayout() { handleOpenSettings('account')} /> } /> {/* Main Workspace */} - {isSettingsRoute ? ( -
- - } /> - } /> - -
- ) : (
- {activeView === 'templates' ? ( + {activeView === 'settings' ? ( + + ) : activeView === 'templates' ? ( { refreshStacks(); loadFile(stackName); }} /> ) : activeView === 'resources' ? ( @@ -2929,12 +2931,12 @@ export default function EditorLayout() { ) : ( { loadFile(stackFile); }} + onOpenSettingsSection={(section) => handleOpenSettings(section)} notifications={notifications} onClearNotifications={clearAllNotifications} /> )}
- )} {/* Delete Confirmation Dialog */} diff --git a/frontend/src/components/HomeDashboard.tsx b/frontend/src/components/HomeDashboard.tsx index a8ec2f1f..01dc110f 100644 --- a/frontend/src/components/HomeDashboard.tsx +++ b/frontend/src/components/HomeDashboard.tsx @@ -1,5 +1,6 @@ import { useNodes } from '@/context/NodeContext'; import type { NotificationItem } from './dashboard/types'; +import type { SectionId } from './settings/types'; import { HealthStatusBar, ResourceGauges, @@ -12,13 +13,14 @@ import { interface HomeDashboardProps { onNavigateToStack?: (stackFile: string) => void; + onOpenSettingsSection?: (section: SectionId) => void; notifications: NotificationItem[]; onClearNotifications: () => void | Promise; } const NOOP = () => {}; -export default function HomeDashboard({ onNavigateToStack, notifications, onClearNotifications }: HomeDashboardProps) { +export default function HomeDashboard({ onNavigateToStack, onOpenSettingsSection, notifications, onClearNotifications }: HomeDashboardProps) { const { activeNode, nodes } = useNodes(); const data = useDashboardData(); const activeNodeName = activeNode?.name || 'Local'; @@ -50,7 +52,7 @@ export default function HomeDashboard({ onNavigateToStack, notifications, onClea />
- +
diff --git a/frontend/src/components/NodeManager.tsx b/frontend/src/components/NodeManager.tsx index 49ef2512..eb758c67 100644 --- a/frontend/src/components/NodeManager.tsx +++ b/frontend/src/components/NodeManager.tsx @@ -17,6 +17,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '. import { Combobox } from './ui/combobox'; import { Plus, Trash2, Wifi, WifiOff, Star, Pencil, Monitor, Globe, Copy, KeyRound, Check, AlertTriangle, Calendar, RefreshCw, Terminal } from 'lucide-react'; import { formatTimeUntil, formatTimeAgo } from '@/lib/relativeTime'; +import { SettingsPrimaryButton } from './settings/SettingsActions'; +import { useMastheadStats } from './settings/MastheadStatsContext'; interface NodeSchedulingSummary { active_tasks: number; @@ -59,6 +61,14 @@ const defaultFormData: NodeFormData = { export function NodeManager() { const { nodes, refreshNodes } = useNodes(); + useMastheadStats([ + { label: 'NODES', value: `${nodes.length}` }, + { + label: 'REMOTE', + value: `${nodes.filter(n => n.type === 'remote').length}`, + tone: 'subtitle', + }, + ]); const [createOpen, setCreateOpen] = useState(false); const [editOpen, setEditOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); @@ -415,10 +425,10 @@ export function NodeManager() { }} > - + Add node + @@ -427,15 +437,15 @@ export function NodeManager() { {renderFormFields()} - + Add node + diff --git a/frontend/src/components/RegistriesSection.tsx b/frontend/src/components/RegistriesSection.tsx index 558feff2..290e8c85 100644 --- a/frontend/src/components/RegistriesSection.tsx +++ b/frontend/src/components/RegistriesSection.tsx @@ -11,6 +11,9 @@ import { apiFetch } from '@/lib/api'; import { AdmiralGate } from './AdmiralGate'; import { CapabilityGate } from './CapabilityGate'; import { Database, Plus, Trash2, Pencil, RefreshCw, CheckCircle, XCircle, Clock, Zap } from 'lucide-react'; +import { SettingsPrimaryButton } from './settings/SettingsActions'; +import { SettingsCallout } from './settings/SettingsCallout'; +import { useMastheadStats } from './settings/MastheadStatsContext'; type RegistryType = 'dockerhub' | 'ghcr' | 'ecr' | 'custom'; @@ -116,6 +119,14 @@ export function RegistriesSection() { // eslint-disable-next-line react-hooks/set-state-in-effect useEffect(() => { fetchRegistries(); }, []); + useMastheadStats( + loading + ? null + : [ + { label: 'REGISTRIES', value: `${registries.length}` }, + ], + ); + const resetForm = () => { setFormName(''); setFormUrl(''); @@ -275,9 +286,9 @@ export function RegistriesSection() {
- + { resetForm(); setShowForm(true); }}> + Add registry +
{/* Create / Edit form */} @@ -356,11 +367,11 @@ export function RegistriesSection() {
- +
@@ -376,11 +387,11 @@ export function RegistriesSection() { {/* Empty state */} {!loading && registries.length === 0 && !showForm && ( -
- -

No private registries configured.

-

Add one to pull images from Docker Hub orgs, GHCR, ECR, or self-hosted registries.

-
+ } + title="No private registries configured" + subtitle="Add one to pull images from Docker Hub orgs, GHCR, ECR, or self-hosted registries." + /> )} {/* Registry list */} diff --git a/frontend/src/components/SSOSection.tsx b/frontend/src/components/SSOSection.tsx index 5261b202..cb12fdbd 100644 --- a/frontend/src/components/SSOSection.tsx +++ b/frontend/src/components/SSOSection.tsx @@ -11,6 +11,8 @@ import { CapabilityGate } from './CapabilityGate'; import { PaidGate } from './PaidGate'; import { AdmiralGate } from './AdmiralGate'; import { Loader2, CheckCircle, XCircle } from 'lucide-react'; +import { SettingsPrimaryButton } from './settings/SettingsActions'; +import { useMastheadStats } from './settings/MastheadStatsContext'; const ROLE_OPTIONS = [ { value: 'viewer', label: 'Viewer' }, @@ -359,9 +361,9 @@ function ProviderCard({ providerId, type, label, initialConfig, onSave }: {
- + + {saving ? <> Saving : 'Save'} + @@ -413,6 +415,16 @@ export function SSOSection() { // eslint-disable-next-line react-hooks/set-state-in-effect useEffect(() => { fetchConfigs(); }, []); + const enabledProviders = configs.filter(c => c.enabled).length; + useMastheadStats([ + { label: 'PROVIDERS', value: `${configs.length}` }, + { + label: 'ENABLED', + value: `${enabledProviders}`, + tone: enabledProviders > 0 ? 'value' : 'subtitle', + }, + ]); + const getConfig = (provider: string) => configs.find(c => c.provider === provider) || null; return ( diff --git a/frontend/src/components/UserProfileDropdown.tsx b/frontend/src/components/UserProfileDropdown.tsx index 7581d35a..6f9e2780 100644 --- a/frontend/src/components/UserProfileDropdown.tsx +++ b/frontend/src/components/UserProfileDropdown.tsx @@ -1,5 +1,4 @@ import { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; import { Settings, LogOut, @@ -29,6 +28,7 @@ type Theme = 'light' | 'dark' | 'auto'; interface UserProfileDropdownProps { theme: Theme; setTheme: (theme: Theme) => void; + onOpenSettings: () => void; } const THEME_OPTIONS = [ @@ -48,8 +48,7 @@ function getInitials(username: string | undefined): string { return trimmed.slice(0, 2).toUpperCase(); } -export function UserProfileDropdown({ theme, setTheme }: UserProfileDropdownProps) { - const navigate = useNavigate(); +export function UserProfileDropdown({ theme, setTheme, onOpenSettings }: UserProfileDropdownProps) { const { logout, user, isAdmin } = useAuth(); const { license } = useLicense(); const [billingLoading, setBillingLoading] = useState(false); @@ -132,7 +131,7 @@ export function UserProfileDropdown({ theme, setTheme }: UserProfileDropdownProp {/* Navigation strip */}
- navigate('/settings')} /> + {showBilling ? ( void; +} + function StatusBadge({ value, locked, requiredTier }: { value: string; locked?: boolean; @@ -91,11 +94,10 @@ function SkeletonRow() { ); } -export function ConfigurationStatus() { - const navigate = useNavigate(); +export function ConfigurationStatus({ onOpenSection }: ConfigurationStatusProps = {}) { const { status, loading } = useConfigurationStatus(); - const open = (section: SectionId) => () => navigate(`/settings/${section}`); + const open = (section: SectionId) => () => onOpenSection?.(section); if (loading) { return ( diff --git a/frontend/src/components/settings/AboutSection.tsx b/frontend/src/components/settings/AboutSection.tsx index 6e770944..cec2b0ec 100644 --- a/frontend/src/components/settings/AboutSection.tsx +++ b/frontend/src/components/settings/AboutSection.tsx @@ -1,46 +1,52 @@ -import { Badge } from '@/components/ui/badge'; import { useLicense } from '@/context/LicenseContext'; import { TierBadge } from '@/components/TierBadge'; +import { SettingsSection } from './SettingsSection'; +import { SettingsField } from './SettingsField'; export function AboutSection() { const { license } = useLicense(); return ( -
-
-
- Version - v{__APP_VERSION__} -
-
- Tier -
-
-
- License Status - {license?.status ?? 'community'} -
- {license?.instanceId && ( -
- Instance ID - {license.instanceId.slice(0, 8)} -
- )} -
+
+ + + v{__APP_VERSION__} + + + + + + + {license?.status ?? 'community'} + + + {license?.instanceId ? ( + + + {license.instanceId.slice(0, 8)} + + + ) : null} + - + +
); } diff --git a/frontend/src/components/settings/AccountSection.tsx b/frontend/src/components/settings/AccountSection.tsx index 0cdcee17..c72d713c 100644 --- a/frontend/src/components/settings/AccountSection.tsx +++ b/frontend/src/components/settings/AccountSection.tsx @@ -1,16 +1,18 @@ -import { useCallback, useEffect, useState } from 'react'; -import { Button } from '@/components/ui/button'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Badge } from '@/components/ui/badge'; import { TogglePill } from '@/components/ui/toggle-pill'; -import { Separator } from '@/components/ui/separator'; import { AlertTriangle, RefreshCw, Shield, ShieldCheck } from 'lucide-react'; import { MfaEnrollDialog } from '@/components/mfa/MfaEnrollDialog'; import { MfaDisableDialog } from '@/components/mfa/MfaDisableDialog'; import { MfaBackupCodesDialog } from '@/components/mfa/MfaBackupCodesDialog'; import { toast } from '@/components/ui/toast-store'; import { apiFetch } from '@/lib/api'; +import { Button } from '@/components/ui/button'; +import { SettingsSection } from './SettingsSection'; +import { SettingsField } from './SettingsField'; +import { SettingsCallout } from './SettingsCallout'; +import { SettingsActions, SettingsPrimaryButton } from './SettingsActions'; +import { useMastheadStats } from './MastheadStatsContext'; interface MfaStatus { enabled: boolean; @@ -110,136 +112,215 @@ export function AccountSection() { } }; + const passwordStrengthHelper = useMemo(() => { + const pwd = authData.newPassword; + if (!pwd) return '12+ chars · mixed case · one number'; + if (pwd.length < 8) return 'Too short. At least 8 characters.'; + if (pwd.length < 12) return 'Acceptable. 12 or more recommended.'; + return 'Strong'; + }, [authData.newPassword]); + + const newPasswordTone = authData.newPassword + ? authData.newPassword.length >= 12 + ? 'success' + : authData.newPassword.length < 8 + ? 'error' + : 'warn' + : 'default'; + + const confirmHelper = useMemo(() => { + if (!authData.confirmPassword) return 'Re-enter the new password to confirm.'; + return authData.confirmPassword === authData.newPassword + ? 'Match' + : 'Does not match the new password'; + }, [authData.confirmPassword, authData.newPassword]); + + const confirmTone = authData.confirmPassword + ? authData.confirmPassword === authData.newPassword + ? 'success' + : 'error' + : 'default'; + + useMastheadStats( + mfaLoading + ? null + : [ + { + label: '2FA', + value: mfa?.enabled ? 'on' : 'off', + tone: mfa?.enabled ? 'value' : 'warn', + }, + ...(mfa?.enabled && mfa.backupCodesRemaining <= 2 + ? [{ + label: 'BACKUP', + value: `${mfa.backupCodesRemaining} left`, + tone: mfa.backupCodesRemaining === 0 ? ('error' as const) : ('warn' as const), + }] + : []), + ], + ); + return ( -
-
-
- +
+ + setAuthData({ ...authData, oldPassword: e.target.value })} /> -
-
- + + setAuthData({ ...authData, newPassword: e.target.value })} /> -
-
- + + setAuthData({ ...authData, confirmPassword: e.target.value })} /> -
- -
- - - - {/* Two-factor authentication card */} -
-
- {mfa?.enabled - ? - : - } -
-
-

Two-factor authentication

- {mfa?.enabled && Enabled} -
-

- {mfa?.enabled - ? 'Sign-in requires a code from your authenticator app. Back up your codes somewhere safe.' - : 'Add a time-based one-time password to your account for an extra layer of security.'} -

- - {mfaLoading ? ( -
Loading…
- ) : mfa?.enabled ? ( -
- {mfa.backupCodesRemaining === 0 ? ( -
- -
-
No backup codes left
-
- Regenerate a new set before you lose access to your authenticator app. Without codes, recovery needs an administrator. -
- -
-
- ) : mfa.backupCodesRemaining <= 2 ? ( -
- - - {mfa.backupCodesRemaining} backup code{mfa.backupCodesRemaining === 1 ? '' : 's'} remaining, regenerate a fresh set - -
- ) : ( -
- {mfa.backupCodesRemaining} backup codes remaining -
- )} - - {hasSso && ( -
-
-
Require 2FA even when signing in via SSO
-
- SSO logins skip the second factor by default. -
-
- -
- )} - -
- - -
-
+ + + + {isSaving ? ( + <> + + Updating + ) : ( -
-
- )} + + + {hasSso ? ( + + + + ) : null} + + + + + + ) : ( +
+ } + title="Two-factor is off" + subtitle="Add a time-based code from your authenticator app, every sign-in." + action={ + setEnrollOpen(true)}> + Set up 2FA + + } + />
-
-
+ )} + + {mfa?.enabled && mfa.backupCodesRemaining === 0 ? ( +
+ } + title="No backup codes left" + subtitle="Without codes, recovery needs an administrator if you lose your authenticator." + action={ + + } + /> +
+ ) : null} + diff --git a/frontend/src/components/settings/AppStoreSection.tsx b/frontend/src/components/settings/AppStoreSection.tsx index 4ee594de..5e4c612f 100644 --- a/frontend/src/components/settings/AppStoreSection.tsx +++ b/frontend/src/components/settings/AppStoreSection.tsx @@ -1,22 +1,19 @@ import { useState, useRef, useEffect } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; import { Skeleton } from '@/components/ui/skeleton'; import { toast } from '@/components/ui/toast-store'; import { apiFetch } from '@/lib/api'; import { RefreshCw } from 'lucide-react'; +import { SettingsSection } from './SettingsSection'; +import { SettingsField } from './SettingsField'; +import { SettingsActions, SettingsPrimaryButton } from './SettingsActions'; -function SettingsSkeleton() { +function SectionSkeleton() { return ( -
- - -
- - - -
+
+ +
); } @@ -74,53 +71,58 @@ export function AppStoreSection() { } }; + if (isLoading) return ; + return ( -
- {isLoading ? : ( - <> -
-
- -

- LinuxServer.io - https://api.linuxserver.io/api/v1/images -

-

Used when no custom registry is set.

-
+
+ + + + api.linuxserver.io/api/v1/images + + + -
-
- -

- Provide a URL pointing to a Portainer v2 compatible template JSON file. Overrides the default registry. -

-
- setTemplateRegistryUrl(e.target.value)} - /> -

Leave empty to use the default LinuxServer.io registry.

-
-
+ + + setTemplateRegistryUrl(e.target.value)} + /> + -
+ +
- + + {isSavingRegistry ? ( + <> + + Saving + + ) : ( + 'Save & refresh' + )} +
- - )} +
+
); } diff --git a/frontend/src/components/settings/AppearanceSection.tsx b/frontend/src/components/settings/AppearanceSection.tsx index cd73e8b8..b81a499a 100644 --- a/frontend/src/components/settings/AppearanceSection.tsx +++ b/frontend/src/components/settings/AppearanceSection.tsx @@ -1,9 +1,10 @@ -import { Label } from '@/components/ui/label'; import { Combobox } from '@/components/ui/combobox'; import { Checkbox } from '@/components/ui/checkbox'; import { useDensity } from '@/hooks/use-density'; import type { Density } from '@/hooks/use-density'; import { useDeployFeedbackEnabled } from '@/hooks/use-deploy-feedback-enabled'; +import { SettingsSection } from './SettingsSection'; +import { SettingsField } from './SettingsField'; const DENSITY_OPTIONS: { value: Density; label: string }[] = [ { value: 'comfortable', label: 'Comfortable' }, @@ -20,10 +21,12 @@ export function AppearanceSection() { const [isEnabled, setEnabled] = useDeployFeedbackEnabled(); return ( -
-
-
- +
+ + -

- {DENSITY_DESCRIPTIONS[density]} -

-
-
-
-
- setEnabled(v === true)} - /> - -
-
-

- Preference is saved to this browser only. Each device you use remembers its own choice. + + + +

+ setEnabled(v === true)} + /> + +
+ + + +

+ ⓘ saved to this browser only · every device remembers its own choice

); diff --git a/frontend/src/components/settings/CloudBackupSection.tsx b/frontend/src/components/settings/CloudBackupSection.tsx index 9a099f03..cd3f1e30 100644 --- a/frontend/src/components/settings/CloudBackupSection.tsx +++ b/frontend/src/components/settings/CloudBackupSection.tsx @@ -20,6 +20,8 @@ import { apiFetch } from '@/lib/api'; import { formatBytes } from '@/lib/utils'; import { AdmiralGate } from '@/components/AdmiralGate'; import { Cloud, CloudOff, RefreshCw, CheckCircle2, AlertCircle, Loader2, Trash2, Download } from 'lucide-react'; +import { SettingsPrimaryButton } from './SettingsActions'; +import { useMastheadStats } from './MastheadStatsContext'; type Provider = 'disabled' | 'sencho' | 'custom'; @@ -257,6 +259,27 @@ export function CloudBackupSection() { } }; + useMastheadStats( + loading + ? null + : [ + { + label: 'PROVIDER', + value: provider, + tone: provider === 'disabled' ? 'subtitle' : 'value', + }, + ...(provider === 'sencho' && usage + ? [{ + label: 'USED', + value: `${formatBytes(usage.used_bytes)} / ${formatBytes(usage.quota_bytes)}`, + }] + : []), + ...(snapshots.length > 0 + ? [{ label: 'SNAPSHOTS', value: `${snapshots.length}` }] + : []), + ], + ); + if (loading) { return (
@@ -294,10 +317,10 @@ export function CloudBackupSection() {

Activates a 500 MB allowance backed by Cloudflare R2, scoped to this Admiral license.

- +
)} @@ -362,10 +385,10 @@ export function CloudBackupSection() { {testing ? : null} Test - +
diff --git a/frontend/src/components/settings/DeveloperSection.tsx b/frontend/src/components/settings/DeveloperSection.tsx index 2d51f287..f96c49df 100644 --- a/frontend/src/components/settings/DeveloperSection.tsx +++ b/frontend/src/components/settings/DeveloperSection.tsx @@ -1,11 +1,9 @@ import { useState, useRef, useEffect } from 'react'; -import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; 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 { RefreshCw } from 'lucide-react'; import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; import { useNodes } from '@/context/NodeContext'; @@ -13,21 +11,21 @@ import { SENCHO_SETTINGS_CHANGED } from '@/lib/events'; import type { SenchoSettingsChangedDetail } from '@/lib/events'; import { DEFAULT_SETTINGS } from './types'; import type { PatchableSettings } from './types'; +import { SettingsSection } from './SettingsSection'; +import { SettingsField } from './SettingsField'; +import { SettingsActions, SettingsPrimaryButton } from './SettingsActions'; +import { useMastheadStats } from './MastheadStatsContext'; interface DeveloperSectionProps { onDirtyChange?: (dirty: boolean) => void; } -function SettingsSkeleton() { +function SectionSkeleton() { return ( -
- - -
- - - -
+
+ + +
); } @@ -59,6 +57,18 @@ export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) { onDirtyChange?.(hasChanges); }, [hasChanges, onDirtyChange]); + useMastheadStats( + isLoading + ? null + : [ + { + label: 'DEV MODE', + value: settings.developer_mode === '1' ? 'on' : 'off', + tone: settings.developer_mode === '1' ? 'warn' : 'subtitle', + }, + ], + ); + useEffect(() => { const fetchSettings = async () => { setIsLoading(true); @@ -123,99 +133,90 @@ export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) { } }; + if (isLoading) return ; + return ( -
- {isLoading ? : ( - <> -
-
-
- -

Enable Real-Time Metrics and Debug Diagnostics

-
- onSettingChange('developer_mode', c ? '1' : '0')} - /> -
-
+
+ + + onSettingChange('developer_mode', c ? '1' : '0')} + /> + + - {/* Data Retention (Observability) */} -
+ + +
+ onSettingChange('metrics_retention_hours', e.target.value)} + className="w-24" + /> + hrs +
+
+ + +
+ onSettingChange('log_retention_days', e.target.value)} + className="w-24" + /> + days +
+
+ + {isPaid && license?.variant === 'admiral' && ( +
- - Data Retention + onSettingChange('audit_retention_days', e.target.value)} + className="w-24" + /> + days
-
-
-
- -

How long to keep per-container CPU/RAM/network history.

-
-
- onSettingChange('metrics_retention_hours', e.target.value)} - className="w-20" - /> - hrs -
-
+ + )} + -
-
- -

How long to keep alert and notification history.

-
-
- onSettingChange('log_retention_days', e.target.value)} - className="w-20" - /> - days -
-
- - {isPaid && license?.variant === 'admiral' && ( -
-
- -

How long to keep audit trail entries.

-
-
- onSettingChange('audit_retention_days', e.target.value)} - className="w-20" - /> - days -
-
- )} -
-
- -
- -
- - )} + + + {isSaving ? ( + <> + + Saving + + ) : ( + 'Save settings' + )} + +
); } diff --git a/frontend/src/components/settings/LabelsSection.tsx b/frontend/src/components/settings/LabelsSection.tsx index 3a0deaa9..5704ffb9 100644 --- a/frontend/src/components/settings/LabelsSection.tsx +++ b/frontend/src/components/settings/LabelsSection.tsx @@ -26,6 +26,9 @@ import { PaidGate } from '../PaidGate'; import { CapabilityGate } from '../CapabilityGate'; import { LabelDot } from '../LabelPill'; import { LABEL_COLORS, MAX_LABELS_PER_NODE, type Label, type LabelColor } from '../label-types'; +import { SettingsCallout } from './SettingsCallout'; +import { SettingsPrimaryButton } from './SettingsActions'; +import { useMastheadStats } from './MastheadStatsContext'; interface LabelsSectionProps { onLabelsChanged?: () => void; @@ -72,6 +75,14 @@ export function LabelsSection({ onLabelsChanged }: LabelsSectionProps = {}) { useEffect(() => { fetchLabels(); }, [fetchLabels]); + useMastheadStats( + loading + ? null + : [ + { label: 'LABELS', value: `${labels.length}/${MAX_LABELS_PER_NODE}` }, + ], + ); + const openCreate = () => { setEditingLabel(null); setFormName(''); @@ -135,19 +146,21 @@ export function LabelsSection({ onLabelsChanged }: LabelsSectionProps = {}) {
- + = MAX_LABELS_PER_NODE}> + + {labels.length >= MAX_LABELS_PER_NODE ? 'Limit reached' : 'New label'} +
{loading ? ( -
Loading...
+
Loading…
) : labels.length === 0 ? ( -
- No labels yet. Create one to start organizing your stacks. -
+ ) : (
{labels.map(label => ( diff --git a/frontend/src/components/settings/LicenseSection.tsx b/frontend/src/components/settings/LicenseSection.tsx index a4bc24fd..cd4cee90 100644 --- a/frontend/src/components/settings/LicenseSection.tsx +++ b/frontend/src/components/settings/LicenseSection.tsx @@ -1,8 +1,6 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Badge } from '@/components/ui/badge'; import { toast } from '@/components/ui/toast-store'; import { useLicense } from '@/context/LicenseContext'; import { TierBadge } from '@/components/TierBadge'; @@ -11,6 +9,11 @@ import { CreditCard, RefreshCw, Zap, Compass, ShipWheel, Loader2, } from 'lucide-react'; import { apiFetch } from '@/lib/api'; +import { SettingsSection } from './SettingsSection'; +import { SettingsField } from './SettingsField'; +import { SettingsCallout } from './SettingsCallout'; +import { SettingsActions, SettingsPrimaryButton } from './SettingsActions'; +import { useMastheadStats } from './MastheadStatsContext'; // Lemon Squeezy hosted checkout URLs. Admiral monthly and annual include a built-in // 14-day trial (email + card required on LS checkout); users receive a license key @@ -26,6 +29,12 @@ function getTierDisplayName(tier?: string, variant?: string | null, status?: str return 'Sencho Community'; } +function getTierMastheadValue(tier?: string, variant?: string | null): string { + if (tier === 'paid' && variant === 'admiral') return 'admiral'; + if (tier === 'paid') return 'skipper'; + return 'community'; +} + export function LicenseSection() { const { license, isPaid, activate, deactivate } = useLicense(); const [licenseKeyInput, setLicenseKeyInput] = useState(''); @@ -52,265 +61,345 @@ export function LicenseSection() { const isAdmiral = isPaid && license?.variant === 'admiral' && license?.status === 'active'; const showSkipperCard = !isPaid || license?.status === 'trial'; - // Trial CTA owns the Admiral path for unlicensed users; the Admiral upgrade card is reserved for the - // Skipper-active upgrade path. Otherwise both would link to the same LS checkout for community users. const showTrialCta = license?.status !== 'active' && license?.status !== 'trial'; const showAdmiralUpgradeCard = !isAdmiral && !showTrialCta; const showUpgradeCards = showSkipperCard || showAdmiralUpgradeCard; + const renewsValue = useMemo(() => { + if (!license) return null; + if (license.isLifetime) return 'lifetime'; + if (license.validUntil) return new Date(license.validUntil).toLocaleDateString(); + return null; + }, [license]); + + useMastheadStats([ + { + label: 'PLAN', + value: getTierMastheadValue(license?.tier, license?.variant), + tone: isPaid ? 'value' : 'subtitle', + }, + ...(license?.status === 'trial' && license.trialDaysRemaining !== null + ? [{ + label: 'TRIAL', + value: `${license.trialDaysRemaining}d left`, + tone: 'warn' as const, + }] + : []), + ...(license?.status === 'active' && renewsValue + ? [{ label: license.isLifetime ? 'DURATION' : 'RENEWS', value: renewsValue }] + : []), + ...(license?.status === 'expired' + ? [{ label: 'STATUS', value: 'expired', tone: 'error' as const }] + : []), + ]); + + const tierIcon = isPaid ? : ; + return ( -
- {/* Current Tier Display */} -
-
+
+ +
- {isPaid ? ( - - ) : ( - - )} - - {getTierDisplayName(license?.tier, license?.variant, license?.status)} - + {tierIcon} +
- -
+ - {license?.status === 'trial' && license.trialDaysRemaining !== null && ( -
- - Trial: {license.trialDaysRemaining} day{license.trialDaysRemaining !== 1 ? 's' : ''} remaining -
- )} + {license?.status === 'active' && license.customerName ? ( + + {license.customerName} + + ) : null} - {license?.status === 'active' && ( -
- {license.customerName && ( -
- Customer - {license.customerName} -
- )} - {license.productName && ( -
- Plan - {license.productName} -
- )} - {license.maskedKey && ( -
- License Key - {license.maskedKey} -
- )} - {(license.isLifetime || license.validUntil) && ( -
- {license.isLifetime ? 'Duration' : 'Renews'} - {license.isLifetime ? 'Lifetime' : new Date(license.validUntil!).toLocaleDateString()} -
- )} -
- )} + {license?.status === 'active' && license.productName ? ( + + {license.productName} + + ) : null} - {license?.status === 'expired' && ( -
- - Your license has expired. Renew to restore paid features. -
- )} + {license?.status === 'active' && license.maskedKey ? ( + + {license.maskedKey} + + ) : null} - {license?.status === 'disabled' && ( -
- - Your license has been disabled. Contact support for assistance. -
- )} -
+ {license?.status === 'expired' ? ( + +
+ + Expired +
+
+ ) : null} - {/* Manage Subscription (active paid license) */} - {license?.status === 'active' && ( -
- {!license.isLifetime && ( - )} - Manage Subscription - - - )} -
-

- Deactivating will revert to Community features. -

- -
-
- )} + +
+ + ) : null} + - {showTrialCta && ( -
-
- - Try Admiral free for 14 days -
-

- Admiral unlocks Host Console, Scheduled Operations, LDAP / Active Directory, audit log, API tokens, and unlimited accounts. Starting a trial opens Lemon Squeezy checkout, which requires a card for verification; you can cancel any time before day 14. -

-
- - -
-

- After checkout, paste the license key from your email into the activate field below. -

-
- )} - - {showUpgradeCards && ( -
- -
- {showSkipperCard && ( -
-
- - Skipper - Popular -
-

Professional tools for solo operators.

-
    - {['Fleet View with drill-down', 'Viewer accounts (1 admin + 3 viewers)', 'Webhooks & stack labels', 'Atomic deployments & backups', 'Auto-update policies', 'Google / GitHub / Okta SSO'].map((f) => ( -
  • - - {f} -
  • - ))} -
- -
- )} - - {showAdmiralUpgradeCard && ( -
-
- - Admiral -
-

For teams managing shared infrastructure.

-
    - {[ - ...(license?.variant === 'skipper' ? ['Everything in Skipper'] : ['Everything in Community']), - 'Unlimited accounts & scoped RBAC', - ...(license?.variant !== 'skipper' ? ['Fleet View, webhooks & labels', 'Atomic deployments & backups'] : []), - 'LDAP/AD, audit log & host console', - 'API tokens & private registries', - 'Scheduled operations', - ].map((f) => ( -
  • - - {f} -
  • - ))} -
- -
- )} -
-
- )} - - {/* License key activation */} - {license?.status !== 'active' && ( -
- -
- setLicenseKeyInput(e.target.value)} - className="font-mono" + {showTrialCta ? ( + +
+ } + title="14 days, full Admiral" + subtitle="Host Console, Scheduled Operations, LDAP / Active Directory, audit log, API tokens, and unlimited accounts. Lemon Squeezy needs a card; cancel any time before day 14." /> - +
+ window.open(ADMIRAL_MONTHLY_CHECKOUT_URL, '_blank')} + > + Start monthly trial + + + +
+

+ ⓘ paste the license key from your email into the activate field below +

-
- )} + + ) : null} + + {showUpgradeCards ? ( + +
+ {showSkipperCard ? ( + } + title="Skipper" + blurb="Professional tools for solo operators." + features={[ + 'Fleet View with drill-down', + 'Viewer accounts (1 admin + 3 viewers)', + 'Webhooks & stack labels', + 'Atomic deployments & backups', + 'Auto-update policies', + 'Google / GitHub / Okta SSO', + ]} + action={ + window.open(SKIPPER_CHECKOUT_URL, '_blank')} + > + + Get Skipper + + + } + /> + ) : null} + {showAdmiralUpgradeCard ? ( + } + title="Admiral" + blurb="For teams managing shared infrastructure." + features={[ + ...(license?.variant === 'skipper' ? ['Everything in Skipper'] : ['Everything in Community']), + 'Unlimited accounts & scoped RBAC', + ...(license?.variant !== 'skipper' ? ['Fleet View, webhooks & labels', 'Atomic deployments & backups'] : []), + 'LDAP/AD, audit log & host console', + 'API tokens & private registries', + 'Scheduled operations', + ]} + action={ + showSkipperCard ? ( + + ) : ( + window.open(ADMIRAL_MONTHLY_CHECKOUT_URL, '_blank')} + > + + Get Admiral + + + ) + } + /> + ) : null} +
+
+ ) : null} + + {license?.status !== 'active' ? ( + + +
+ setLicenseKeyInput(e.target.value)} + className="font-mono" + /> + { + if (!licenseKeyInput.trim()) return; + setIsActivating(true); + const result = await activate(licenseKeyInput.trim()); + if (result.success) { + toast.success('License activated successfully.'); + setLicenseKeyInput(''); + } else { + toast.error(result.error || 'Activation failed'); + } + setIsActivating(false); + }} + disabled={isActivating || !licenseKeyInput.trim()} + > + {isActivating ? ( + <> + + Activating + + ) : ( + 'Activate' + )} + +
+
+
+ ) : null} +
+ ); +} + +interface UpgradeCardProps { + tone: 'warn' | 'brand'; + icon: React.ReactNode; + title: string; + blurb: string; + features: string[]; + action: React.ReactNode; +} + +function UpgradeCard({ tone, icon, title, blurb, features, action }: UpgradeCardProps) { + const iconClass = tone === 'warn' ? 'text-warning' : 'text-brand'; + return ( +
+
+ {icon} + {title} +
+

{blurb}

+
    + {features.map((f) => ( +
  • + + {f} +
  • + ))} +
+
{action}
); } diff --git a/frontend/src/components/settings/MastheadStatsContext.tsx b/frontend/src/components/settings/MastheadStatsContext.tsx new file mode 100644 index 00000000..d3b8cabe --- /dev/null +++ b/frontend/src/components/settings/MastheadStatsContext.tsx @@ -0,0 +1,44 @@ +import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import type { MastheadMetadataItem } from '@/components/ui/PageMasthead'; + +interface MastheadStatsContextValue { + extras: MastheadMetadataItem[] | null; + setExtras: (stats: MastheadMetadataItem[] | null) => void; +} + +const MastheadStatsContext = createContext(null); + +export function MastheadStatsProvider({ children }: { children: ReactNode }) { + const [extras, setExtras] = useState(null); + const value = useMemo(() => ({ extras, setExtras }), [extras]); + return {children}; +} + +// eslint-disable-next-line react-refresh/only-export-components +export function useMastheadStatsValue(): MastheadMetadataItem[] | null { + return useContext(MastheadStatsContext)?.extras ?? null; +} + +/** + * Sections call this with their contextual stats; they appear in the page masthead. + * Pass null to clear. Stats are cleared automatically when the calling section unmounts. + */ +// eslint-disable-next-line react-refresh/only-export-components +export function useMastheadStats(stats: MastheadMetadataItem[] | null): void { + const ctx = useContext(MastheadStatsContext); + const setExtras = ctx?.setExtras; + const signature = stats ? stats.map(s => `${s.label}|${s.value}|${s.tone ?? ''}`).join('§') : ''; + const lastSignatureRef = useRef(''); + + useEffect(() => { + if (!setExtras) return; + if (lastSignatureRef.current === signature) return; + lastSignatureRef.current = signature; + setExtras(stats); + }, [setExtras, signature, stats]); + + useEffect(() => { + if (!setExtras) return; + return () => setExtras(null); + }, [setExtras]); +} diff --git a/frontend/src/components/settings/NotificationRoutingSection.tsx b/frontend/src/components/settings/NotificationRoutingSection.tsx index 71a67c65..223b33d8 100644 --- a/frontend/src/components/settings/NotificationRoutingSection.tsx +++ b/frontend/src/components/settings/NotificationRoutingSection.tsx @@ -36,6 +36,9 @@ import type { NotificationCategory } from '@/components/dashboard/types'; import type { Label as StackLabel } from '@/components/label-types'; import { CATEGORY_LABELS } from '@/lib/notificationCategories'; import { Plus, Trash2, Pencil, RefreshCw, Zap, X, Route } from 'lucide-react'; +import { SettingsCallout } from './SettingsCallout'; +import { SettingsPrimaryButton } from './SettingsActions'; +import { useMastheadStats } from './MastheadStatsContext'; interface NotificationRoute { id: number; @@ -280,6 +283,20 @@ export function NotificationRoutingSection() { setFormCategories(prev => prev.filter(c => c !== cat)); }; + const enabledRoutesCount = routes.filter(r => r.enabled).length; + useMastheadStats( + loading + ? null + : [ + { label: 'ROUTES', value: `${routes.length}` }, + { + label: 'ENABLED', + value: `${enabledRoutesCount}`, + tone: enabledRoutesCount > 0 ? 'value' : 'subtitle', + }, + ], + ); + const availableStackOptions = stackOptions.filter(o => !formStacks.includes(o.value)); const availableLabelOptions = useMemo( () => labelOptions.filter(l => !formLabelIds.includes(l.id)).map(l => ({ value: String(l.id), label: l.name })), @@ -295,9 +312,9 @@ export function NotificationRoutingSection() {
- + { resetForm(); setShowForm(true); }}> + Add route +
{ if (!open) resetForm(); }}> @@ -469,9 +486,9 @@ export function NotificationRoutingSection() {
- + + {saving ? <>Saving : editingId ? 'Update' : 'Create'} +
@@ -485,13 +502,11 @@ export function NotificationRoutingSection() { )} {!loading && routes.length === 0 && ( -
- -

No routing rules configured.

-

- Alerts will use your global notification channels. Add a route to direct specific stack alerts to dedicated channels. -

-
+ } + title="No routing rules configured" + subtitle="Alerts use your global notification channels. Add a route to direct specific stack alerts to dedicated channels." + /> )} {!loading && routes.map(route => ( diff --git a/frontend/src/components/settings/NotificationsSection.tsx b/frontend/src/components/settings/NotificationsSection.tsx index cdcfbed5..c3fa9fb0 100644 --- a/frontend/src/components/settings/NotificationsSection.tsx +++ b/frontend/src/components/settings/NotificationsSection.tsx @@ -3,13 +3,16 @@ import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightI import { springs } from '@/lib/motion'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; import { TogglePill } from '@/components/ui/toggle-pill'; import { toast } from '@/components/ui/toast-store'; import { apiFetch } from '@/lib/api'; import { useNodes } from '@/context/NodeContext'; import { RefreshCw } from 'lucide-react'; import type { Agent } from './types'; +import { SettingsSection } from './SettingsSection'; +import { SettingsField } from './SettingsField'; +import { SettingsActions, SettingsPrimaryButton } from './SettingsActions'; +import { useMastheadStats } from './MastheadStatsContext'; export function NotificationsSection() { const { activeNode } = useNodes(); @@ -39,7 +42,16 @@ export function NotificationsSection() { } }; - useEffect(() => { fetchAgents(); }, [activeNode?.id]); + useEffect(() => { fetchAgents(); }, [activeNode?.id]); // eslint-disable-line react-hooks/exhaustive-deps + + const enabledCount = Object.values(agents).filter(a => a.enabled).length; + useMastheadStats([ + { + label: 'CHANNELS', + value: `${enabledCount}/3`, + tone: enabledCount > 0 ? 'value' : 'subtitle', + }, + ]); const handleAgentChange = (type: string, field: keyof Agent, value: Agent[keyof Agent]) => { setAgents(prev => ({ @@ -93,37 +105,56 @@ export function NotificationsSection() { }; const renderAgentTab = (type: 'discord' | 'slack' | 'webhook', title: string) => ( -
-
- + + handleAgentChange(type, 'enabled', c)} /> -
-
- + + handleAgentChange(type, 'url', e.target.value)} /> -
-
+ + - -
-
+ saveAgent(type)} disabled={isSavingAgent[type]}> + {isSavingAgent[type] ? ( + <> + + Saving + + ) : ( + 'Save' + )} + + + ); return ( -
+
setNotifTab(v as 'discord' | 'slack' | 'webhook')} className="w-full"> diff --git a/frontend/src/components/settings/SectionGate.tsx b/frontend/src/components/settings/SectionGate.tsx index 81c18b86..8e1f087d 100644 --- a/frontend/src/components/settings/SectionGate.tsx +++ b/frontend/src/components/settings/SectionGate.tsx @@ -1,10 +1,9 @@ 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 { getSettingsItem, isItemVisible, isItemLocked } from './registry'; import type { VisibilityContext } from './registry'; import type { SectionId } from './types'; @@ -52,10 +51,11 @@ export function SectionGate({ sectionId, children }: SectionGateProps) { const item = getSettingsItem(sectionId); - if (!item || !isItemVisible(item, visibility)) { - const fallback = SETTINGS_ITEMS.find(i => isItemVisible(i, visibility)); - return ; - } + // SettingsPage routes invisible sections back to a visible default before this + // component renders, so reaching this branch means the registry shape changed + // mid-session. Render nothing rather than throw — SettingsPage's effect will + // resolve to a valid section on the next tick. + if (!item || !isItemVisible(item, visibility)) return null; if (isItemLocked(item, visibility) && (item.tier === 'skipper' || item.tier === 'admiral')) { return ; diff --git a/frontend/src/components/settings/SecuritySection.tsx b/frontend/src/components/settings/SecuritySection.tsx index 45223493..d84fbdd8 100644 --- a/frontend/src/components/settings/SecuritySection.tsx +++ b/frontend/src/components/settings/SecuritySection.tsx @@ -28,6 +28,9 @@ import { toast } from '@/components/ui/toast-store'; import { apiFetch } from '@/lib/api'; import { PaidGate } from '@/components/PaidGate'; import { ShieldCheck, Plus, Trash2, Pencil, Download, RefreshCw, Loader2, Info } from 'lucide-react'; +import { SettingsCallout } from './SettingsCallout'; +import { SettingsPrimaryButton } from './SettingsActions'; +import { useMastheadStats } from './MastheadStatsContext'; import type { FleetRole, ScanPolicy, VulnSeverity } from '@/types/security'; import { useLicense } from '@/context/LicenseContext'; import { useNodes } from '@/context/NodeContext'; @@ -260,6 +263,19 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) { } }; + useMastheadStats( + loading + ? null + : [ + { label: 'POLICIES', value: `${policies.length}` }, + { + label: 'TRIVY', + value: trivy.source === 'none' ? 'missing' : trivy.source, + tone: trivy.source === 'none' ? 'warn' : 'value', + }, + ], + ); + if (!isPaid) { return (
@@ -277,10 +293,10 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
{!isRemote && !isReplica && (
- + + + Add policy +
)} @@ -317,14 +333,14 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) { {isAdmiral && (
{trivy.source === 'none' && ( - + )} {trivy.source === 'managed' && updateCheck?.updateAvailable && ( - + diff --git a/frontend/src/components/settings/SettingsActions.tsx b/frontend/src/components/settings/SettingsActions.tsx new file mode 100644 index 00000000..a669299a --- /dev/null +++ b/frontend/src/components/settings/SettingsActions.tsx @@ -0,0 +1,67 @@ +import { forwardRef, type ReactNode } from 'react'; +import { Button, type ButtonProps } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +interface SettingsActionsProps { + children: ReactNode; + hint?: ReactNode; + align?: 'end' | 'between'; + className?: string; +} + +/** + * Action row matching the audit's set-a-cta: secondary outline + cyan-filled primary, + * always in that order (left to right). The optional `hint` slot puts a mono micro-fact + * on the left (e.g. "DEPLOYS TO local"); pass `align="between"` to use it. + */ +export function SettingsActions({ children, hint, align = 'end', className }: SettingsActionsProps) { + return ( +
+ {hint ? ( + + {hint} + + ) : null} +
{children}
+
+ ); +} + +export const SettingsPrimaryButton = forwardRef( + ({ className, size, ...props }, ref) => ( + -
-

Settings

-

{nodeName}

-
-
- -
+