-
+
+
+
- {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 && (
-
- {billingLoading ? (
-
- ) : (
-
+ {license?.status === 'trial' && license.trialDaysRemaining !== null ? (
+
+
+
+
+ {license.trialDaysRemaining} day{license.trialDaysRemaining !== 1 ? 's' : ''}
+
+
+
+ ) : null}
+
+ {license?.status === 'active' ? (
+
+
+ {!license.isLifetime && (
+
+ {billingLoading ? (
+
+ ) : (
+
+ )}
+ Manage subscription
+
+
)}
- Manage Subscription
-
-
- )}
-
-
- Deactivating will revert to Community features.
-
-
{
- setIsDeactivating(true);
- const result = await deactivate();
- if (result.success) {
- toast.success('License deactivated.');
- } else {
- toast.error(result.error || 'Deactivation failed');
- }
- setIsDeactivating(false);
- }}
- disabled={isDeactivating}
- >
- {isDeactivating
- ? <> Deactivating...>
- : 'Deactivate License'
- }
-
-
-
- )}
+ {
+ setIsDeactivating(true);
+ const result = await deactivate();
+ if (result.success) {
+ toast.success('License deactivated.');
+ } else {
+ toast.error(result.error || 'Deactivation failed');
+ }
+ setIsDeactivating(false);
+ }}
+ disabled={isDeactivating}
+ >
+ {isDeactivating ? (
+ <>
+
+ Deactivating
+ >
+ ) : (
+ 'Deactivate'
+ )}
+
+
+
+ ) : 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.
-
-
- window.open(ADMIRAL_MONTHLY_CHECKOUT_URL, '_blank')}
- >
- Start monthly trial
-
-
- window.open(ADMIRAL_ANNUAL_CHECKOUT_URL, '_blank')}
- >
- Start annual trial
-
-
-
-
- After checkout, paste the license key from your email into the activate field below.
-
-
- )}
-
- {showUpgradeCards && (
-
-
Upgrade your plan
-
- {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}
-
- ))}
-
-
window.open(SKIPPER_CHECKOUT_URL, '_blank')}
- >
-
- Get Skipper
-
-
-
- )}
-
- {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}
-
- ))}
-
-
window.open(ADMIRAL_MONTHLY_CHECKOUT_URL, '_blank')}
- >
-
- Get Admiral
-
-
-
- )}
-
-
- )}
-
- {/* License key activation */}
- {license?.status !== 'active' && (
-
-
Have a license key?
-
-
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."
/>
-
{
- 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'
- }
-
+
+ window.open(ADMIRAL_MONTHLY_CHECKOUT_URL, '_blank')}
+ >
+ Start monthly trial
+
+
+ window.open(ADMIRAL_ANNUAL_CHECKOUT_URL, '_blank')}
+ >
+ Start annual 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
+
+
+ ) : (
+ 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
-
+
{ resetForm(); setShowForm(true); }}>
+ Add route
+
{ if (!open) resetForm(); }}>
@@ -469,9 +486,9 @@ export function NotificationRoutingSection() {
Cancel
-
- {saving ? <> Saving...> : editingId ? 'Update' : 'Create'}
-
+
+ {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) => (
-
-
- Enable {title}
+
+
handleAgentChange(type, 'enabled', c)}
/>
-
-
- Webhook URL
+
+
handleAgentChange(type, 'url', e.target.value)}
/>
-
-
+
+
testAgent(type)} disabled={isTestingAgent[type]}>
- {isTestingAgent[type] ? <> Testing...> : 'Test'}
+ {isTestingAgent[type] ? (
+ <>
+
+ Testing
+ >
+ ) : (
+ 'Test'
+ )}
- saveAgent(type)} disabled={isSavingAgent[type]}>
- {isSavingAgent[type] ? <> Saving...> : 'Save'}
-
-
-
+ 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
-
+
+
+ Add policy
+
)}
@@ -317,14 +333,14 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
{isAdmiral && (
{trivy.source === 'none' && (
-
+
{trivyBusy === 'install' ? (
) : (
)}
Install Trivy
-
+
)}
{trivy.source === 'managed' && updateCheck?.updateAvailable && (
@@ -399,13 +415,11 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
)}
{!isRemote && !loading && policies.length === 0 && (
-
-
-
No scan policies configured.
-
- Add one to enforce severity thresholds across your fleet.
-
-
+ }
+ title="No scan policies configured"
+ subtitle="Add one to enforce severity thresholds across your fleet."
+ />
)}
{!isRemote && !loading &&
@@ -527,9 +541,9 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
setDialogOpen(false)}>
Cancel
-
+
{saving ? 'Saving...' : editingId ? 'Update' : 'Create'}
-
+
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) => (
+
+ ),
+);
+SettingsPrimaryButton.displayName = 'SettingsPrimaryButton';
+
+export const SettingsSecondaryButton = forwardRef(
+ ({ className, variant, size, ...props }, ref) => (
+
+ ),
+);
+SettingsSecondaryButton.displayName = 'SettingsSecondaryButton';
+
diff --git a/frontend/src/components/settings/SettingsCallout.tsx b/frontend/src/components/settings/SettingsCallout.tsx
new file mode 100644
index 00000000..c6d1b6e5
--- /dev/null
+++ b/frontend/src/components/settings/SettingsCallout.tsx
@@ -0,0 +1,95 @@
+import type { ReactNode } from 'react';
+import { cn } from '@/lib/utils';
+
+export type SettingsCalloutTone = 'default' | 'warn' | 'error' | 'success' | 'brand';
+
+interface SettingsCalloutProps {
+ icon?: ReactNode;
+ title: ReactNode;
+ subtitle?: ReactNode;
+ action?: ReactNode;
+ tone?: SettingsCalloutTone;
+ className?: string;
+}
+
+const toneStyles: Record = {
+ default: {
+ border: 'border-card-border',
+ bg: 'bg-card',
+ iconBg: 'bg-glass',
+ iconText: 'text-stat-subtitle',
+ },
+ warn: {
+ border: 'border-warning/40',
+ bg: 'bg-warning/5',
+ iconBg: 'bg-warning/15',
+ iconText: 'text-warning',
+ },
+ error: {
+ border: 'border-destructive/40',
+ bg: 'bg-destructive/5',
+ iconBg: 'bg-destructive/15',
+ iconText: 'text-destructive',
+ },
+ success: {
+ border: 'border-success/40',
+ bg: 'bg-success/5',
+ iconBg: 'bg-success/15',
+ iconText: 'text-success',
+ },
+ brand: {
+ border: 'border-brand/40',
+ bg: 'bg-brand/5',
+ iconBg: 'bg-brand/15',
+ iconText: 'text-brand',
+ },
+};
+
+/**
+ * Callout card matching the audit's set-a-2fa pattern: icon, stacked title and subtitle,
+ * trailing action button. Used for gating prompts (Set up 2FA, Activate license,
+ * Configure SSO, empty-state CTAs).
+ */
+export function SettingsCallout({
+ icon,
+ title,
+ subtitle,
+ action,
+ tone = 'default',
+ className,
+}: SettingsCalloutProps) {
+ const styles = toneStyles[tone];
+ return (
+
+ {icon ? (
+
+ {icon}
+
+ ) : null}
+
+
+ {title}
+
+ {subtitle ? (
+
+ {subtitle}
+
+ ) : null}
+
+ {action ?
{action}
: null}
+
+ );
+}
diff --git a/frontend/src/components/settings/SettingsField.tsx b/frontend/src/components/settings/SettingsField.tsx
new file mode 100644
index 00000000..3036f736
--- /dev/null
+++ b/frontend/src/components/settings/SettingsField.tsx
@@ -0,0 +1,66 @@
+import type { ReactNode } from 'react';
+import { cn } from '@/lib/utils';
+
+export type SettingsFieldTone = 'default' | 'warn' | 'error' | 'success';
+
+interface SettingsFieldProps {
+ label: ReactNode;
+ helper?: ReactNode;
+ tone?: SettingsFieldTone;
+ htmlFor?: string;
+ children: ReactNode;
+ align?: 'center' | 'start';
+ className?: string;
+}
+
+const helperToneClass: Record = {
+ default: 'text-stat-subtitle',
+ warn: 'text-warning',
+ error: 'text-destructive',
+ success: 'text-success',
+};
+
+/**
+ * Two-column field row matching the audit's set-a-row pattern: label + mono helper on
+ * the left, control on the right. Replaces the stacked label-then-input-then-paragraph
+ * shadcn default. Use inside a .
+ */
+export function SettingsField({
+ label,
+ helper,
+ tone = 'default',
+ htmlFor,
+ children,
+ align = 'center',
+ className,
+}: SettingsFieldProps) {
+ return (
+
+
+
+ {label}
+
+ {helper ? (
+
+ {helper}
+
+ ) : null}
+
+
{children}
+
+ );
+}
diff --git a/frontend/src/components/settings/SettingsPage.tsx b/frontend/src/components/settings/SettingsPage.tsx
index 0ce7c9c8..22953c60 100644
--- a/frontend/src/components/settings/SettingsPage.tsx
+++ b/frontend/src/components/settings/SettingsPage.tsx
@@ -1,5 +1,4 @@
-import { useLayoutEffect, useRef, useState, useCallback, useMemo } from 'react';
-import { useParams, Navigate, useNavigate } from 'react-router-dom';
+import { useLayoutEffect, useRef, useState, useCallback, useMemo, useEffect } from 'react';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
CommandDialog,
@@ -9,7 +8,7 @@ import {
CommandItem,
CommandList,
} from '@/components/ui/command';
-import { Lock } from 'lucide-react';
+import { PageMasthead, type MastheadMetadataItem } from '@/components/ui/PageMasthead';
import { useAuth } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
import { useNodes } from '@/context/NodeContext';
@@ -43,13 +42,47 @@ import {
import type { SectionId, SettingsItemMeta, VisibilityContext } from './index';
import { SectionGate } from './SectionGate';
import { SettingsSidebar } from './SettingsSidebar';
+import { MastheadStatsProvider, useMastheadStatsValue } from './MastheadStatsContext';
+import { TierLockChip } from './TierLockChip';
import { cn } from '@/lib/utils';
-export function SettingsPage() {
- const { sectionId } = useParams<{ sectionId: string }>();
- // Derive currentSection from the registry item itself (trusted data), not from the raw
- // URL param, so the tainted sectionId string never reaches a property write.
- const currentSection: SectionId = (SETTINGS_ITEMS.find(i => i.id === sectionId)?.id) ?? 'appearance';
+interface SettingsPageProps {
+ currentSection: SectionId;
+ onSectionChange: (section: SectionId) => void;
+}
+
+export function SettingsPage(props: SettingsPageProps) {
+ return (
+
+
+
+ );
+}
+
+function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProps) {
+ const { isAdmin } = useAuth();
+ const { isPaid, license } = useLicense();
+ const { activeNode } = useNodes();
+ const isRemote = activeNode?.type === 'remote';
+ const isAdmiral = isPaid && license?.variant === 'admiral';
+ const visibility: VisibilityContext = useMemo(
+ () => ({ isRemote, isAdmin, isPaid, isAdmiral }),
+ [isRemote, isAdmin, isPaid, isAdmiral],
+ );
+
+ // Resolve the rendered section: must be a registry id and must be visible to the
+ // current operator. If the current selection points to a hidden section (e.g.,
+ // node-scoped item on a remote, or admin-only item for a non-admin), fall back to
+ // the first visible item.
+ const safeSection: SectionId = useMemo(() => {
+ const direct = SETTINGS_ITEMS.find(i => i.id === currentSection);
+ if (direct && isItemVisible(direct, visibility)) return direct.id;
+ const fallback = SETTINGS_ITEMS.find(i => isItemVisible(i, visibility));
+ return fallback?.id ?? 'appearance';
+ }, [currentSection, visibility]);
+ useEffect(() => {
+ if (safeSection !== currentSection) onSectionChange(safeSection);
+ }, [safeSection, currentSection, onSectionChange]);
const contentViewportRef = useRef(null);
// Map avoids prototype pollution: Map.set() does not write to object prototype chain.
@@ -67,28 +100,19 @@ export function SettingsPage() {
useLayoutEffect(() => {
if (contentViewportRef.current) {
- contentViewportRef.current.scrollTop = scrollPositionsRef.current.get(currentSection) ?? 0;
+ contentViewportRef.current.scrollTop = scrollPositionsRef.current.get(safeSection) ?? 0;
}
- }, [currentSection]);
+ }, [safeSection]);
const saveScrollPosition = useCallback(() => {
if (contentViewportRef.current) {
- scrollPositionsRef.current.set(currentSection, contentViewportRef.current.scrollTop);
+ scrollPositionsRef.current.set(safeSection, contentViewportRef.current.scrollTop);
}
- }, [currentSection]);
+ }, [safeSection]);
- const { isAdmin } = useAuth();
- const { isPaid, license } = useLicense();
- const { activeNode } = useNodes();
- const isRemote = activeNode?.type === 'remote';
- const isAdmiral = isPaid && license?.variant === 'admiral';
- const visibility: VisibilityContext = useMemo(
- () => ({ isRemote, isAdmin, isPaid, isAdmiral }),
- [isRemote, isAdmin, isPaid, isAdmiral],
- );
-
- const activeItem = getSettingsItem(currentSection);
+ const activeItem = getSettingsItem(safeSection);
const activeGroup = activeItem ? getSettingsGroup(activeItem.group) : undefined;
+ const nodeName = activeNode?.name ?? 'local';
const visibleItems = useMemo(
() => SETTINGS_ITEMS.filter(item => isItemVisible(item, visibility)),
@@ -105,8 +129,6 @@ export function SettingsPage() {
[visibleItems],
);
- const navigate = useNavigate();
-
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
@@ -115,7 +137,7 @@ export function SettingsPage() {
}, []);
const sectionElement = useMemo(() => {
- switch (currentSection) {
+ switch (safeSection) {
case 'account': return ;
case 'appearance': return ;
case 'license': return ;
@@ -135,61 +157,67 @@ export function SettingsPage() {
case 'app-store': return ;
case 'support': return ;
case 'about': return ;
- default: return ;
+ default: return null;
}
+ // Section components close over isPaid for tier-gated branches; handleDirtyChange is stable.
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [currentSection, isPaid]);
+ }, [safeSection, isPaid]);
+
+ const kicker = activeItem && activeGroup
+ ? `Settings · ${activeGroup.label} · ${activeItem.label}`
+ : 'Settings';
+
+ const extraStats = useMastheadStatsValue();
+ const metadata = useMemo(() => {
+ const baseScope: MastheadMetadataItem = activeItem
+ ? activeItem.scope === 'node'
+ ? { label: 'NODE', value: nodeName }
+ : { label: 'SCOPE', value: scopeLabel(activeItem) }
+ : { label: 'SCOPE', value: 'global' };
+ return [baseScope, ...(extraStats ?? [])];
+ }, [activeItem, nodeName, extraStats]);
return (
-
setCommandOpen(true)}
+
-
-
+
+
setCommandOpen(true)}
+ />
-
-
-
- {sectionElement}
-
-
-
+
+
+
+ {activeItem?.description ? (
+
+ {activeItem.description}
+
+ ) : null}
+
+ {sectionElement}
+
+
+
+
@@ -206,7 +234,7 @@ export function SettingsPage() {
visibility={visibility}
onSelect={() => {
setCommandOpen(false);
- navigate(`/settings/${item.id}`);
+ onSectionChange(item.id);
}}
/>
))}
@@ -218,6 +246,11 @@ export function SettingsPage() {
);
}
+function scopeLabel(item: SettingsItemMeta): string {
+ if (item.group === 'identity') return 'operator';
+ return 'global';
+}
+
function SettingsCommandItem({
item,
glyph,
@@ -233,17 +266,12 @@ function SettingsCommandItem({
const searchValue = [item.label, item.description, ...item.keywords].join(' ').toLowerCase();
return (
- {glyph}
+ {glyph}
{item.label}
{item.description}
- {item.tier && locked ? (
-
-
- {item.tier === 'admiral' ? 'ADMIRAL' : 'SKIPPER'}
-
- ) : null}
+ {item.tier && locked ? : null}
);
}
diff --git a/frontend/src/components/settings/SettingsSection.tsx b/frontend/src/components/settings/SettingsSection.tsx
new file mode 100644
index 00000000..fa305242
--- /dev/null
+++ b/frontend/src/components/settings/SettingsSection.tsx
@@ -0,0 +1,39 @@
+import type { ReactNode } from 'react';
+import { cn } from '@/lib/utils';
+
+interface SettingsSectionProps {
+ title: string;
+ kicker?: ReactNode;
+ description?: ReactNode;
+ children: ReactNode;
+ className?: string;
+}
+
+/**
+ * Mono-uppercase section header (PASSWORD, SESSIONS, THRESHOLDS) with a hairline rule
+ * underneath, matching the audit's set-a-section pattern. Children render in a stack
+ * with hairline dividers, typically SettingsField rows.
+ */
+export function SettingsSection({ title, kicker, description, children, className }: SettingsSectionProps) {
+ return (
+
+
+
+ {title}
+
+ {kicker ? (
+
+ {kicker}
+
+ ) : null}
+
+
+ {description ? (
+
+ {description}
+
+ ) : null}
+ {children}
+
+ );
+}
diff --git a/frontend/src/components/settings/SettingsSidebar.tsx b/frontend/src/components/settings/SettingsSidebar.tsx
index a89b428a..5baf0789 100644
--- a/frontend/src/components/settings/SettingsSidebar.tsx
+++ b/frontend/src/components/settings/SettingsSidebar.tsx
@@ -1,5 +1,4 @@
-import { NavLink, useNavigate } from 'react-router-dom';
-import { ChevronLeft, Lock, Search } from 'lucide-react';
+import { Search } from 'lucide-react';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useAuth } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
@@ -7,34 +6,17 @@ import { useNodes } from '@/context/NodeContext';
import { SETTINGS_GROUPS, SETTINGS_ITEMS, isItemVisible, isItemLocked } from './registry';
import type { VisibilityContext, SettingsItemMeta } from './registry';
import type { SectionId } from './types';
+import { TierLockChip } from './TierLockChip';
import { cn } from '@/lib/utils';
-interface TierChipProps {
- tier: 'skipper' | 'admiral';
-}
-
-function TierChip({ tier }: TierChipProps) {
- return (
-
- {tier === 'admiral' ? 'Admiral' : 'Skipper'}
-
- );
-}
-
interface SettingsSidebarProps {
+ currentSection: SectionId;
+ onSectionChange: (section: SectionId) => void;
dirtyFlags?: Partial>;
onOpenPalette: () => void;
}
-export function SettingsSidebar({ dirtyFlags, onOpenPalette }: SettingsSidebarProps) {
- const navigate = useNavigate();
+export function SettingsSidebar({ currentSection, onSectionChange, dirtyFlags, onOpenPalette }: SettingsSidebarProps) {
const { isAdmin } = useAuth();
const { isPaid, license } = useLicense();
const { activeNode } = useNodes();
@@ -49,93 +31,86 @@ export function SettingsSidebar({ dirtyFlags, onOpenPalette }: SettingsSidebarPr
isRemote,
};
- const nodeName = activeNode?.name ?? 'control plane';
-
- function handleBack() {
- if (window.history.length <= 1) {
- navigate('/');
- } else {
- navigate(-1);
- }
- }
-
function isVisible(item: SettingsItemMeta): boolean {
return isItemVisible(item, visibility);
}
return (
-
-
-
-
-
-
-
Settings
-
{nodeName}
-
-
-
-