feat(settings): dress the page to match the audit (#849)

* 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.
This commit is contained in:
Anso
2026-04-30 19:37:38 -04:00
committed by GitHub
parent 9a1c043189
commit eead195529
39 changed files with 1904 additions and 1244 deletions
+16 -8
View File
@@ -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');
});
+1 -1
View File
@@ -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();
});
/**
-58
View File
@@ -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",
-1
View File
@@ -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",
+8 -12
View File
@@ -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 (
<BrowserRouter>
<AuthProvider>
<DeployFeedbackProvider>
<AppContent />
<DeployFeedbackPortal />
</DeployFeedbackProvider>
<ToastContainer />
</AuthProvider>
</BrowserRouter>
<AuthProvider>
<DeployFeedbackProvider>
<AppContent />
<DeployFeedbackPortal />
</DeployFeedbackProvider>
<ToastContainer />
</AuthProvider>
);
}
+23 -11
View File
@@ -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() {
<CapabilityGate capability="api-tokens" featureName="API Tokens">
<div className="space-y-6">
<div className="flex justify-end">
<Button size="sm" onClick={() => setShowForm(!showForm)}>
<Plus className="w-4 h-4 mr-1.5" strokeWidth={1.5} /> Create Token
</Button>
<SettingsPrimaryButton size="sm" onClick={() => setShowForm(!showForm)}>
<Plus className="w-4 h-4" strokeWidth={1.5} /> Create token
</SettingsPrimaryButton>
</div>
{/* Create form */}
@@ -180,9 +192,9 @@ export function ApiTokensSection() {
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" size="sm" onClick={() => setShowForm(false)}>Cancel</Button>
<Button size="sm" onClick={handleCreate} disabled={creating}>
{creating ? <><RefreshCw className="w-4 h-4 mr-1.5 animate-spin" />Creating...</> : 'Create'}
</Button>
<SettingsPrimaryButton size="sm" onClick={handleCreate} disabled={creating}>
{creating ? <><RefreshCw className="w-4 h-4 animate-spin" />Creating</> : 'Create'}
</SettingsPrimaryButton>
</div>
</div>
)}
@@ -214,11 +226,11 @@ export function ApiTokensSection() {
{/* Empty state */}
{!loading && tokens.length === 0 && !showForm && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Zap className="w-10 h-10 text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground">No API tokens yet.</p>
<p className="text-xs text-muted-foreground mt-1">Create one to authenticate CI/CD pipelines and scripts.</p>
</div>
<SettingsCallout
icon={<Zap className="h-4 w-4" />}
title="No API tokens yet"
subtitle="Create one to authenticate CI/CD pipelines and scripts."
/>
)}
{/* Token list */}
+18 -16
View File
@@ -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<SectionId>('appearance');
const [securityHistoryOpen, setSecurityHistoryOpen] = useState(false);
const [filterNodeId, setFilterNodeId] = useState<number | null>(null);
const [schedulePrefill, setSchedulePrefill] = useState<ScheduleTaskPrefill | null>(null);
@@ -359,8 +360,11 @@ export default function EditorLayout() {
const [autoUpdateSettings, setAutoUpdateSettings] = useState<Record<string, boolean>>({});
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<NotificationItem[]>([]);
@@ -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={
<NodeSwitcher
onManageNodes={() => navigate('/settings/nodes')}
onManageNodes={() => handleOpenSettings('nodes')}
/>
}
createStackSlot={createStackSlot}
@@ -2335,21 +2339,19 @@ export default function EditorLayout() {
<UserProfileDropdown
theme={theme}
setTheme={setTheme}
onOpenSettings={() => handleOpenSettings('account')}
/>
}
/>
{/* Main Workspace */}
{isSettingsRoute ? (
<div className="flex-1 flex overflow-hidden">
<Routes>
<Route path="/settings/:sectionId" element={<SettingsPage />} />
<Route path="/settings" element={<Navigate to="/settings/account" replace />} />
</Routes>
</div>
) : (
<div key={activeView} className="flex-1 overflow-y-auto p-6 animate-fade-up">
{activeView === 'templates' ? (
{activeView === 'settings' ? (
<SettingsPage
currentSection={settingsSection}
onSectionChange={setSettingsSection}
/>
) : activeView === 'templates' ? (
<AppStoreView onDeploySuccess={(stackName) => { refreshStacks(); loadFile(stackName); }} />
) : activeView === 'resources' ? (
<ResourcesView />
@@ -2929,12 +2931,12 @@ export default function EditorLayout() {
) : (
<HomeDashboard
onNavigateToStack={(stackFile) => { loadFile(stackFile); }}
onOpenSettingsSection={(section) => handleOpenSettings(section)}
notifications={notifications}
onClearNotifications={clearAllNotifications}
/>
)}
</div>
)}
</div>
{/* Delete Confirmation Dialog */}
+4 -2
View File
@@ -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<void>;
}
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
/>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<ConfigurationStatus />
<ConfigurationStatus onOpenSection={onOpenSettingsSection} />
<RecentActivity />
</div>
+16 -6
View File
@@ -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() {
}}
>
<DialogTrigger asChild>
<Button size="sm" className="gap-1 shrink-0">
<SettingsPrimaryButton size="sm" className="gap-1 shrink-0">
<Plus className="w-4 h-4" />
Add Node
</Button>
Add node
</SettingsPrimaryButton>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader className="pr-8">
@@ -427,15 +437,15 @@ export function NodeManager() {
{renderFormFields()}
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button
<SettingsPrimaryButton
onClick={handleCreate}
disabled={
!formData.name ||
(formData.type === 'remote' && formData.mode === 'proxy' && (!formData.api_url || !formData.api_token))
}
>
Add Node
</Button>
Add node
</SettingsPrimaryButton>
</DialogFooter>
</DialogContent>
</Dialog>
+22 -11
View File
@@ -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() {
<CapabilityGate capability="registries" featureName="Private Registries">
<div className="space-y-6">
<div className="flex justify-end">
<Button size="sm" onClick={() => { resetForm(); setShowForm(true); }}>
<Plus className="w-4 h-4 mr-1.5" strokeWidth={1.5} /> Add Registry
</Button>
<SettingsPrimaryButton size="sm" onClick={() => { resetForm(); setShowForm(true); }}>
<Plus className="w-4 h-4" strokeWidth={1.5} /> Add registry
</SettingsPrimaryButton>
</div>
{/* Create / Edit form */}
@@ -356,11 +367,11 @@ export function RegistriesSection() {
</Button>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={resetForm}>Cancel</Button>
<Button size="sm" onClick={handleSave} disabled={saving}>
<SettingsPrimaryButton size="sm" onClick={handleSave} disabled={saving}>
{saving ? (
<><RefreshCw className="w-4 h-4 mr-1.5 animate-spin" strokeWidth={1.5} />Saving...</>
<><RefreshCw className="w-4 h-4 animate-spin" strokeWidth={1.5} />Saving</>
) : editingId ? 'Update' : 'Add'}
</Button>
</SettingsPrimaryButton>
</div>
</div>
</div>
@@ -376,11 +387,11 @@ export function RegistriesSection() {
{/* Empty state */}
{!loading && registries.length === 0 && !showForm && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Database className="w-10 h-10 text-muted-foreground/50 mb-3" strokeWidth={1.5} />
<p className="text-sm text-muted-foreground">No private registries configured.</p>
<p className="text-xs text-muted-foreground mt-1">Add one to pull images from Docker Hub orgs, GHCR, ECR, or self-hosted registries.</p>
</div>
<SettingsCallout
icon={<Database className="h-4 w-4" strokeWidth={1.5} />}
title="No private registries configured"
subtitle="Add one to pull images from Docker Hub orgs, GHCR, ECR, or self-hosted registries."
/>
)}
{/* Registry list */}
+15 -3
View File
@@ -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 }: {
<div className="flex items-center justify-between pt-2">
<div className="flex items-center gap-2">
<Button size="sm" onClick={handleSave} disabled={saving}>
{saving ? <><Loader2 className="w-3 h-3 mr-1 animate-spin" /> Saving...</> : 'Save'}
</Button>
<SettingsPrimaryButton size="sm" onClick={handleSave} disabled={saving}>
{saving ? <><Loader2 className="w-3 h-3 animate-spin" /> Saving</> : 'Save'}
</SettingsPrimaryButton>
<Button size="sm" variant="outline" onClick={handleTest} disabled={testing}>
{testing ? <><Loader2 className="w-3 h-3 mr-1 animate-spin" /> Testing...</> : 'Test Connection'}
</Button>
@@ -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 (
@@ -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 */}
<div className="border-t border-card-border/60">
<MenuRow icon={Settings} label="Settings" onClick={() => navigate('/settings')} />
<MenuRow icon={Settings} label="Settings" onClick={onOpenSettings} />
{showBilling ? (
<MenuRow
icon={CreditCard}
@@ -2,9 +2,12 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Bell, Zap, Shield, HardDrive, ChevronRight } from 'lucide-react';
import { formatCount } from '@/lib/utils';
import { useConfigurationStatus } from './useConfigurationStatus';
import { useNavigate } from 'react-router-dom';
import type { SectionId } from '@/components/settings/types';
interface ConfigurationStatusProps {
onOpenSection?: (section: SectionId) => 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 (
@@ -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 (
<div className="space-y-6">
<div className="space-y-4 bg-glass border border-glass-border p-4 rounded-lg">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Version</span>
<Badge variant="secondary" className="font-mono">v{__APP_VERSION__}</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Tier</span>
<div><TierBadge /></div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">License Status</span>
<Badge variant="outline" className="capitalize">{license?.status ?? 'community'}</Badge>
</div>
{license?.instanceId && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Instance ID</span>
<code className="text-xs font-mono bg-muted px-2 py-1 rounded">{license.instanceId.slice(0, 8)}</code>
</div>
)}
</div>
<div className="flex flex-col gap-10">
<SettingsSection title="Build">
<SettingsField label="Version">
<span className="font-mono text-sm text-stat-value">v{__APP_VERSION__}</span>
</SettingsField>
<SettingsField label="Tier">
<TierBadge />
</SettingsField>
<SettingsField label="License status">
<span className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-value">
{license?.status ?? 'community'}
</span>
</SettingsField>
{license?.instanceId ? (
<SettingsField
label="Instance ID"
helper="Used to identify this control plane to the license server."
>
<code className="text-xs font-mono bg-muted px-2 py-1 rounded">
{license.instanceId.slice(0, 8)}
</code>
</SettingsField>
) : null}
</SettingsSection>
<div className="space-y-2">
<h4 className="text-sm font-medium">Links</h4>
<div className="flex flex-col gap-1.5">
<SettingsSection title="Links">
<SettingsField
label="Changelog"
helper="See what shipped, when, and why."
>
<a
href="https://github.com/studio-saelix/sencho/blob/main/CHANGELOG.md"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-brand hover:text-brand/80 transition-colors"
>
Changelog &rarr;
github.com/studio-saelix/sencho
</a>
</div>
</div>
</SettingsField>
</SettingsSection>
</div>
);
}
@@ -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 (
<div className="space-y-6">
<div className="space-y-4 max-w-sm">
<div className="space-y-2">
<Label>Current Password</Label>
<div className="flex flex-col gap-10">
<SettingsSection title="Password">
<SettingsField
label="Current password"
helper="Required to change any auth setting on this account."
htmlFor="account-current-password"
>
<Input
id="account-current-password"
type="password"
autoComplete="current-password"
value={authData.oldPassword}
onChange={(e) => setAuthData({ ...authData, oldPassword: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>New Password</Label>
</SettingsField>
<SettingsField
label="New password"
helper={passwordStrengthHelper}
tone={newPasswordTone}
htmlFor="account-new-password"
>
<Input
id="account-new-password"
type="password"
autoComplete="new-password"
value={authData.newPassword}
onChange={(e) => setAuthData({ ...authData, newPassword: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Confirm New Password</Label>
</SettingsField>
<SettingsField
label="Confirm new password"
helper={confirmHelper}
tone={confirmTone}
htmlFor="account-confirm-password"
>
<Input
id="account-confirm-password"
type="password"
autoComplete="new-password"
value={authData.confirmPassword}
onChange={(e) => setAuthData({ ...authData, confirmPassword: e.target.value })}
/>
</div>
<Button onClick={handlePasswordChange} disabled={isSaving} className="w-full">
{isSaving
? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Updating...</>
: 'Update Password'
}
</Button>
</div>
<Separator />
{/* Two-factor authentication card */}
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-5 max-w-lg">
<div className="flex items-start gap-3">
{mfa?.enabled
? <ShieldCheck className="w-5 h-5 mt-0.5 text-success" strokeWidth={1.5} />
: <Shield className="w-5 h-5 mt-0.5 text-muted-foreground" strokeWidth={1.5} />
}
<div className="flex-1">
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium">Two-factor authentication</h4>
{mfa?.enabled && <Badge variant="secondary">Enabled</Badge>}
</div>
<p className="text-sm text-muted-foreground mt-1">
{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.'}
</p>
{mfaLoading ? (
<div className="mt-4 text-xs text-muted-foreground">Loading</div>
) : mfa?.enabled ? (
<div className="mt-4 space-y-3">
{mfa.backupCodesRemaining === 0 ? (
<div className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 p-3">
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0 text-destructive" strokeWidth={1.5} />
<div className="flex-1">
<div className="text-sm font-medium text-destructive">No backup codes left</div>
<div className="text-xs text-destructive/80 mt-0.5">
Regenerate a new set before you lose access to your authenticator app. Without codes, recovery needs an administrator.
</div>
<Button
variant="ghost"
size="sm"
className="mt-2 h-7 px-2 text-destructive hover:bg-destructive hover:text-destructive-foreground"
onClick={() => setRegenOpen(true)}
>
Regenerate now
</Button>
</div>
</div>
) : mfa.backupCodesRemaining <= 2 ? (
<div className="flex items-center gap-2 text-xs font-mono tabular-nums text-warning">
<AlertTriangle className="w-3.5 h-3.5 shrink-0" strokeWidth={1.5} />
<span>
{mfa.backupCodesRemaining} backup code{mfa.backupCodesRemaining === 1 ? '' : 's'} remaining, regenerate a fresh set
</span>
</div>
) : (
<div className="text-xs text-muted-foreground font-mono tabular-nums">
{mfa.backupCodesRemaining} backup codes remaining
</div>
)}
{hasSso && (
<div className="flex items-start justify-between gap-3 rounded-md border border-card-border bg-background/40 p-3">
<div>
<div className="text-sm">Require 2FA even when signing in via SSO</div>
<div className="text-xs text-muted-foreground mt-0.5">
SSO logins skip the second factor by default.
</div>
</div>
<TogglePill
checked={mfa.sso_enforce_mfa}
onChange={handleBypassToggle}
disabled={togglingBypass}
/>
</div>
)}
<div className="flex flex-wrap gap-2">
<Button variant="ghost" size="sm" onClick={() => setRegenOpen(true)}>
Regenerate backup codes
</Button>
<Button
variant="ghost"
size="sm"
className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
onClick={() => setDisableOpen(true)}
>
Disable 2FA
</Button>
</div>
</div>
</SettingsField>
<SettingsActions>
<SettingsPrimaryButton onClick={handlePasswordChange} disabled={isSaving}>
{isSaving ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
Updating
</>
) : (
<div className="mt-4">
<Button size="sm" onClick={() => setEnrollOpen(true)}>
Set up 2FA
'Update password'
)}
</SettingsPrimaryButton>
</SettingsActions>
</SettingsSection>
<SettingsSection
title="Two-factor authentication"
kicker={mfa?.enabled ? 'enabled' : 'off'}
>
{mfaLoading ? (
<div className="py-4 text-xs text-stat-subtitle">Loading</div>
) : mfa?.enabled ? (
<>
<SettingsField
label="Authenticator app"
helper="Sign-in requires a time-based code from your authenticator. Keep your backup codes safe."
>
<div className="flex items-center gap-2">
<ShieldCheck className="h-4 w-4 text-brand" strokeWidth={1.5} />
<span className="font-mono text-[12px] uppercase tracking-[0.16em] text-stat-value">
enrolled
</span>
</div>
</SettingsField>
<SettingsField
label="Backup codes"
helper={
mfa.backupCodesRemaining === 0
? 'No backup codes remain. Regenerate a fresh set before you lose access to your authenticator.'
: mfa.backupCodesRemaining <= 2
? 'Running low. Regenerate a fresh set.'
: 'Single-use codes for when your authenticator is unavailable.'
}
tone={
mfa.backupCodesRemaining === 0
? 'error'
: mfa.backupCodesRemaining <= 2
? 'warn'
: 'default'
}
>
<div className="flex items-center gap-3">
<span className="font-mono tabular-nums text-sm text-stat-value">
{mfa.backupCodesRemaining} remaining
</span>
<Button variant="outline" size="sm" onClick={() => setRegenOpen(true)}>
Regenerate
</Button>
</div>
)}
</SettingsField>
{hasSso ? (
<SettingsField
label="Require 2FA on SSO sign-in"
helper="By default, SSO logins skip the second factor. Enforce it here to require both."
>
<TogglePill
checked={mfa.sso_enforce_mfa}
onChange={handleBypassToggle}
disabled={togglingBypass}
/>
</SettingsField>
) : null}
<SettingsActions>
<Button
variant="ghost"
size="sm"
className="text-destructive/80 hover:bg-destructive hover:text-destructive-foreground"
onClick={() => setDisableOpen(true)}
>
Disable 2FA
</Button>
</SettingsActions>
</>
) : (
<div className="pt-3">
<SettingsCallout
tone="warn"
icon={<Shield className="h-4 w-4" strokeWidth={1.5} />}
title="Two-factor is off"
subtitle="Add a time-based code from your authenticator app, every sign-in."
action={
<SettingsPrimaryButton size="sm" onClick={() => setEnrollOpen(true)}>
Set up 2FA
</SettingsPrimaryButton>
}
/>
</div>
</div>
</div>
)}
{mfa?.enabled && mfa.backupCodesRemaining === 0 ? (
<div className="pt-3">
<SettingsCallout
tone="error"
icon={<AlertTriangle className="h-4 w-4" strokeWidth={1.5} />}
title="No backup codes left"
subtitle="Without codes, recovery needs an administrator if you lose your authenticator."
action={
<Button variant="outline" size="sm" onClick={() => setRegenOpen(true)}>
Regenerate
</Button>
}
/>
</div>
) : null}
</SettingsSection>
<MfaEnrollDialog open={enrollOpen} onOpenChange={setEnrollOpen} onEnrolled={refreshMfa} />
<MfaDisableDialog open={disableOpen} onOpenChange={setDisableOpen} onDisabled={refreshMfa} />
@@ -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 (
<div className="space-y-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
<div className="space-y-4 bg-glass border border-glass-border p-4 rounded-lg">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
<div className="space-y-3 rounded-lg border border-glass-border bg-glass p-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
);
}
@@ -74,53 +71,58 @@ export function AppStoreSection() {
}
};
if (isLoading) return <SectionSkeleton />;
return (
<div className="space-y-6">
{isLoading ? <SettingsSkeleton /> : (
<>
<div className="space-y-6 bg-glass border border-glass-border p-4 rounded-lg">
<div className="space-y-1">
<Label className="text-base">Default Registry</Label>
<p className="text-xs text-muted-foreground">
LinuxServer.io - <span className="font-mono">https://api.linuxserver.io/api/v1/images</span>
</p>
<p className="text-xs text-muted-foreground">Used when no custom registry is set.</p>
</div>
<div className="flex flex-col gap-10">
<SettingsSection title="Default registry">
<SettingsField
label="LinuxServer.io"
helper="Used when no custom registry is set."
>
<code className="font-mono text-xs text-stat-subtitle">
api.linuxserver.io/api/v1/images
</code>
</SettingsField>
</SettingsSection>
<div className="space-y-3 pt-4 border-t border-glass-border">
<div className="space-y-1">
<Label className="text-base">Custom Registry URL</Label>
<p className="text-xs text-muted-foreground">
Provide a URL pointing to a <span className="font-medium">Portainer v2</span> compatible template JSON file. Overrides the default registry.
</p>
</div>
<Input
placeholder="https://example.com/templates.json"
value={templateRegistryUrl}
onChange={(e) => setTemplateRegistryUrl(e.target.value)}
/>
<p className="text-xs text-muted-foreground">Leave empty to use the default LinuxServer.io registry.</p>
</div>
</div>
<SettingsSection title="Custom registry">
<SettingsField
label="Registry URL"
helper="Provide a Portainer v2 compatible template JSON URL. Overrides the default registry. Leave empty to use LinuxServer.io."
htmlFor="template-registry-url"
>
<Input
id="template-registry-url"
placeholder="https://example.com/templates.json"
value={templateRegistryUrl}
onChange={(e) => setTemplateRegistryUrl(e.target.value)}
/>
</SettingsField>
<div className="flex items-center justify-between">
<SettingsActions align="between" hint={templateRegistryUrl ? 'using custom registry' : 'using default'}>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setTemplateRegistryUrl('')}
disabled={isSavingRegistry || !templateRegistryUrl}
>
Reset to Default
</Button>
<Button onClick={saveRegistrySettings} disabled={isSavingRegistry}>
{isSavingRegistry
? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Saving...</>
: 'Save & Refresh'
}
Reset to default
</Button>
<SettingsPrimaryButton onClick={saveRegistrySettings} disabled={isSavingRegistry}>
{isSavingRegistry ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
Saving
</>
) : (
'Save & refresh'
)}
</SettingsPrimaryButton>
</div>
</>
)}
</SettingsActions>
</SettingsSection>
</div>
);
}
@@ -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 (
<div className="space-y-6">
<div className="space-y-4 bg-glass border border-glass-border p-4 rounded-lg">
<div className="space-y-2">
<Label htmlFor="density-select">Density</Label>
<div className="flex flex-col gap-10">
<SettingsSection title="Display" kicker="this browser">
<SettingsField
label="Density"
helper={DENSITY_DESCRIPTIONS[density]}
>
<Combobox
options={DENSITY_OPTIONS}
value={density}
@@ -32,28 +35,30 @@ export function AppearanceSection() {
}}
placeholder="Select density"
/>
<p className="text-xs text-stat-subtitle">
{DENSITY_DESCRIPTIONS[density]}
</p>
</div>
</div>
<div className="space-y-4 bg-glass border border-glass-border p-4 rounded-lg">
<div className="flex items-start gap-3">
<Checkbox
id="deploy-feedback"
checked={isEnabled}
onCheckedChange={(v) => setEnabled(v === true)}
/>
<label htmlFor="deploy-feedback">
<p className="text-sm font-medium cursor-pointer">Show deploy progress modal</p>
<p className="text-xs text-stat-subtitle mt-0.5">
Stream live output for deploy, restart, update, install, and Git operations.
</p>
</label>
</div>
</div>
<p className="text-xs text-stat-subtitle">
Preference is saved to this browser only. Each device you use remembers its own choice.
</SettingsField>
<SettingsField
label="Deploy progress modal"
helper="Stream live output for deploy, restart, update, install, and Git operations."
>
<div className="flex items-center gap-2">
<Checkbox
id="deploy-feedback"
checked={isEnabled}
onCheckedChange={(v) => setEnabled(v === true)}
/>
<label
htmlFor="deploy-feedback"
className="text-sm text-stat-value cursor-pointer select-none"
>
{isEnabled ? 'Enabled' : 'Disabled'}
</label>
</div>
</SettingsField>
</SettingsSection>
<p className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle/70">
saved to this browser only · every device remembers its own choice
</p>
</div>
);
@@ -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 (
<div className="space-y-3">
@@ -294,10 +317,10 @@ export function CloudBackupSection() {
<p className="text-xs text-muted-foreground">
Activates a 500 MB allowance backed by Cloudflare R2, scoped to this Admiral license.
</p>
<Button size="sm" onClick={handleProvision} disabled={provisioning}>
<SettingsPrimaryButton size="sm" onClick={handleProvision} disabled={provisioning}>
{provisioning ? <Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} /> : <Cloud className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />}
Activate
</Button>
</SettingsPrimaryButton>
</div>
)}
@@ -362,10 +385,10 @@ export function CloudBackupSection() {
{testing ? <Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} /> : null}
Test
</Button>
<Button size="sm" onClick={handleSaveCustom} disabled={saving}>
{saving ? <Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} /> : null}
<SettingsPrimaryButton size="sm" onClick={handleSaveCustom} disabled={saving}>
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" strokeWidth={1.5} /> : null}
Save
</Button>
</SettingsPrimaryButton>
</div>
</div>
@@ -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 (
<div className="space-y-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
<div className="space-y-4 bg-glass border border-glass-border p-4 rounded-lg">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
<div className="space-y-3 rounded-lg border border-glass-border bg-glass p-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
);
}
@@ -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 <SectionSkeleton />;
return (
<div className="space-y-6">
{isLoading ? <SettingsSkeleton /> : (
<>
<div className="space-y-6 bg-glass border border-glass-border p-4 rounded-lg">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="developer_mode" className="text-base">Developer Mode</Label>
<p className="text-xs text-muted-foreground">Enable Real-Time Metrics and Debug Diagnostics</p>
</div>
<TogglePill
id="developer_mode"
checked={settings.developer_mode === '1'}
onChange={(c) => onSettingChange('developer_mode', c ? '1' : '0')}
/>
</div>
</div>
<div className="flex flex-col gap-10">
<SettingsSection title="Diagnostics">
<SettingsField
label="Developer mode"
helper="Enable real-time metrics streams and verbose debug diagnostics in the UI."
>
<TogglePill
id="developer_mode"
checked={settings.developer_mode === '1'}
onChange={(c) => onSettingChange('developer_mode', c ? '1' : '0')}
/>
</SettingsField>
</SettingsSection>
{/* Data Retention (Observability) */}
<div className="space-y-3">
<SettingsSection title="Data retention">
<SettingsField
label="Container metrics"
helper="How long to keep per-container CPU, RAM, and network history."
>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={8760}
value={settings.metrics_retention_hours}
onChange={(e) => onSettingChange('metrics_retention_hours', e.target.value)}
className="w-24"
/>
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">hrs</span>
</div>
</SettingsField>
<SettingsField
label="Notification log"
helper="How long to keep alert and notification history."
>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={365}
value={settings.log_retention_days}
onChange={(e) => onSettingChange('log_retention_days', e.target.value)}
className="w-24"
/>
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">days</span>
</div>
</SettingsField>
{isPaid && license?.variant === 'admiral' && (
<SettingsField
label="Audit log"
helper="How long to keep audit trail entries."
>
<div className="flex items-center gap-2">
<Database className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium text-foreground">Data Retention</span>
<Input
type="number"
min={1}
max={365}
value={settings.audit_retention_days}
onChange={(e) => onSettingChange('audit_retention_days', e.target.value)}
className="w-24"
/>
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">days</span>
</div>
<div className="space-y-4 bg-glass border border-glass-border p-4 rounded-lg">
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<Label className="text-base">Container Metrics Retention</Label>
<p className="text-xs text-muted-foreground">How long to keep per-container CPU/RAM/network history.</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Input
type="number"
min={1}
max={8760}
value={settings.metrics_retention_hours}
onChange={(e) => onSettingChange('metrics_retention_hours', e.target.value)}
className="w-20"
/>
<span className="text-sm text-muted-foreground w-8">hrs</span>
</div>
</div>
</SettingsField>
)}
</SettingsSection>
<div className="flex items-center justify-between gap-4 pt-4 border-t border-glass-border">
<div className="space-y-0.5">
<Label className="text-base">Notification Log Retention</Label>
<p className="text-xs text-muted-foreground">How long to keep alert and notification history.</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Input
type="number"
min={1}
max={365}
value={settings.log_retention_days}
onChange={(e) => onSettingChange('log_retention_days', e.target.value)}
className="w-20"
/>
<span className="text-sm text-muted-foreground w-8">days</span>
</div>
</div>
{isPaid && license?.variant === 'admiral' && (
<div className="flex items-center justify-between gap-4 pt-4 border-t border-glass-border">
<div className="space-y-0.5">
<Label className="text-base">Audit Log Retention</Label>
<p className="text-xs text-muted-foreground">How long to keep audit trail entries.</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Input
type="number"
min={1}
max={365}
value={settings.audit_retention_days}
onChange={(e) => onSettingChange('audit_retention_days', e.target.value)}
className="w-20"
/>
<span className="text-sm text-muted-foreground w-8">days</span>
</div>
</div>
)}
</div>
</div>
<div className="flex justify-end">
<Button onClick={saveSettings} disabled={isSaving}>
{isSaving
? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Saving...</>
: 'Save Developer Settings'
}
</Button>
</div>
</>
)}
<SettingsActions hint={hasChanges ? 'unsaved changes' : undefined}>
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges}>
{isSaving ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
Saving
</>
) : (
'Save settings'
)}
</SettingsPrimaryButton>
</SettingsActions>
</div>
);
}
@@ -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 = {}) {
<CapabilityGate capability="labels" featureName="Stack Labels">
<div className="space-y-4">
<div className="flex justify-end">
<Button size="sm" onClick={openCreate} disabled={labels.length >= MAX_LABELS_PER_NODE}>
<Plus className="w-4 h-4 mr-1.5" strokeWidth={1.5} />
{labels.length >= MAX_LABELS_PER_NODE ? 'Limit reached' : 'New Label'}
</Button>
<SettingsPrimaryButton size="sm" onClick={openCreate} disabled={labels.length >= MAX_LABELS_PER_NODE}>
<Plus className="w-4 h-4" strokeWidth={1.5} />
{labels.length >= MAX_LABELS_PER_NODE ? 'Limit reached' : 'New label'}
</SettingsPrimaryButton>
</div>
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel">
{loading ? (
<div className="p-6 text-center text-sm text-muted-foreground">Loading...</div>
<div className="p-6 text-center text-sm text-stat-subtitle">Loading</div>
) : labels.length === 0 ? (
<div className="p-6 text-center text-sm text-muted-foreground">
No labels yet. Create one to start organizing your stacks.
</div>
<SettingsCallout
className="m-2"
title="No labels yet"
subtitle="Create one to start organizing your stacks."
/>
) : (
<div className="divide-y divide-border">
{labels.map(label => (
@@ -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 ? <CheckCircle className="h-4 w-4" /> : <Crown className="h-4 w-4" />;
return (
<div className="space-y-6">
{/* Current Tier Display */}
<div className="bg-glass border border-glass-border p-4 rounded-lg space-y-4">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-10">
<SettingsSection title="Plan">
<SettingsField
label={getTierDisplayName(license?.tier, license?.variant, license?.status)}
helper={
license?.status === 'expired'
? 'Your license has expired. Renew to restore paid features.'
: license?.status === 'disabled'
? 'Your license has been disabled. Contact support for assistance.'
: license?.status === 'trial' && license.trialDaysRemaining !== null
? `Trial: ${license.trialDaysRemaining} day${license.trialDaysRemaining !== 1 ? 's' : ''} remaining.`
: isPaid
? 'Active license on this control plane.'
: 'Free tier with the core experience.'
}
tone={
license?.status === 'expired' || license?.status === 'disabled'
? 'error'
: license?.status === 'trial'
? 'warn'
: 'default'
}
>
<div className="flex items-center gap-2">
{isPaid ? (
<CheckCircle className="w-5 h-5 text-success" />
) : (
<Crown className="w-5 h-5 text-muted-foreground" />
)}
<span className="font-medium text-base">
{getTierDisplayName(license?.tier, license?.variant, license?.status)}
</span>
<span className="text-stat-subtitle">{tierIcon}</span>
<TierBadge />
</div>
<TierBadge />
</div>
</SettingsField>
{license?.status === 'trial' && license.trialDaysRemaining !== null && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="w-4 h-4" />
<span>Trial: {license.trialDaysRemaining} day{license.trialDaysRemaining !== 1 ? 's' : ''} remaining</span>
</div>
)}
{license?.status === 'active' && license.customerName ? (
<SettingsField label="Customer">
<span className="text-sm text-stat-value">{license.customerName}</span>
</SettingsField>
) : null}
{license?.status === 'active' && (
<div className="space-y-2 text-sm">
{license.customerName && (
<div className="flex justify-between">
<span className="text-muted-foreground">Customer</span>
<span>{license.customerName}</span>
</div>
)}
{license.productName && (
<div className="flex justify-between">
<span className="text-muted-foreground">Plan</span>
<span>{license.productName}</span>
</div>
)}
{license.maskedKey && (
<div className="flex justify-between">
<span className="text-muted-foreground">License Key</span>
<span className="font-mono text-xs">{license.maskedKey}</span>
</div>
)}
{(license.isLifetime || license.validUntil) && (
<div className="flex justify-between">
<span className="text-muted-foreground">{license.isLifetime ? 'Duration' : 'Renews'}</span>
<span>{license.isLifetime ? 'Lifetime' : new Date(license.validUntil!).toLocaleDateString()}</span>
</div>
)}
</div>
)}
{license?.status === 'active' && license.productName ? (
<SettingsField label="Product">
<span className="text-sm text-stat-value">{license.productName}</span>
</SettingsField>
) : null}
{license?.status === 'expired' && (
<div className="flex items-center gap-2 text-sm text-destructive">
<XCircle className="w-4 h-4" />
<span>Your license has expired. Renew to restore paid features.</span>
</div>
)}
{license?.status === 'active' && license.maskedKey ? (
<SettingsField label="License key">
<span className="font-mono text-xs text-stat-value">{license.maskedKey}</span>
</SettingsField>
) : null}
{license?.status === 'disabled' && (
<div className="flex items-center gap-2 text-sm text-destructive">
<XCircle className="w-4 h-4" />
<span>Your license has been disabled. Contact support for assistance.</span>
</div>
)}
</div>
{license?.status === 'expired' ? (
<SettingsField
label="Status"
helper="Renew to restore paid features."
tone="error"
>
<div className="flex items-center gap-2 text-destructive">
<XCircle className="h-4 w-4" />
<span className="text-sm">Expired</span>
</div>
</SettingsField>
) : null}
{/* Manage Subscription (active paid license) */}
{license?.status === 'active' && (
<div className="space-y-3">
{!license.isLifetime && (
<Button
variant="outline"
size="sm"
onClick={openBillingPortal}
disabled={billingLoading}
>
{billingLoading ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<CreditCard className="w-4 h-4 mr-2" />
{license?.status === 'trial' && license.trialDaysRemaining !== null ? (
<SettingsField
label="Trial countdown"
helper="Activate before the trial ends to keep paid features."
tone="warn"
>
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-warning" />
<span className="font-mono tabular-nums text-sm text-stat-value">
{license.trialDaysRemaining} day{license.trialDaysRemaining !== 1 ? 's' : ''}
</span>
</div>
</SettingsField>
) : null}
{license?.status === 'active' ? (
<SettingsActions align="between" hint="Lemon Squeezy manages billing">
<div className="flex items-center gap-2">
{!license.isLifetime && (
<Button
variant="outline"
size="sm"
onClick={openBillingPortal}
disabled={billingLoading}
>
{billingLoading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<CreditCard className="w-4 h-4" />
)}
Manage subscription
<ExternalLink className="w-3 h-3 opacity-50" />
</Button>
)}
Manage Subscription
<ExternalLink className="w-3 h-3 ml-1.5 opacity-50" />
</Button>
)}
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Deactivating will revert to Community features.
</p>
<Button
variant="outline"
size="sm"
onClick={async () => {
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
? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Deactivating...</>
: 'Deactivate License'
}
</Button>
</div>
</div>
)}
<Button
variant="outline"
size="sm"
onClick={async () => {
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 ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
Deactivating
</>
) : (
'Deactivate'
)}
</Button>
</div>
</SettingsActions>
) : null}
</SettingsSection>
{showTrialCta && (
<div className="border border-glass-border rounded-lg p-4 space-y-3 bg-glass">
<div className="flex items-center gap-2">
<ShipWheel className="w-4 h-4 text-blue-500" />
<span className="font-medium text-sm">Try Admiral free for 14 days</span>
</div>
<p className="text-xs text-muted-foreground">
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.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<Button
size="sm"
onClick={() => window.open(ADMIRAL_MONTHLY_CHECKOUT_URL, '_blank')}
>
Start monthly trial
<ExternalLink className="w-3 h-3 ml-1.5 opacity-50" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => window.open(ADMIRAL_ANNUAL_CHECKOUT_URL, '_blank')}
>
Start annual trial
<ExternalLink className="w-3 h-3 ml-1.5 opacity-50" />
</Button>
</div>
<p className="text-[11px] text-muted-foreground">
After checkout, paste the license key from your email into the activate field below.
</p>
</div>
)}
{showUpgradeCards && (
<div className="space-y-3">
<Label className="text-base">Upgrade your plan</Label>
<div className={`grid gap-3 ${showSkipperCard && showAdmiralUpgradeCard ? 'grid-cols-1 sm:grid-cols-2' : 'grid-cols-1'}`}>
{showSkipperCard && (
<div className="relative border border-glass-border rounded-lg p-4 space-y-3 bg-glass flex flex-col">
<div className="flex items-center gap-2">
<Compass className="w-4 h-4 text-amber-500" />
<span className="font-medium text-sm">Skipper</span>
<Badge variant="secondary" className="text-[10px] font-medium uppercase px-1.5 py-0">Popular</Badge>
</div>
<p className="text-xs text-muted-foreground">Professional tools for solo operators.</p>
<ul className="space-y-1.5">
{['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) => (
<li key={f} className="flex items-center gap-2 text-xs text-muted-foreground">
<Check className="w-3 h-3 shrink-0 text-success" />
{f}
</li>
))}
</ul>
<Button
size="sm"
className="w-full mt-auto"
onClick={() => window.open(SKIPPER_CHECKOUT_URL, '_blank')}
>
<Zap className="w-4 h-4 mr-2" />
Get Skipper
<ExternalLink className="w-3 h-3 ml-1.5 opacity-50" />
</Button>
</div>
)}
{showAdmiralUpgradeCard && (
<div className="border border-glass-border rounded-lg p-4 space-y-3 bg-glass flex flex-col">
<div className="flex items-center gap-2">
<ShipWheel className="w-4 h-4 text-blue-500" />
<span className="font-medium text-sm">Admiral</span>
</div>
<p className="text-xs text-muted-foreground">For teams managing shared infrastructure.</p>
<ul className="space-y-1.5">
{[
...(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) => (
<li key={f} className="flex items-center gap-2 text-xs text-muted-foreground">
<Check className="w-3 h-3 shrink-0 text-success" />
{f}
</li>
))}
</ul>
<Button
size="sm"
variant={showSkipperCard ? 'outline' : 'default'}
className="w-full mt-auto"
onClick={() => window.open(ADMIRAL_MONTHLY_CHECKOUT_URL, '_blank')}
>
<Zap className="w-4 h-4 mr-2" />
Get Admiral
<ExternalLink className="w-3 h-3 ml-1.5 opacity-50" />
</Button>
</div>
)}
</div>
</div>
)}
{/* License key activation */}
{license?.status !== 'active' && (
<div className="border-t border-glass-border pt-4 space-y-2">
<Label className="text-sm text-muted-foreground">Have a license key?</Label>
<div className="flex gap-2">
<Input
placeholder="XXXXX-XXXXX-XXXXX-XXXXX"
value={licenseKeyInput}
onChange={(e) => setLicenseKeyInput(e.target.value)}
className="font-mono"
{showTrialCta ? (
<SettingsSection title="Try Admiral free">
<div className="pt-3 flex flex-col gap-3">
<SettingsCallout
tone="brand"
icon={<ShipWheel className="h-4 w-4" strokeWidth={1.5} />}
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."
/>
<Button
variant="outline"
onClick={async () => {
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
? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Activating...</>
: 'Activate'
}
</Button>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<SettingsPrimaryButton
size="sm"
onClick={() => window.open(ADMIRAL_MONTHLY_CHECKOUT_URL, '_blank')}
>
Start monthly trial
<ExternalLink className="w-3 h-3 opacity-60" />
</SettingsPrimaryButton>
<Button
size="sm"
variant="outline"
onClick={() => window.open(ADMIRAL_ANNUAL_CHECKOUT_URL, '_blank')}
>
Start annual trial
<ExternalLink className="w-3 h-3 opacity-60" />
</Button>
</div>
<p className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle/70">
paste the license key from your email into the activate field below
</p>
</div>
</div>
)}
</SettingsSection>
) : null}
{showUpgradeCards ? (
<SettingsSection title="Upgrade">
<div className={`pt-3 grid gap-3 ${showSkipperCard && showAdmiralUpgradeCard ? 'grid-cols-1 sm:grid-cols-2' : 'grid-cols-1'}`}>
{showSkipperCard ? (
<UpgradeCard
tone="warn"
icon={<Compass className="h-4 w-4" />}
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={
<SettingsPrimaryButton
size="sm"
className="w-full"
onClick={() => window.open(SKIPPER_CHECKOUT_URL, '_blank')}
>
<Zap className="w-4 h-4" />
Get Skipper
<ExternalLink className="w-3 h-3 opacity-60" />
</SettingsPrimaryButton>
}
/>
) : null}
{showAdmiralUpgradeCard ? (
<UpgradeCard
tone="brand"
icon={<ShipWheel className="h-4 w-4" />}
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 ? (
<Button
size="sm"
variant="outline"
className="w-full"
onClick={() => window.open(ADMIRAL_MONTHLY_CHECKOUT_URL, '_blank')}
>
<Zap className="w-4 h-4" />
Get Admiral
<ExternalLink className="w-3 h-3 opacity-60" />
</Button>
) : (
<SettingsPrimaryButton
size="sm"
className="w-full"
onClick={() => window.open(ADMIRAL_MONTHLY_CHECKOUT_URL, '_blank')}
>
<Zap className="w-4 h-4" />
Get Admiral
<ExternalLink className="w-3 h-3 opacity-60" />
</SettingsPrimaryButton>
)
}
/>
) : null}
</div>
</SettingsSection>
) : null}
{license?.status !== 'active' ? (
<SettingsSection title="Activate">
<SettingsField
label="License key"
helper="Paste the key from your activation email."
htmlFor="license-key"
>
<div className="flex gap-2">
<Input
id="license-key"
placeholder="XXXXX-XXXXX-XXXXX-XXXXX"
value={licenseKeyInput}
onChange={(e) => setLicenseKeyInput(e.target.value)}
className="font-mono"
/>
<SettingsPrimaryButton
onClick={async () => {
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 ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
Activating
</>
) : (
'Activate'
)}
</SettingsPrimaryButton>
</div>
</SettingsField>
</SettingsSection>
) : null}
</div>
);
}
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 (
<div className="border border-card-border rounded-md bg-card p-4 flex flex-col gap-3">
<div className="flex items-center gap-2">
<span className={iconClass}>{icon}</span>
<span className="font-display italic text-base text-stat-value">{title}</span>
</div>
<p className="text-xs text-stat-subtitle">{blurb}</p>
<ul className="space-y-1.5 flex-1">
{features.map((f) => (
<li key={f} className="flex items-start gap-2 text-xs text-stat-subtitle">
<Check className="h-3 w-3 shrink-0 text-brand mt-0.5" />
{f}
</li>
))}
</ul>
<div className="mt-auto">{action}</div>
</div>
);
}
@@ -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<MastheadStatsContextValue | null>(null);
export function MastheadStatsProvider({ children }: { children: ReactNode }) {
const [extras, setExtras] = useState<MastheadMetadataItem[] | null>(null);
const value = useMemo<MastheadStatsContextValue>(() => ({ extras, setExtras }), [extras]);
return <MastheadStatsContext.Provider value={value}>{children}</MastheadStatsContext.Provider>;
}
// 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<string>('');
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]);
}
@@ -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<ComboboxOption[]>(
() => labelOptions.filter(l => !formLabelIds.includes(l.id)).map(l => ({ value: String(l.id), label: l.name })),
@@ -295,9 +312,9 @@ export function NotificationRoutingSection() {
<CapabilityGate capability="notification-routing" featureName="Notification Routing">
<div className="space-y-6">
<div className="flex justify-end">
<Button size="sm" onClick={() => { resetForm(); setShowForm(true); }}>
<Plus className="w-4 h-4 mr-1.5" /> Add Route
</Button>
<SettingsPrimaryButton size="sm" onClick={() => { resetForm(); setShowForm(true); }}>
<Plus className="w-4 h-4" /> Add route
</SettingsPrimaryButton>
</div>
<Dialog open={showForm} onOpenChange={(open) => { if (!open) resetForm(); }}>
@@ -469,9 +486,9 @@ export function NotificationRoutingSection() {
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" size="sm" onClick={resetForm}>Cancel</Button>
<Button size="sm" onClick={handleSave} disabled={saving}>
{saving ? <><RefreshCw className="w-4 h-4 mr-1.5 animate-spin" />Saving...</> : editingId ? 'Update' : 'Create'}
</Button>
<SettingsPrimaryButton size="sm" onClick={handleSave} disabled={saving}>
{saving ? <><RefreshCw className="w-4 h-4 animate-spin" />Saving</> : editingId ? 'Update' : 'Create'}
</SettingsPrimaryButton>
</div>
</div>
</DialogContent>
@@ -485,13 +502,11 @@ export function NotificationRoutingSection() {
)}
{!loading && routes.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Route className="w-10 h-10 text-muted-foreground/50 mb-3" strokeWidth={1.5} />
<p className="text-sm text-muted-foreground">No routing rules configured.</p>
<p className="text-xs text-muted-foreground mt-1">
Alerts will use your global notification channels. Add a route to direct specific stack alerts to dedicated channels.
</p>
</div>
<SettingsCallout
icon={<Route className="h-4 w-4" strokeWidth={1.5} />}
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 => (
@@ -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) => (
<div className="space-y-4 py-4">
<div className="flex items-center justify-between">
<Label htmlFor={`${type}-enabled`} className="font-medium">Enable {title}</Label>
<SettingsSection title={title} kicker={agents[type].enabled ? 'enabled' : 'off'}>
<SettingsField
label="Enabled"
helper={`Send Sencho events to this ${title.toLowerCase()} channel.`}
>
<TogglePill
id={`${type}-enabled`}
checked={agents[type].enabled}
onChange={(c) => handleAgentChange(type, 'enabled', c)}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${type}-url`}>Webhook URL</Label>
</SettingsField>
<SettingsField
label="Webhook URL"
helper="Sencho posts JSON payloads here. Use a private channel."
htmlFor={`${type}-url`}
>
<Input
id={`${type}-url`}
placeholder="https://..."
value={agents[type].url}
onChange={(e) => handleAgentChange(type, 'url', e.target.value)}
/>
</div>
<div className="flex space-x-2 justify-end pt-4">
</SettingsField>
<SettingsActions>
<Button variant="outline" onClick={() => testAgent(type)} disabled={isTestingAgent[type]}>
{isTestingAgent[type] ? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Testing...</> : 'Test'}
{isTestingAgent[type] ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
Testing
</>
) : (
'Test'
)}
</Button>
<Button onClick={() => saveAgent(type)} disabled={isSavingAgent[type]}>
{isSavingAgent[type] ? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Saving...</> : 'Save'}
</Button>
</div>
</div>
<SettingsPrimaryButton onClick={() => saveAgent(type)} disabled={isSavingAgent[type]}>
{isSavingAgent[type] ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
Saving
</>
) : (
'Save'
)}
</SettingsPrimaryButton>
</SettingsActions>
</SettingsSection>
);
return (
<div className="space-y-6">
<div className="flex flex-col gap-6">
<Tabs value={notifTab} onValueChange={(v) => setNotifTab(v as 'discord' | 'slack' | 'webhook')} className="w-full">
<TabsList className="w-full mb-4 grid grid-cols-3">
<TabsHighlight className="rounded-md bg-glass-highlight" transition={springs.snappy}>
@@ -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 <Navigate to={`/settings/${fallback?.id ?? 'appearance'}`} replace />;
}
// 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 <TierLockedCard tier={item.tier} />;
@@ -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 (
<div className="space-y-6">
@@ -277,10 +293,10 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
<div className="space-y-6">
{!isRemote && !isReplica && (
<div className="flex justify-end">
<Button size="sm" onClick={openCreate}>
<Plus className="w-4 h-4 mr-1.5" />
Add Policy
</Button>
<SettingsPrimaryButton size="sm" onClick={openCreate}>
<Plus className="w-4 h-4" />
Add policy
</SettingsPrimaryButton>
</div>
)}
@@ -317,14 +333,14 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
{isAdmiral && (
<div className="flex items-center gap-2 shrink-0">
{trivy.source === 'none' && (
<Button size="sm" onClick={handleInstallTrivy} disabled={trivyBusy !== null}>
<SettingsPrimaryButton size="sm" onClick={handleInstallTrivy} disabled={trivyBusy !== null}>
{trivyBusy === 'install' ? (
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />
) : (
<Download className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
)}
Install Trivy
</Button>
</SettingsPrimaryButton>
)}
{trivy.source === 'managed' && updateCheck?.updateAvailable && (
<Button size="sm" variant="outline" onClick={handleUpdateTrivy} disabled={trivyBusy !== null}>
@@ -399,13 +415,11 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
)}
{!isRemote && !loading && policies.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<ShieldCheck className="w-10 h-10 text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground">No scan policies configured.</p>
<p className="text-xs text-muted-foreground mt-1">
Add one to enforce severity thresholds across your fleet.
</p>
</div>
<SettingsCallout
icon={<ShieldCheck className="h-4 w-4" />}
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 }) {
<Button variant="outline" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving}>
<SettingsPrimaryButton onClick={handleSave} disabled={saving}>
{saving ? 'Saving...' : editingId ? 'Update' : 'Create'}
</Button>
</SettingsPrimaryButton>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -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 (
<div
className={cn(
'flex flex-wrap items-center gap-[var(--density-cell-y,0.5rem)] pt-[var(--density-row-y,0.75rem)]',
align === 'between' ? 'justify-between' : 'justify-end',
className,
)}
>
{hint ? (
<span className="font-mono text-[10px] uppercase tracking-[0.2em] text-stat-subtitle">
{hint}
</span>
) : null}
<div className="flex items-center gap-2">{children}</div>
</div>
);
}
export const SettingsPrimaryButton = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, size, ...props }, ref) => (
<Button
ref={ref}
size={size ?? 'sm'}
{...props}
className={cn(
'bg-brand text-brand-foreground shadow-btn-glow hover:bg-brand/90',
'font-mono uppercase tracking-[0.16em] text-xs',
className,
)}
/>
),
);
SettingsPrimaryButton.displayName = 'SettingsPrimaryButton';
export const SettingsSecondaryButton = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => (
<Button
ref={ref}
variant={variant ?? 'outline'}
size={size ?? 'sm'}
{...props}
className={cn(
'font-mono uppercase tracking-[0.16em] text-xs',
className,
)}
/>
),
);
SettingsSecondaryButton.displayName = 'SettingsSecondaryButton';
@@ -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<SettingsCalloutTone, { border: string; bg: string; iconBg: string; iconText: string }> = {
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 (
<div
className={cn(
'flex items-center gap-4 rounded-md border px-4 py-3',
styles.border,
styles.bg,
className,
)}
>
{icon ? (
<div
className={cn(
'flex h-9 w-9 shrink-0 items-center justify-center rounded-md',
styles.iconBg,
styles.iconText,
)}
>
{icon}
</div>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-value">
{title}
</div>
{subtitle ? (
<div className="text-sm leading-relaxed text-stat-subtitle">
{subtitle}
</div>
) : null}
</div>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
);
}
@@ -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<SettingsFieldTone, string> = {
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 <SettingsSection>.
*/
export function SettingsField({
label,
helper,
tone = 'default',
htmlFor,
children,
align = 'center',
className,
}: SettingsFieldProps) {
return (
<div
className={cn(
'grid grid-cols-1 gap-[var(--density-cell-y,0.5rem)] py-[var(--density-row-y,0.75rem)] md:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)] md:gap-[var(--density-gap,1rem)]',
align === 'center' ? 'md:items-center' : 'md:items-start',
className,
)}
>
<div className="flex flex-col gap-1 min-w-0">
<label
htmlFor={htmlFor}
className="text-sm font-medium text-stat-value leading-snug"
>
{label}
</label>
{helper ? (
<p
className={cn(
'text-sm leading-relaxed',
helperToneClass[tone],
)}
>
{helper}
</p>
) : null}
</div>
<div className="min-w-0">{children}</div>
</div>
);
}
+107 -79
View File
@@ -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 (
<MastheadStatsProvider>
<SettingsPageInner {...props} />
</MastheadStatsProvider>
);
}
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<HTMLDivElement | null>(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 <AccountSection />;
case 'appearance': return <AppearanceSection />;
case 'license': return <LicenseSection />;
@@ -135,61 +157,67 @@ export function SettingsPage() {
case 'app-store': return <AppStoreSection />;
case 'support': return <SupportSection />;
case 'about': return <AboutSection />;
default: return <Navigate to="/settings/appearance" replace />;
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<MastheadMetadataItem[]>(() => {
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 (
<div
className="flex flex-1 overflow-hidden min-h-0"
className="h-full overflow-auto p-6 flex flex-col gap-4 min-w-0"
onKeyDown={handleKeyDown}
>
<SettingsSidebar
dirtyFlags={dirtyFlags}
onOpenPalette={() => setCommandOpen(true)}
<PageMasthead
kicker={kicker}
state={activeItem?.label ?? 'Settings'}
tone="live"
pulsing={false}
metadata={metadata}
className="rounded-lg"
/>
<div className="flex-1 flex flex-col min-h-0 min-w-0">
<header className="flex items-start justify-between gap-4 px-6 pt-5 pb-4 border-b border-border/60 shrink-0">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
<span>Settings</span>
<span className="text-stat-subtitle/50"></span>
<span>{activeGroup?.label ?? ''}</span>
<span className="text-stat-subtitle/50"></span>
<span className="text-stat-value">{activeItem?.label ?? ''}</span>
{activeItem?.scope === 'node' ? (
<span className="ml-2 flex items-center gap-1 text-brand">
<span className="text-stat-subtitle/50">·</span>
<span className="truncate max-w-[160px]">{activeNode?.name ?? 'local'}</span>
<span className="text-stat-subtitle/70">(node-scoped)</span>
</span>
) : null}
</div>
<h2 className="mt-1.5 font-display italic text-2xl leading-tight text-stat-value truncate">
{activeItem?.label ?? 'Settings'}
</h2>
{activeItem?.description ? (
<p className="mt-1 text-sm text-stat-subtitle/90 truncate">
{activeItem.description}
</p>
) : null}
</div>
</header>
<div className="flex flex-1 min-h-0 gap-4">
<SettingsSidebar
dirtyFlags={dirtyFlags}
currentSection={safeSection}
onSectionChange={onSectionChange}
onOpenPalette={() => setCommandOpen(true)}
/>
<ScrollArea
block
viewportRef={contentViewportRef}
className="flex-1 min-w-0"
onScrollCapture={saveScrollPosition}
>
<div className="px-6 py-5 flex flex-col gap-6 min-w-0">
<SectionGate sectionId={currentSection}>
{sectionElement}
</SectionGate>
</div>
</ScrollArea>
<div className="flex-1 min-h-0 min-w-0 rounded-lg border border-card-border border-t-card-border-top bg-card text-card-foreground shadow-card-bevel transition-colors overflow-hidden flex flex-col">
<ScrollArea
block
viewportRef={contentViewportRef}
className="flex-1 min-w-0"
onScrollCapture={saveScrollPosition}
>
<div className="px-7 pt-6 pb-8 flex flex-col gap-6 min-w-0">
{activeItem?.description ? (
<p className="text-sm text-stat-subtitle/90 leading-relaxed max-w-3xl">
{activeItem.description}
</p>
) : null}
<SectionGate sectionId={safeSection}>
{sectionElement}
</SectionGate>
</div>
</ScrollArea>
</div>
</div>
<CommandDialog open={commandOpen} onOpenChange={setCommandOpen}>
@@ -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 (
<CommandItem value={searchValue} onSelect={onSelect}>
<span className="font-mono text-[11px] w-3 text-center text-stat-subtitle/70">{glyph}</span>
<span className="font-mono text-[10px] w-3 text-center text-stat-subtitle/70">{glyph}</span>
<div className={cn('flex flex-col gap-0.5 min-w-0 flex-1', locked && 'opacity-60')}>
<span className="text-sm font-medium text-stat-value truncate">{item.label}</span>
<span className="text-xs text-stat-subtitle truncate">{item.description}</span>
</div>
{item.tier && locked ? (
<span className="flex items-center gap-1 rounded-sm border border-card-border bg-card px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-[0.18em] text-stat-subtitle/80">
<Lock className="h-2.5 w-2.5" strokeWidth={1.5} />
{item.tier === 'admiral' ? 'ADMIRAL' : 'SKIPPER'}
</span>
) : null}
{item.tier && locked ? <TierLockChip tier={item.tier} /> : null}
</CommandItem>
);
}
@@ -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 (
<section className={cn('flex flex-col', className)}>
<header className="flex items-baseline justify-between gap-3 pb-[var(--density-cell-y,0.5rem)]">
<h3 className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle">
{title}
</h3>
{kicker ? (
<span className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle/70">
{kicker}
</span>
) : null}
</header>
<div className="border-t border-border/60" />
{description ? (
<p className="pt-3 text-sm leading-relaxed text-stat-subtitle">
{description}
</p>
) : null}
<div className="flex flex-col divide-y divide-border/40">{children}</div>
</section>
);
}
@@ -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 (
<span
className={cn(
'ml-auto shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
tier === 'admiral'
? 'bg-warning/15 text-warning'
: 'bg-brand/15 text-brand',
)}
>
{tier === 'admiral' ? 'Admiral' : 'Skipper'}
</span>
);
}
interface SettingsSidebarProps {
currentSection: SectionId;
onSectionChange: (section: SectionId) => void;
dirtyFlags?: Partial<Record<SectionId, boolean>>;
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 (
<aside className="w-[220px] bg-glass border-r border-glass-border flex flex-col shrink-0 min-h-0">
<div className="flex items-center gap-2 px-4 pt-5 pb-3">
<button
onClick={handleBack}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-stat-subtitle transition-colors hover:bg-accent/40 hover:text-stat-value"
aria-label="Go back"
>
<ChevronLeft className="h-3.5 w-3.5" strokeWidth={2} />
</button>
<div className="min-w-0 flex-1">
<p className="font-display italic text-xl leading-tight text-stat-value">Settings</p>
<p className="truncate text-[11px] text-stat-subtitle">{nodeName}</p>
</div>
</div>
<div className="px-3 pb-2">
<aside className="w-[240px] rounded-lg border border-card-border border-t-card-border-top bg-card text-card-foreground shadow-card-bevel transition-colors flex flex-col shrink-0 min-h-0 overflow-hidden">
<div className="px-3 pt-5 pb-2">
<button
onClick={onOpenPalette}
className="flex w-full items-center gap-2 rounded-md border border-glass-border bg-glass px-3 py-1.5 text-xs text-stat-subtitle transition-colors hover:border-brand/30 hover:text-stat-value"
className="flex w-full items-center gap-2 rounded-md border border-glass-border bg-glass px-2.5 py-1.5 text-xs text-stat-subtitle transition-colors hover:border-brand/30 hover:text-stat-value"
>
<Search className="h-3 w-3 shrink-0" />
<span className="flex-1 text-left">Filter settings</span>
<span className="flex-1 text-left">Filter</span>
<kbd className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle/70 border border-card-border rounded px-1 py-px">
K
</kbd>
</button>
</div>
<ScrollArea className="flex-1 px-3">
<nav className="pb-4">
{SETTINGS_GROUPS.map(group => {
const items = SETTINGS_ITEMS.filter(
const groupItems = SETTINGS_ITEMS.filter(
item => item.group === group.id && isVisible(item),
);
if (items.length === 0) return null;
if (groupItems.length === 0) return null;
const unlockedCount = groupItems.filter(
item => !isItemLocked(item, visibility),
).length;
return (
<div key={group.id} className="mb-1 mt-3">
<p className="mb-1 px-2 text-[10px] font-semibold uppercase tracking-widest text-stat-subtitle/60">
{group.label}
</p>
{items.map(item => {
<div className="mb-1 flex items-center justify-between gap-2 px-2">
<span className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle/70">
{group.label}
</span>
<span className="font-mono text-[10px] leading-3 tabular-nums text-stat-subtitle/50">
{unlockedCount}/{groupItems.length}
</span>
</div>
{groupItems.map(item => {
const locked = isItemLocked(item, visibility);
const isDirty = dirtyFlags?.[item.id] ?? false;
const isActive = item.id === currentSection;
return (
<NavLink
<button
key={item.id}
to={`/settings/${item.id}`}
className={({ isActive }) =>
cn(
'relative flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-xs transition-colors',
isActive
? 'bg-gradient-to-r from-brand/10 to-transparent text-stat-value'
: 'text-stat-subtitle hover:bg-accent/40 hover:text-stat-value',
)
}
>
{({ isActive }) => (
<>
{isActive && (
<span className="absolute inset-y-1 left-0 w-[2px] rounded-full bg-brand" />
)}
<span className="flex-1 truncate">{item.label}</span>
{isDirty && (
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-warning" />
)}
{locked && <Lock className="h-3 w-3 shrink-0 text-stat-subtitle/60" />}
{item.tier && !locked && (
<TierChip tier={item.tier} />
)}
</>
type="button"
onClick={() => onSectionChange(item.id)}
aria-current={isActive ? 'page' : undefined}
className={cn(
'relative flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-xs transition-colors',
isActive
? 'text-stat-value'
: 'text-stat-subtitle hover:bg-accent/40 hover:text-stat-value',
locked && 'opacity-60',
)}
</NavLink>
>
{isActive && (
<span
aria-hidden="true"
className="absolute inset-y-1 left-0 w-[3px] rounded-full bg-brand shadow-[0_0_8px_color-mix(in_oklch,var(--brand)_30%,transparent)]"
/>
)}
<span
aria-hidden="true"
className={cn(
'h-1 w-1 shrink-0 rounded-full',
isActive ? 'bg-brand' : 'bg-stat-subtitle/40',
)}
/>
<span className="flex-1 truncate text-left">{item.label}</span>
{isDirty && (
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-warning" />
)}
{item.tier && locked && <TierLockChip tier={item.tier} showIcon={false} />}
</button>
);
})}
</div>
@@ -1,86 +1,95 @@
import { Button } from '@/components/ui/button';
import { useLicense } from '@/context/LicenseContext';
import { TierBadge } from '@/components/TierBadge';
import { Book, Bug, Mail, ExternalLink, Crown } from 'lucide-react';
import { SettingsSection } from './SettingsSection';
import { SettingsCallout } from './SettingsCallout';
import { SettingsPrimaryButton } from './SettingsActions';
interface ResourceLinkProps {
icon: React.ReactNode;
title: string;
blurb: string;
href: string;
external?: boolean;
}
function ResourceLink({ icon, title, blurb, href, external = true }: ResourceLinkProps) {
return (
<a
href={href}
target={external ? '_blank' : undefined}
rel={external ? 'noopener noreferrer' : undefined}
className="flex items-center gap-3 p-3 rounded-md border border-card-border bg-card hover:border-brand/30 transition-colors"
>
<div className="w-9 h-9 rounded-md bg-glass flex items-center justify-center shrink-0 text-stat-subtitle">
{icon}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-stat-value">{title}</p>
<p className="text-xs text-stat-subtitle">{blurb}</p>
</div>
<ExternalLink className="w-4 h-4 text-stat-subtitle shrink-0" />
</a>
);
}
export function SupportSection() {
const { isPaid, license } = useLicense();
return (
<div className="space-y-6">
{/* Self-serve channels (all tiers) */}
<div className="space-y-3">
<h4 className="text-sm font-medium text-muted-foreground">Resources</h4>
<div className="grid gap-3">
<a href="https://docs.sencho.io" target="_blank" rel="noopener noreferrer"
className="flex items-center gap-3 p-3 rounded-lg border border-glass-border hover:bg-muted/50 transition-colors">
<div className="w-9 h-9 rounded-lg bg-muted flex items-center justify-center shrink-0">
<Book className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">Documentation</p>
<p className="text-xs text-muted-foreground">Guides, reference, and tutorials</p>
</div>
<ExternalLink className="w-4 h-4 text-muted-foreground shrink-0" />
</a>
<a href="https://github.com/studio-saelix/sencho/issues" target="_blank" rel="noopener noreferrer"
className="flex items-center gap-3 p-3 rounded-lg border border-glass-border hover:bg-muted/50 transition-colors">
<div className="w-9 h-9 rounded-lg bg-muted flex items-center justify-center shrink-0">
<Bug className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">GitHub Issues</p>
<p className="text-xs text-muted-foreground">Report bugs and request features</p>
</div>
<ExternalLink className="w-4 h-4 text-muted-foreground shrink-0" />
</a>
<div className="flex flex-col gap-10">
<SettingsSection title="Self-serve">
<div className="pt-3 grid gap-3">
<ResourceLink
icon={<Book className="w-4 h-4" />}
title="Documentation"
blurb="Guides, reference, and tutorials"
href="https://docs.sencho.io"
/>
<ResourceLink
icon={<Bug className="w-4 h-4" />}
title="GitHub Issues"
blurb="Report bugs and request features"
href="https://github.com/studio-saelix/sencho/issues"
/>
</div>
</div>
</SettingsSection>
{/* Paid tier support channels */}
{isPaid && (
<div className="space-y-3">
<h4 className="text-sm font-medium text-muted-foreground flex items-center gap-2">
Priority Support <TierBadge />
</h4>
<div className="grid gap-3">
<a href={license?.variant === 'admiral' ? 'mailto:support@sencho.io' : 'mailto:licensing@sencho.io'}
className="flex items-center gap-3 p-3 rounded-lg border border-glass-border hover:bg-muted/50 transition-colors">
<div className="w-9 h-9 rounded-lg bg-muted flex items-center justify-center shrink-0">
<Mail className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">
{license?.variant === 'admiral' ? 'Priority Email Support' : 'Email Support'}
</p>
<p className="text-xs text-muted-foreground">
{license?.variant === 'admiral'
? 'Direct support with responses within 24 hours'
: 'Reach our support team directly'}
</p>
</div>
<ExternalLink className="w-4 h-4 text-muted-foreground shrink-0" />
</a>
<SettingsSection
title="Priority support"
kicker={<TierBadge />}
>
<div className="pt-3 grid gap-3">
<ResourceLink
icon={<Mail className="w-4 h-4" />}
title={license?.variant === 'admiral' ? 'Priority email support' : 'Email support'}
blurb={
license?.variant === 'admiral'
? 'Direct support with responses within 24 hours'
: 'Reach our support team directly'
}
href={license?.variant === 'admiral' ? 'mailto:support@sencho.io' : 'mailto:licensing@sencho.io'}
external={false}
/>
</div>
</div>
</SettingsSection>
)}
{/* Upsell for Community */}
{!isPaid && (
<div className="rounded-lg border border-glass-border p-4 bg-muted/30">
<div className="flex items-start gap-3">
<Crown className="w-5 h-5 text-muted-foreground mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Need faster support?</p>
<p className="text-xs text-muted-foreground mt-1">
Upgrade to Skipper or Admiral for direct email support and priority issue handling.
</p>
<Button size="sm" className="mt-3" onClick={() => window.open('https://sencho.io/#pricing', '_blank')}>
View Plans
</Button>
</div>
</div>
</div>
<SettingsCallout
icon={<Crown className="h-4 w-4" />}
title="Need faster support?"
subtitle="Skipper and Admiral tiers include direct email support and priority issue handling."
action={
<SettingsPrimaryButton
size="sm"
onClick={() => window.open('https://sencho.io/#pricing', '_blank')}
>
View plans
</SettingsPrimaryButton>
}
/>
)}
</div>
);
+108 -110
View File
@@ -1,5 +1,4 @@
import { useState, useRef, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { useState, useRef, useEffect, useMemo } from 'react';
import { Skeleton } from '@/components/ui/skeleton';
import { RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -8,6 +7,10 @@ import { toast } from '@/components/ui/toast-store';
import { useNodes } from '@/context/NodeContext';
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 SystemSectionProps {
onDirtyChange?: (dirty: boolean) => void;
@@ -118,30 +121,6 @@ function TogglePill({ checked, onChange }: TogglePillProps) {
);
}
interface RowProps {
label: string;
desc: string;
control: React.ReactNode;
last?: boolean;
}
function Row({ label, desc, control, last }: RowProps) {
return (
<div
className={cn(
'flex items-center gap-4 px-4 py-3',
!last && 'border-b border-glass-border',
)}
>
<div className="min-w-0 flex-1">
<div className="text-sm text-stat-value">{label}</div>
<div className="mt-0.5 text-xs text-stat-subtitle">{desc}</div>
</div>
<div className="shrink-0">{control}</div>
</div>
);
}
function SettingsSkeleton() {
return (
<div className="space-y-3 rounded-lg border border-glass-border bg-glass p-4">
@@ -170,17 +149,35 @@ export function SystemSection({ onDirtyChange }: SystemSectionProps) {
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const hasChanges =
settings.host_cpu_limit !== serverSettingsRef.current.host_cpu_limit ||
settings.host_ram_limit !== serverSettingsRef.current.host_ram_limit ||
settings.host_disk_limit !== serverSettingsRef.current.host_disk_limit ||
settings.docker_janitor_gb !== serverSettingsRef.current.docker_janitor_gb ||
settings.global_crash !== serverSettingsRef.current.global_crash;
const dirtyCount = useMemo(() => {
const baseline = serverSettingsRef.current;
let n = 0;
if (settings.host_cpu_limit !== baseline.host_cpu_limit) n++;
if (settings.host_ram_limit !== baseline.host_ram_limit) n++;
if (settings.host_disk_limit !== baseline.host_disk_limit) n++;
if (settings.docker_janitor_gb !== baseline.docker_janitor_gb) n++;
if (settings.global_crash !== baseline.global_crash) n++;
return n;
}, [settings]);
const hasChanges = dirtyCount > 0;
useEffect(() => {
onDirtyChange?.(hasChanges);
}, [hasChanges, onDirtyChange]);
useMastheadStats(
isLoading
? null
: [
{
label: 'EDITED',
value: hasChanges ? `${dirtyCount} pending` : 'saved',
tone: hasChanges ? 'warn' : 'value',
},
],
);
useEffect(() => {
const fetchSettings = async () => {
setIsLoading(true);
@@ -234,85 +231,86 @@ export function SystemSection({ onDirtyChange }: SystemSectionProps) {
if (isLoading) return <SettingsSkeleton />;
return (
<div className="space-y-6">
<div className="overflow-hidden rounded-lg border border-glass-border bg-glass">
<Row
label="Host CPU limit"
desc="Alerts fire when 5-min avg exceeds"
control={
<NumberChip
value={settings.host_cpu_limit || '90'}
onChange={(v) => onSettingChange('host_cpu_limit', v)}
suffix="%"
min={1}
max={100}
warnOver={95}
/>
}
/>
<Row
label="Host RAM limit"
desc="Swap is never acceptable"
control={
<NumberChip
value={settings.host_ram_limit || '90'}
onChange={(v) => onSettingChange('host_ram_limit', v)}
suffix="%"
min={1}
max={100}
warnOver={95}
/>
}
/>
<Row
label="Host disk limit"
desc="Low free space slows image pulls and backups"
control={
<NumberChip
value={settings.host_disk_limit || '90'}
onChange={(v) => onSettingChange('host_disk_limit', v)}
suffix="%"
min={1}
max={100}
warnOver={95}
/>
}
/>
<Row
label="Janitor threshold"
desc="Alert when reclaimable Docker data exceeds this"
control={
<NumberChip
value={settings.docker_janitor_gb || '5'}
onChange={(v) => onSettingChange('docker_janitor_gb', v)}
suffix="GiB"
min={0}
step={0.5}
warnOver={10}
/>
}
/>
<Row
last
label="Global crash capture"
desc="Watch every managed container for unexpected exits"
control={
<TogglePill
checked={settings.global_crash === '1'}
onChange={(next) => onSettingChange('global_crash', next ? '1' : '0')}
/>
}
/>
</div>
<div className="flex flex-col gap-10">
<SettingsSection title="Host thresholds">
<SettingsField
label="CPU limit"
helper="Alerts fire when the 5-minute average exceeds this percentage."
>
<NumberChip
value={settings.host_cpu_limit || '90'}
onChange={(v) => onSettingChange('host_cpu_limit', v)}
suffix="%"
min={1}
max={100}
warnOver={95}
/>
</SettingsField>
<SettingsField
label="RAM limit"
helper="Swap is never acceptable. Set this below where the host begins paging."
>
<NumberChip
value={settings.host_ram_limit || '90'}
onChange={(v) => onSettingChange('host_ram_limit', v)}
suffix="%"
min={1}
max={100}
warnOver={95}
/>
</SettingsField>
<SettingsField
label="Disk limit"
helper="Low free space slows image pulls and backups."
>
<NumberChip
value={settings.host_disk_limit || '90'}
onChange={(v) => onSettingChange('host_disk_limit', v)}
suffix="%"
min={1}
max={100}
warnOver={95}
/>
</SettingsField>
</SettingsSection>
<div className="flex justify-end">
<Button onClick={saveSettings} disabled={isSaving}>
{isSaving
? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Saving...</>
: 'Save limits'
}
</Button>
</div>
<SettingsSection title="Docker hygiene">
<SettingsField
label="Janitor threshold"
helper="Alert when reclaimable Docker data exceeds this size."
>
<NumberChip
value={settings.docker_janitor_gb || '5'}
onChange={(v) => onSettingChange('docker_janitor_gb', v)}
suffix="GiB"
min={0}
step={0.5}
warnOver={10}
/>
</SettingsField>
<SettingsField
label="Global crash capture"
helper="Watch every managed container for unexpected exits."
>
<TogglePill
checked={settings.global_crash === '1'}
onChange={(next) => onSettingChange('global_crash', next ? '1' : '0')}
/>
</SettingsField>
</SettingsSection>
<SettingsActions hint={hasChanges ? `${dirtyCount} unsaved` : undefined}>
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges}>
{isSaving ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
Saving
</>
) : (
'Save limits'
)}
</SettingsPrimaryButton>
</SettingsActions>
</div>
);
}
@@ -0,0 +1,24 @@
import { Lock } from 'lucide-react';
import { cn } from '@/lib/utils';
export type TierLockTier = 'skipper' | 'admiral';
interface TierLockChipProps {
tier: TierLockTier;
showIcon?: boolean;
className?: string;
}
export function TierLockChip({ tier, showIcon = true, className }: TierLockChipProps) {
return (
<span
className={cn(
'inline-flex items-center gap-1 rounded-sm border border-card-border bg-card px-1.5 py-0.5 font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle/80',
className,
)}
>
{showIcon && <Lock className="h-2.5 w-2.5" strokeWidth={1.5} />}
{tier === 'admiral' ? 'Admiral' : 'Skipper'}
</span>
);
}
@@ -16,6 +16,9 @@ import { useLicense } from '@/context/LicenseContext';
import { PaidGate } from '@/components/PaidGate';
import { CapabilityGate } from '@/components/CapabilityGate';
import { RefreshCw, Trash2, Plus, Pencil, ShieldOff } from 'lucide-react';
import { SettingsCallout } from './SettingsCallout';
import { SettingsPrimaryButton } from './SettingsActions';
import { useMastheadStats } from './MastheadStatsContext';
interface UserItem {
id: number;
@@ -59,6 +62,14 @@ export function UsersSection() {
useEffect(() => { fetchUsers(); }, []);
useMastheadStats(
loading
? null
: [
{ label: 'OPERATORS', value: `${users.length}` },
],
);
const resetForm = () => {
setFormUsername('');
setFormPassword('');
@@ -253,9 +264,9 @@ export function UsersSection() {
<div className="space-y-6">
{!showForm && (
<div className="flex justify-end">
<Button size="sm" onClick={() => { resetForm(); setShowForm(true); }}>
<Plus className="w-4 h-4 mr-1" strokeWidth={1.5} />Add User
</Button>
<SettingsPrimaryButton size="sm" onClick={() => { resetForm(); setShowForm(true); }}>
<Plus className="w-4 h-4" strokeWidth={1.5} />Add user
</SettingsPrimaryButton>
</div>
)}
@@ -319,9 +330,9 @@ export function UsersSection() {
)}
<div className="flex gap-2 justify-end">
<Button variant="outline" size="sm" onClick={resetForm}>Cancel</Button>
<Button size="sm" onClick={handleSave} disabled={saving}>
{saving ? <><RefreshCw className="w-4 h-4 mr-1 animate-spin" strokeWidth={1.5} />Saving...</> : (editingUser ? 'Update User' : 'Create User')}
</Button>
<SettingsPrimaryButton size="sm" onClick={handleSave} disabled={saving}>
{saving ? <><RefreshCw className="w-4 h-4 animate-spin" strokeWidth={1.5} />Saving</> : (editingUser ? 'Update user' : 'Create user')}
</SettingsPrimaryButton>
</div>
{/* Scoped Permissions (Admiral, editing only) */}
@@ -406,7 +417,10 @@ export function UsersSection() {
<Skeleton className="h-12 w-full" />
</div>
) : users.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm">No users found.</div>
<SettingsCallout
title="No users yet"
subtitle="Add an operator to give someone else access to this control plane."
/>
) : (
<div className="border border-glass-border rounded-lg overflow-hidden">
<table className="w-full text-sm">
@@ -1,8 +1,6 @@
import { useState, useEffect } 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 { TogglePill } from '@/components/ui/toggle-pill';
import { Skeleton } from '@/components/ui/skeleton';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
@@ -15,6 +13,11 @@ import {
RefreshCw, CheckCircle, XCircle, Webhook, Copy, Trash2,
Plus, ChevronDown, ChevronRight, History,
} from 'lucide-react';
import { SettingsSection } from './SettingsSection';
import { SettingsField } from './SettingsField';
import { SettingsCallout } from './SettingsCallout';
import { SettingsActions, SettingsPrimaryButton } from './SettingsActions';
import { useMastheadStats } from './MastheadStatsContext';
interface WebhookItem {
id: number;
@@ -48,7 +51,6 @@ export function WebhooksSection({ isPaid }: { isPaid: boolean }) {
const [history, setHistory] = useState<Record<number, WebhookExecution[]>>({});
const [loadingHistory, setLoadingHistory] = useState<number | null>(null);
// Form state
const [formName, setFormName] = useState('');
const [formStack, setFormStack] = useState('');
const [formAction, setFormAction] = useState<string>('deploy');
@@ -70,6 +72,20 @@ export function WebhooksSection({ isPaid }: { isPaid: boolean }) {
useEffect(() => { fetchWebhooks(); fetchStacks(); }, []);
const enabledCount = webhooks.filter(w => w.enabled).length;
useMastheadStats(
loading
? null
: [
{ label: 'WEBHOOKS', value: `${webhooks.length}` },
{
label: 'ENABLED',
value: `${enabledCount}`,
tone: enabledCount > 0 ? 'value' : 'subtitle',
},
],
);
const handleCreate = async () => {
if (!formName || !formStack || !formAction) {
toast.error('All fields are required.');
@@ -154,33 +170,29 @@ export function WebhooksSection({ isPaid }: { isPaid: boolean }) {
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-10">
<div className="flex justify-end">
<Button size="sm" onClick={() => setShowForm(!showForm)}>
<Plus className="w-4 h-4 mr-1.5" /> Create Webhook
</Button>
<SettingsPrimaryButton size="sm" onClick={() => setShowForm(!showForm)}>
<Plus className="w-4 h-4" /> Create webhook
</SettingsPrimaryButton>
</div>
{/* Create Form */}
{showForm && (
<div className="space-y-4 bg-glass border border-glass-border p-4 rounded-lg">
<div className="space-y-2">
<Label>Name</Label>
<Input placeholder="Deploy on push" value={formName} onChange={e => setFormName(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Stack</Label>
<SettingsSection title="New webhook">
<SettingsField label="Name" helper="Shown in execution history and notifications." htmlFor="webhook-name">
<Input id="webhook-name" placeholder="Deploy on push" value={formName} onChange={e => setFormName(e.target.value)} />
</SettingsField>
<SettingsField label="Stack" helper="The webhook will operate on this stack." htmlFor="webhook-stack">
<Select value={formStack} onValueChange={setFormStack}>
<SelectTrigger><SelectValue placeholder="Select a stack..." /></SelectTrigger>
<SelectTrigger id="webhook-stack"><SelectValue placeholder="Select a stack..." /></SelectTrigger>
<SelectContent>
{stacks.map(s => <SelectItem key={s} value={s}>{s}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Action</Label>
</SettingsField>
<SettingsField label="Action" helper="What happens when the webhook is triggered." htmlFor="webhook-action">
<Select value={formAction} onValueChange={setFormAction}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectTrigger id="webhook-action"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="deploy">Deploy (down + up)</SelectItem>
<SelectItem value="restart">Restart</SelectItem>
@@ -190,34 +202,38 @@ export function WebhooksSection({ isPaid }: { isPaid: boolean }) {
<SelectItem value="git-pull">Git source sync</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-2 pt-2">
</SettingsField>
<SettingsActions>
<Button variant="outline" size="sm" onClick={() => setShowForm(false)}>Cancel</Button>
<Button size="sm" onClick={handleCreate} disabled={creating}>
{creating ? <><RefreshCw className="w-4 h-4 mr-1.5 animate-spin" />Creating...</> : 'Create'}
</Button>
</div>
</div>
<SettingsPrimaryButton size="sm" onClick={handleCreate} disabled={creating}>
{creating ? <><RefreshCw className="w-4 h-4 animate-spin" />Creating</> : 'Create'}
</SettingsPrimaryButton>
</SettingsActions>
</SettingsSection>
)}
{/* Secret reveal (shown once after creation) */}
{newSecret && (
<div className="bg-success-muted border border-success/30 rounded-lg p-4 space-y-3">
<div className="flex items-center gap-2 text-sm font-medium text-success">
<CheckCircle className="w-4 h-4" /> Webhook created - copy your secret now
</div>
<p className="text-xs text-muted-foreground">This secret will not be shown again. Store it securely.</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-xs font-mono bg-muted px-3 py-2 rounded-lg break-all">{newSecret.secret}</code>
<Button variant="outline" size="sm" onClick={() => handleCopy(newSecret.secret, 'Secret')}>
<Copy className="w-4 h-4" />
</Button>
</div>
<Button variant="outline" size="sm" onClick={() => setNewSecret(null)}>Dismiss</Button>
</div>
<SettingsCallout
tone="success"
icon={<CheckCircle className="h-4 w-4" />}
title="Webhook created. Copy your secret now."
subtitle={
<div className="flex flex-col gap-2 mt-1">
<span>This secret will not be shown again. Store it securely.</span>
<div className="flex items-center gap-2">
<code className="flex-1 text-xs font-mono bg-muted px-3 py-2 rounded-md break-all">{newSecret.secret}</code>
<Button variant="outline" size="sm" onClick={() => handleCopy(newSecret.secret, 'Secret')}>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
}
action={
<Button variant="outline" size="sm" onClick={() => setNewSecret(null)}>Dismiss</Button>
}
/>
)}
{/* Loading state */}
{loading && (
<div className="space-y-3">
<Skeleton className="h-20 w-full rounded-lg" />
@@ -225,98 +241,102 @@ export function WebhooksSection({ isPaid }: { isPaid: boolean }) {
</div>
)}
{/* Empty state */}
{!loading && webhooks.length === 0 && !showForm && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Webhook className="w-10 h-10 text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground">No webhooks configured yet.</p>
<p className="text-xs text-muted-foreground mt-1">Create one to trigger stack actions from CI/CD.</p>
</div>
<SettingsCallout
icon={<Webhook className="h-4 w-4" />}
title="No webhooks yet"
subtitle="Create one to trigger stack actions from CI/CD."
/>
)}
{/* Webhook list */}
{!loading && webhooks.map(wh => {
const triggerUrl = `${window.location.origin}/api/webhooks/${wh.id}/trigger`;
const isExpanded = expandedHistory === wh.id;
return (
<div key={wh.id} className="border border-glass-border rounded-lg overflow-hidden">
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<Webhook className="w-4 h-4 text-muted-foreground shrink-0" />
<span className="font-medium text-sm truncate">{wh.name}</span>
<Badge variant="outline" className="text-[10px] shrink-0">{wh.action}</Badge>
<Badge variant="secondary" className="text-[10px] shrink-0">{wh.stack_name}</Badge>
</div>
<div className="flex items-center gap-2 shrink-0">
<TogglePill checked={wh.enabled} onChange={(c) => handleToggle(wh.id!, c)} />
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => handleDelete(wh.id!)}>
<Trash2 className="w-4 h-4 text-muted-foreground" />
</Button>
</div>
</div>
{/* Trigger URL */}
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Trigger URL</Label>
<div className="flex items-center gap-2">
<code className="flex-1 text-[11px] font-mono bg-muted px-2.5 py-1.5 rounded-md truncate">{triggerUrl}</code>
<Button variant="outline" size="sm" className="h-7 px-2" onClick={() => handleCopy(triggerUrl, 'URL')}>
<Copy className="w-3 h-3" />
</Button>
</div>
</div>
{/* Secret (masked) */}
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">Secret:</span>
<code className="font-mono text-muted-foreground">{wh.secret}</code>
</div>
{/* History toggle */}
<button
onClick={() => fetchHistory(wh.id!)}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{isExpanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
<History className="w-3 h-3" />
Recent executions
</button>
</div>
{/* Execution history */}
{isExpanded && (
<div className="border-t bg-muted/20 px-4 py-3">
{loadingHistory === wh.id ? (
<Skeleton className="h-8 w-full" />
) : (history[wh.id!] ?? []).length === 0 ? (
<p className="text-xs text-muted-foreground">No executions yet.</p>
) : (
<div className="space-y-1.5 max-h-48 overflow-y-auto">
{(history[wh.id!] ?? []).map(ex => (
<div key={ex.id} className="flex items-center gap-2 text-xs">
{ex.status === 'success'
? <CheckCircle className="w-3 h-3 text-success shrink-0" />
: <XCircle className="w-3 h-3 text-red-500 shrink-0" />}
<span className="font-medium">{ex.action}</span>
<span className="text-muted-foreground">
{new Date(ex.executed_at).toLocaleString()}
{!loading && webhooks.length > 0 && (
<SettingsSection title="Configured webhooks" kicker={`${webhooks.length} total`}>
<div className="pt-3 flex flex-col gap-3">
{webhooks.map(wh => {
const triggerUrl = `${window.location.origin}/api/webhooks/${wh.id}/trigger`;
const isExpanded = expandedHistory === wh.id;
return (
<div key={wh.id} className="border border-card-border rounded-md overflow-hidden bg-card">
<div className="p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<Webhook className="w-4 h-4 text-stat-subtitle shrink-0" />
<span className="font-medium text-sm truncate text-stat-value">{wh.name}</span>
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle border border-card-border rounded px-1.5 py-0.5 shrink-0">
{wh.action}
</span>
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle border border-card-border rounded px-1.5 py-0.5 shrink-0">
{wh.stack_name}
</span>
{ex.duration_ms !== null && (
<span className="text-muted-foreground">{(ex.duration_ms / 1000).toFixed(1)}s</span>
)}
{ex.error && (
<span className="text-red-500 truncate" title={ex.error}>{ex.error}</span>
)}
</div>
))}
<div className="flex items-center gap-2 shrink-0">
<TogglePill checked={wh.enabled} onChange={(c) => handleToggle(wh.id!, c)} />
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => handleDelete(wh.id!)}>
<Trash2 className="w-4 h-4 text-stat-subtitle" />
</Button>
</div>
</div>
<div className="space-y-1">
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Trigger URL</div>
<div className="flex items-center gap-2">
<code className="flex-1 text-[11px] font-mono bg-muted px-2.5 py-1.5 rounded-md truncate">{triggerUrl}</code>
<Button variant="outline" size="sm" className="h-7 px-2" onClick={() => handleCopy(triggerUrl, 'URL')}>
<Copy className="w-3 h-3" />
</Button>
</div>
</div>
<div className="flex items-center gap-2 text-xs">
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Secret</span>
<code className="font-mono text-stat-subtitle">{wh.secret}</code>
</div>
<button
onClick={() => fetchHistory(wh.id!)}
className="flex items-center gap-1.5 text-xs text-stat-subtitle hover:text-stat-value transition-colors"
>
{isExpanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
<History className="w-3 h-3" />
Recent executions
</button>
</div>
)}
</div>
)}
{isExpanded && (
<div className="border-t border-card-border bg-muted/20 px-4 py-3">
{loadingHistory === wh.id ? (
<Skeleton className="h-8 w-full" />
) : (history[wh.id!] ?? []).length === 0 ? (
<p className="text-xs text-stat-subtitle">No executions yet.</p>
) : (
<div className="space-y-1.5 max-h-48 overflow-y-auto">
{(history[wh.id!] ?? []).map(ex => (
<div key={ex.id} className="flex items-center gap-2 text-xs">
{ex.status === 'success'
? <CheckCircle className="w-3 h-3 text-success shrink-0" />
: <XCircle className="w-3 h-3 text-destructive shrink-0" />}
<span className="font-medium">{ex.action}</span>
<span className="text-stat-subtitle">
{new Date(ex.executed_at).toLocaleString()}
</span>
{ex.duration_ms !== null && (
<span className="text-stat-subtitle">{(ex.duration_ms / 1000).toFixed(1)}s</span>
)}
{ex.error && (
<span className="text-destructive truncate" title={ex.error}>{ex.error}</span>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
);
})}
</div>
);
})}
</SettingsSection>
)}
</div>
);
}
@@ -31,3 +31,9 @@ export type {
Scope,
VisibilityContext,
} from './registry';
export { SettingsSection } from './SettingsSection';
export { SettingsField, type SettingsFieldTone } from './SettingsField';
export { SettingsCallout, type SettingsCalloutTone } from './SettingsCallout';
export { SettingsActions, SettingsPrimaryButton, SettingsSecondaryButton } from './SettingsActions';
export { TierLockChip } from './TierLockChip';
export { useMastheadStats } from './MastheadStatsContext';
+6 -6
View File
@@ -68,13 +68,13 @@ export function PageMasthead({
return (
<div
className={cn(
'relative shrink-0 overflow-hidden border-b border-card-border bg-card',
'relative shrink-0 overflow-hidden border border-card-border border-t-card-border-top bg-card shadow-card-bevel transition-colors',
className,
)}
>
<div className={cn('pointer-events-none absolute inset-0 bg-gradient-to-r', config.tintClass)} />
<div className="absolute inset-y-0 left-0 w-[3px] bg-brand" />
<div className="relative grid grid-cols-[1fr_auto] items-center gap-6 py-4 pl-7 pr-6">
<div className="relative grid grid-cols-[1fr_auto] items-center gap-6 py-5 pl-7 pr-6">
<div className="flex min-w-0 items-center gap-4">
<span
aria-hidden="true"
@@ -85,10 +85,10 @@ export function PageMasthead({
)}
/>
<div className="flex min-w-0 flex-col gap-1">
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
<span className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle">
{kicker}
</span>
<span className={cn('font-display italic text-2xl leading-none tracking-tight', config.stateTextClass)}>
<span className={cn('font-display italic text-[22px] leading-7 tracking-[-0.01em]', config.stateTextClass)}>
{state}
</span>
</div>
@@ -105,12 +105,12 @@ export function PageMasthead({
idx > 0 && 'border-l border-border/60',
)}
>
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">
<span className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle">
{item.label}
</span>
<span
className={cn(
'font-mono tabular-nums text-lg leading-none',
'font-mono font-medium tabular-nums text-xl leading-none',
metadataToneClass[item.tone ?? 'value'],
)}
>