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}

-
-
- -
+