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
@@ -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>
);
}