feat: add dedicated Security page and policy-pack foundation (#1362)

* feat: add dedicated Security page and policy-pack foundation

Bring vulnerability scanning, scan history, suppressions, Compose risks,
secrets, policy packs, and scanner setup into one node-scoped Security
command center instead of scattering them across Resources and Settings.

- New top-level Security view with Overview, Images, Compose risks,
  Secrets, Policies, Suppressions, History, and Scanner setup tabs
  (status masthead + signal rail; controlled tabs with deep-link support).
- Backend: GET /security/overview rollup and GET /security/policy-packs
  static catalog (auth-only, Community). DatabaseService gains an uncapped
  scan-status count and a node-eligible block-policy count, and
  getImageScanSummaries now projects secret and misconfig counts.
- Reuse existing surfaces: the scan-history sheet, the control-governed
  suppression and acknowledgement panels, and the scan-detail sheet (now
  with an initial-tab prop so it opens on the matching finding type).
- Extract a shared SeverityBadge (from Resources) and a TrivyManager
  (from Settings) so both surfaces render identical controls.
- Resources "Scan history" now links into the Security page History tab.
- Docs for the new Security surface and tests for the new endpoints,
  helpers, nav wiring, and tabs.

* refactor: consolidate scanner and policy management onto the Security page

Remove the Settings "Vulnerability Scanning" section now that the Security
page covers the same ground, with every option preserved:

- Scanner install / update / uninstall / auto-update live on the Scanner setup
  tab (TrivyManager).
- Scan policies, the honor-suppressions toggle, and the replica
  managed-by-control / demote controls move into a new ScanPolicyManager on the
  Policies tab (paid; Community sees only the policy-pack catalog).
- CVE suppressions and acknowledgements remain on the Suppressions tab.

Wiring removed: the registry section and the now-empty Security settings group,
the SectionId, the SettingsSectionContent case and the isPaid prop it was the
sole consumer of, and SecuritySection itself. The dashboard configuration-status
"Vulnerability scanning" row now navigates to the Security page Policies tab.

Docs that pointed at "Settings -> Security -> Vulnerability Scanning" are swept
to the relevant Security page tabs.

* fix: harden Security page scanner refresh, policy-load errors, and secret-only badges

Address independent-review findings on the Security page:

- Scanner setup now refreshes Trivy state when the active node changes, so the
  displayed scanner status matches the node TrivyManager's actions target (both
  follow x-node-id). Previously, switching nodes on the tab left stale state.
- ScanPolicyManager surfaces an explicit error state on a failed policy fetch
  instead of falling through to a false "No scan policies configured".
- The shared SeverityBadge and the Images findings column no longer label a scan
  "clean" when it has secrets or misconfigurations but no CVE severity
  (highest_severity is derived from vulnerabilities only); they show a "Findings"
  state and the secret/misconfig counts instead.
- The Overview enforcement note points to the Policies tab, not the removed
  Settings section.
- The History tab auto-opens the scan-history sheet only on a deep-link (mount
  with the History tab active), not on every manual tab selection.

Adds tests for the badge secret/misconfig state and the policy-load error state.
This commit is contained in:
Anso
2026-06-12 10:41:39 -04:00
committed by GitHub
parent 77f1611971
commit 2a4955f56d
51 changed files with 2559 additions and 509 deletions
@@ -1,663 +0,0 @@
import { useEffect, 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 { TogglePill } from '@/components/ui/toggle-pill';
import { Skeleton } from '@/components/ui/skeleton';
import { Combobox } from '@/components/ui/combobox';
import { Modal, ModalHeader, ModalBody, ModalFooter, ConfirmModal } from '@/components/ui/modal';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
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 { useNodes } from '@/context/NodeContext';
import { useTrivyStatus } from '@/hooks/useTrivyStatus';
import { SuppressionsPanel } from './SuppressionsPanel';
import { MisconfigAckPanel } from './MisconfigAckPanel';
import { useAuth } from '@/context/AuthContext';
const SEVERITY_OPTIONS: Array<{ value: VulnSeverity; label: string }> = [
{ value: 'CRITICAL', label: 'Critical' },
{ value: 'HIGH', label: 'High' },
{ value: 'MEDIUM', label: 'Medium' },
{ value: 'LOW', label: 'Low' },
];
interface PolicyFormState {
name: string;
stack_pattern: string;
max_severity: VulnSeverity;
block_on_deploy: boolean;
enabled: boolean;
}
const EMPTY_FORM: PolicyFormState = {
name: '',
stack_pattern: '',
max_severity: 'CRITICAL',
block_on_deploy: false,
enabled: true,
};
const TRIVY_SOURCE_BADGES: Record<'managed' | 'host' | 'none', { label: string; variant: 'outline' | 'secondary' }> = {
managed: { label: 'Installed (managed)', variant: 'outline' },
host: { label: 'Installed (host)', variant: 'outline' },
none: { label: 'Not installed', variant: 'secondary' },
};
const TRIVY_SOURCE_DESCRIPTIONS: Record<'managed' | 'host' | 'none', string | null> = {
managed: null,
host: 'Managed externally via the host binary. Install and updates are handled outside Sencho.',
none: "Install Trivy into Sencho's data volume to enable image vulnerability scanning. No host mounts required.",
};
const TRIVY_OP_LABELS: Record<'install' | 'update' | 'uninstall', { loading: string; success: string }> = {
install: { loading: 'Installing Trivy...', success: 'Trivy installed' },
update: { loading: 'Updating Trivy...', success: 'Trivy updated' },
uninstall: { loading: 'Removing Trivy...', success: 'Trivy removed' },
};
export function SecuritySection({ isPaid }: { isPaid: boolean }) {
const { isAdmin } = useAuth();
const [policies, setPolicies] = useState<ScanPolicy[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [form, setForm] = useState<PolicyFormState>(EMPTY_FORM);
const [saving, setSaving] = useState(false);
const [deleteId, setDeleteId] = useState<number | null>(null);
const { activeNode } = useNodes();
const isRemote = activeNode?.type === 'remote';
const { status: trivy, updateCheck, refresh: refreshTrivy, refreshUpdateCheck } = useTrivyStatus();
const [trivyBusy, setTrivyBusy] = useState<null | 'install' | 'update' | 'uninstall' | 'auto-update' | 'honor-suppressions'>(null);
const [uninstallConfirm, setUninstallConfirm] = useState(false);
const [fleetRole, setFleetRole] = useState<FleetRole>('control');
const [fleetRoleProbeFailed, setFleetRoleProbeFailed] = useState(false);
const [demoteConfirm, setDemoteConfirm] = useState(false);
const [demoteBusy, setDemoteBusy] = useState(false);
const isReplica = fleetRole === 'replica';
const runTrivyOp = async (
op: 'install' | 'update' | 'uninstall',
path: string,
method: 'POST' | 'DELETE',
) => {
const { loading, success } = TRIVY_OP_LABELS[op];
setTrivyBusy(op);
const toastId = toast.loading(loading);
try {
const res = await apiFetch(path, { method });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.error || `Trivy ${op} failed`);
}
toast.success(success);
await Promise.all([refreshTrivy(), refreshUpdateCheck()]);
} catch (err) {
toast.error((err as Error)?.message || `Trivy ${op} failed`);
} finally {
toast.dismiss(toastId);
setTrivyBusy(null);
}
};
const handleInstallTrivy = () => runTrivyOp('install', '/security/trivy-install', 'POST');
const handleUpdateTrivy = () => runTrivyOp('update', '/security/trivy-update', 'POST');
const handleUninstallTrivy = async () => {
setUninstallConfirm(false);
await runTrivyOp('uninstall', '/security/trivy-install', 'DELETE');
};
const handleAutoUpdateToggle = async (enabled: boolean) => {
setTrivyBusy('auto-update');
try {
const res = await apiFetch('/security/trivy-auto-update', {
method: 'PUT',
body: JSON.stringify({ enabled }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.error || 'Failed to update setting');
}
await refreshTrivy();
} catch (err) {
toast.error((err as Error)?.message || 'Failed to update setting');
} finally {
setTrivyBusy(null);
}
};
const handleHonorSuppressionsToggle = async (enabled: boolean) => {
setTrivyBusy('honor-suppressions');
try {
const res = await apiFetch('/security/deploy-block-honor-suppressions', {
method: 'PUT',
body: JSON.stringify({ enabled }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.error || 'Failed to update setting');
}
await refreshTrivy();
} catch (err) {
toast.error((err as Error)?.message || 'Failed to update setting');
} finally {
setTrivyBusy(null);
}
};
const fetchPolicies = async () => {
try {
const res = await apiFetch('/security/policies', { localOnly: true });
if (res.ok) {
const data = await res.json();
setPolicies(Array.isArray(data) ? data : []);
}
} catch (err) {
console.error('Failed to load scan policies:', err);
toast.error('Failed to load scan policies');
} finally {
setLoading(false);
}
};
useEffect(() => {
if (!isPaid) { setLoading(false); return; }
if (isRemote) { setPolicies([]); setLoading(false); return; }
fetchPolicies();
}, [isPaid, isRemote]);
useEffect(() => {
void refreshTrivy();
}, [activeNode?.id, refreshTrivy]);
useEffect(() => {
if (isRemote) return;
let cancelled = false;
(async () => {
try {
const res = await apiFetch('/fleet/role', { localOnly: true });
if (!res.ok) {
if (!cancelled) setFleetRoleProbeFailed(true);
return;
}
const data = await res.json();
if (cancelled) return;
if (data?.role === 'control' || data?.role === 'replica') {
setFleetRole(data.role);
setFleetRoleProbeFailed(false);
} else {
setFleetRoleProbeFailed(true);
}
} catch {
if (!cancelled) setFleetRoleProbeFailed(true);
}
})();
return () => { cancelled = true; };
}, [isRemote]);
const handleDemote = async () => {
setDemoteBusy(true);
try {
const res = await apiFetch('/fleet/role/demote', {
method: 'POST',
localOnly: true,
body: JSON.stringify({ confirm: true }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.error || 'Demote failed');
}
toast.success('Replica demoted to control');
setFleetRole('control');
setDemoteConfirm(false);
fetchPolicies();
} catch (err) {
toast.error((err as Error)?.message || 'Demote failed');
} finally {
setDemoteBusy(false);
}
};
const openCreate = () => {
setEditingId(null);
setForm(EMPTY_FORM);
setDialogOpen(true);
};
const openEdit = (policy: ScanPolicy) => {
setEditingId(policy.id);
setForm({
name: policy.name,
stack_pattern: policy.stack_pattern ?? '',
max_severity: policy.max_severity,
block_on_deploy: policy.block_on_deploy === 1,
enabled: policy.enabled === 1,
});
setDialogOpen(true);
};
const handleSave = async () => {
if (!form.name.trim()) {
toast.error('Policy name is required');
return;
}
setSaving(true);
try {
const payload = {
name: form.name.trim(),
stack_pattern: form.stack_pattern.trim() || null,
max_severity: form.max_severity,
block_on_deploy: form.block_on_deploy ? 1 : 0,
enabled: form.enabled ? 1 : 0,
};
const url = editingId ? `/security/policies/${editingId}` : '/security/policies';
const method = editingId ? 'PUT' : 'POST';
const res = await apiFetch(url, {
method,
localOnly: true,
body: JSON.stringify(payload),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.error || 'Failed to save policy');
}
toast.success(editingId ? 'Policy updated' : 'Policy created');
setDialogOpen(false);
fetchPolicies();
} catch (err) {
toast.error((err as Error)?.message || 'Failed to save policy');
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (deleteId == null) return;
try {
const res = await apiFetch(`/security/policies/${deleteId}`, {
method: 'DELETE',
localOnly: true,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.error || 'Failed to delete policy');
}
toast.success('Policy deleted');
fetchPolicies();
} catch (err) {
toast.error((err as Error)?.message || 'Failed to delete policy');
} finally {
setDeleteId(null);
}
};
useMastheadStats(
loading
? null
: [
...(isPaid ? [{ label: 'POLICIES', value: `${policies.length}` }] : []),
{
label: 'TRIVY',
value: trivy.source === 'none' ? 'missing' : trivy.source,
tone: trivy.source === 'none' ? 'warn' : 'value' as const,
},
],
);
return (
<div className="space-y-6">
{isPaid && isAdmin && !isRemote && !isReplica && (
<div className="flex justify-end">
<SettingsPrimaryButton size="sm" onClick={openCreate}>
<Plus className="w-4 h-4" />
Add policy
</SettingsPrimaryButton>
</div>
)}
{!isRemote && isReplica && (
<div
role="status"
aria-live="polite"
className="flex items-start justify-between gap-3 rounded-lg border border-card-border bg-muted/30 px-4 py-3"
>
<div className="flex items-start gap-2">
<Info className="w-4 h-4 text-muted-foreground shrink-0 mt-0.5" strokeWidth={1.5} aria-hidden="true" />
<div className="text-sm">
<div className="font-medium">Managed by control node</div>
<p className="text-xs text-muted-foreground mt-0.5">
Security policies replicate from the control Sencho instance. View them here for audit; edit them on the control.
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
className="shrink-0"
onClick={() => setDemoteConfirm(true)}
disabled={demoteBusy}
>
Demote to control
</Button>
</div>
)}
{!isRemote && fleetRoleProbeFailed && !isReplica && (
<div
role="status"
aria-live="polite"
className="flex items-start gap-2 rounded-lg border border-card-border bg-muted/30 px-4 py-3"
>
<Info className="w-4 h-4 text-muted-foreground shrink-0 mt-0.5" strokeWidth={1.5} aria-hidden="true" />
<div className="text-sm">
<div className="font-medium">Fleet role could not be determined</div>
<p className="text-xs text-muted-foreground mt-0.5">
Treating this instance as a control. Refresh the page to retry.
</p>
</div>
</div>
)}
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<ShieldCheck className="w-4 h-4 text-muted-foreground shrink-0" strokeWidth={1.5} />
<span className="font-medium text-sm">Vulnerability Scanner</span>
<Badge variant={TRIVY_SOURCE_BADGES[trivy.source].variant} className="text-[10px] shrink-0">
{TRIVY_SOURCE_BADGES[trivy.source].label}
</Badge>
{updateCheck?.updateAvailable && (
<Badge variant="secondary" className="text-[10px] shrink-0">
Update available to v{updateCheck.latest}
</Badge>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{isAdmin && trivy.source === 'none' && (
<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
</SettingsPrimaryButton>
)}
{isAdmin && trivy.source === 'managed' && updateCheck?.updateAvailable && (
<Button size="sm" variant="outline" onClick={handleUpdateTrivy} disabled={trivyBusy !== null}>
{trivyBusy === 'update' ? (
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />
) : (
<RefreshCw className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
)}
Update
</Button>
)}
{isAdmin && trivy.source === 'managed' && (
<Button
size="sm"
variant="ghost"
className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
onClick={() => setUninstallConfirm(true)}
disabled={trivyBusy !== null}
>
Uninstall
</Button>
)}
</div>
</div>
{trivy.source === 'managed' && trivy.version && (
<div className="text-xs text-stat-subtitle font-mono">Version: v{trivy.version}</div>
)}
{TRIVY_SOURCE_DESCRIPTIONS[trivy.source] && (
<div className="text-xs text-stat-subtitle">{TRIVY_SOURCE_DESCRIPTIONS[trivy.source]}</div>
)}
{trivy.source === 'managed' && isAdmin && (
<div className="flex items-center justify-between rounded-lg border border-glass-border px-3 py-2.5">
<div>
<Label className="text-sm">Auto-update Trivy</Label>
<p className="text-xs text-muted-foreground">
Check daily and install newer Trivy releases automatically.
</p>
</div>
<TogglePill
checked={trivy.autoUpdate}
onChange={handleAutoUpdateToggle}
disabled={trivyBusy !== null}
/>
</div>
)}
</div>
{isRemote && (
<div
role="status"
aria-live="polite"
className="flex items-start gap-2 rounded-lg border border-card-border bg-muted/30 px-4 py-3"
>
<Info className="w-4 h-4 text-muted-foreground shrink-0 mt-0.5" strokeWidth={1.5} aria-hidden="true" />
<div className="text-sm">
<div className="font-medium">Scanner is per-node</div>
<p className="text-xs text-muted-foreground mt-0.5">
Trivy is installed independently on each Sencho instance. Scan policies and CVE suppressions are managed on the control node.
</p>
</div>
</div>
)}
{!isRemote && loading && (
<div className="space-y-3">
<Skeleton className="h-20 w-full rounded-lg" />
<Skeleton className="h-20 w-full rounded-lg" />
</div>
)}
{isPaid && !isRemote && !loading && policies.length === 0 && (
<SettingsCallout
icon={<ShieldCheck className="h-4 w-4" />}
title="No scan policies configured"
subtitle="Add one to enforce severity thresholds across your fleet."
/>
)}
{isPaid && !isRemote && !loading &&
policies.map((policy) => (
<div key={policy.id} className="border border-glass-border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<ShieldCheck className="w-4 h-4 text-muted-foreground shrink-0" strokeWidth={1.5} />
<span className="font-medium text-sm truncate">{policy.name}</span>
<Badge variant="outline" className="text-[10px] shrink-0">
max: {policy.max_severity}
</Badge>
{policy.block_on_deploy === 1 && (
<Badge variant="destructive" className="text-[10px] shrink-0">
block
</Badge>
)}
{policy.enabled === 0 && (
<Badge variant="secondary" className="text-[10px] shrink-0">
disabled
</Badge>
)}
</div>
{isAdmin && !isReplica && (
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => openEdit(policy)}
>
<Pencil className="w-3.5 h-3.5 text-muted-foreground" strokeWidth={1.5} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
onClick={() => setDeleteId(policy.id)}
>
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
</div>
)}
</div>
<div className="text-xs text-muted-foreground">
Scope: {policy.stack_pattern ? (
<code className="font-mono bg-muted px-1.5 py-0.5 rounded text-[11px]">{policy.stack_pattern}</code>
) : (
<span className="italic">all stacks</span>
)}
</div>
</div>
))}
{isPaid && isAdmin && !isRemote && (
<div className="flex items-center justify-between gap-3 rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel px-4 py-3">
<div className="min-w-0">
<Label className="text-sm">Honor suppressions in deploy blocks</Label>
<p className="text-xs text-muted-foreground mt-0.5">
When on, a suppressed CVE no longer counts toward a block-on-deploy policy, so an accepted finding will not stop a deploy on this instance. Off by default: policies block on the raw scan result.
</p>
</div>
<TogglePill
checked={trivy.honorSuppressionsOnDeploy}
onChange={handleHonorSuppressionsToggle}
disabled={trivyBusy !== null}
/>
</div>
)}
{!isRemote && <SuppressionsPanel isReplica={isReplica} />}
{!isRemote && <MisconfigAckPanel isReplica={isReplica} />}
{isPaid && (
<>
<Modal open={dialogOpen} onOpenChange={setDialogOpen} size="md">
<ModalHeader
kicker={editingId ? 'SECURITY · EDIT POLICY' : 'SECURITY · NEW POLICY'}
title={editingId ? 'Edit policy' : 'New policy'}
description="Configure the severity threshold and scope for this scan policy."
/>
<ModalBody>
<div className="space-y-2">
<Label htmlFor="policy-name">Name</Label>
<Input
id="policy-name"
placeholder="Production block on critical"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="policy-pattern">Stack pattern (optional)</Label>
<Input
id="policy-pattern"
placeholder="e.g. prod-* or leave blank for all"
value={form.stack_pattern}
onChange={(e) => setForm({ ...form, stack_pattern: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
Glob-style pattern matched against stack names. Leave blank to apply to all stacks.
</p>
</div>
<div className="space-y-2">
<Label>Max severity</Label>
<Combobox
options={SEVERITY_OPTIONS}
value={form.max_severity}
onValueChange={(v) => setForm({ ...form, max_severity: v as VulnSeverity })}
/>
</div>
<div className="flex items-center justify-between rounded-lg border border-glass-border px-3 py-2.5">
<div>
<Label className="text-sm">Block on deploy</Label>
<p className="text-xs text-muted-foreground">
Reject a deploy before containers start when any image meets or exceeds the threshold. With this off, the policy only evaluates and raises an alert.
</p>
</div>
<TogglePill
checked={form.block_on_deploy}
onChange={(c) => setForm({ ...form, block_on_deploy: c })}
/>
</div>
<div className="flex items-center justify-between rounded-lg border border-glass-border px-3 py-2.5">
<div>
<Label className="text-sm">Enabled</Label>
<p className="text-xs text-muted-foreground">Disabled policies are skipped during evaluation.</p>
</div>
<TogglePill
checked={form.enabled}
onChange={(c) => setForm({ ...form, enabled: c })}
/>
</div>
</ModalBody>
<ModalFooter
secondary={
<Button variant="outline" size="sm" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
}
primary={
<SettingsPrimaryButton size="sm" onClick={handleSave} disabled={saving}>
{saving ? 'Saving...' : editingId ? 'Update' : 'Create'}
</SettingsPrimaryButton>
}
/>
</Modal>
<ConfirmModal
open={deleteId != null}
onOpenChange={(open) => !open && setDeleteId(null)}
variant="destructive"
kicker="SECURITY · DELETE · IRREVERSIBLE"
title="Delete scan policy"
confirmLabel="Delete"
onConfirm={handleDelete}
>
<p className="text-sm text-stat-subtitle">
Removes the policy immediately. Existing scans are not affected.
</p>
</ConfirmModal>
</>
)}
<ConfirmModal
open={uninstallConfirm}
onOpenChange={setUninstallConfirm}
variant="destructive"
kicker="TRIVY · REMOVE · IRREVERSIBLE"
title="Remove Trivy"
confirmLabel="Remove"
onConfirm={handleUninstallTrivy}
>
<p className="text-sm text-stat-subtitle">
Removes the managed Trivy binary. Vulnerability scanning stops working until Trivy is reinstalled or a host binary is provided.
</p>
</ConfirmModal>
<ConfirmModal
open={demoteConfirm}
onOpenChange={setDemoteConfirm}
variant="destructive"
kicker="FLEET · DEMOTE · IRREVERSIBLE"
title="Demote replica to control"
confirmLabel={demoteBusy ? 'Demoting...' : 'Demote'}
onConfirm={handleDemote}
>
<p className="text-sm text-stat-subtitle">
Removes every replicated scan policy and CVE suppression mirrored from the control. Local edits to security policies on this instance become available again.
</p>
</ConfirmModal>
</div>
);
}
@@ -194,7 +194,6 @@ function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProp
<div className="px-7 pt-6 pb-8 flex flex-col gap-6 min-w-0">
<SettingsSectionContent
sectionId={safeSection}
isPaid={isPaid}
onDirtyChange={handleDirtyChange}
showDescription
/>
@@ -35,9 +35,6 @@ const UsersSection = lazy(() =>
const WebhooksSection = lazy(() =>
import('./WebhooksSection').then(m => ({ default: m.WebhooksSection })),
);
const SecuritySection = lazy(() =>
import('./SecuritySection').then(m => ({ default: m.SecuritySection })),
);
const LabelsSection = lazy(() =>
import('./LabelsSection').then(m => ({ default: m.LabelsSection })),
);
@@ -71,7 +68,6 @@ function SectionSkeleton() {
function renderSection(
sectionId: SectionId,
isPaid: boolean,
onDirtyChange: (section: SectionId, dirty: boolean) => void,
) {
switch (sectionId) {
@@ -89,7 +85,6 @@ function renderSection(
case 'notifications': return <NotificationsSection />;
case 'notification-routing': return <NotificationRoutingSection />;
case 'webhooks': return <WebhooksSection />;
case 'security': return <SecuritySection isPaid={isPaid} />;
case 'cloud-backup': return <CloudBackupSection />;
case 'developer': return <DeveloperSection onDirtyChange={(d) => onDirtyChange('developer', d)} />;
case 'data-retention': return <DataRetentionSection onDirtyChange={(d) => onDirtyChange('data-retention', d)} />;
@@ -105,7 +100,6 @@ function renderSection(
interface SettingsSectionContentProps {
sectionId: SectionId;
isPaid: boolean;
onDirtyChange: (section: SectionId, dirty: boolean) => void;
/** Render the section's lead description paragraph above the content. */
showDescription?: boolean;
@@ -117,14 +111,14 @@ interface SettingsSectionContentProps {
* the desktop SettingsPage and the mobile settings screen so the section switch,
* lazy splitting, and gating live in exactly one place.
*/
export function SettingsSectionContent({ sectionId, isPaid, onDirtyChange, showDescription }: SettingsSectionContentProps) {
export function SettingsSectionContent({ sectionId, onDirtyChange, showDescription }: SettingsSectionContentProps) {
const item = getSettingsItem(sectionId);
// Memoize the section element so unrelated re-renders of the host page (the
// command palette opening, a dirty-flag toggle) do not re-render the active
// section. onDirtyChange is stable from both call sites.
const element = useMemo(
() => renderSection(sectionId, isPaid, onDirtyChange),
[sectionId, isPaid, onDirtyChange],
() => renderSection(sectionId, onDirtyChange),
[sectionId, onDirtyChange],
);
return (
<>
@@ -63,7 +63,10 @@ describe('settings registry', () => {
const byId = new Map(SETTINGS_ITEMS.map(i => [i.id, i]));
expect(byId.get('notifications')?.label).toBe('Channels');
expect(byId.get('notification-routing')?.label).toBe('Notification Routing');
expect(byId.get('security')?.label).toBe('Vulnerability Scanning');
});
it('no longer registers the standalone Vulnerability Scanning section (moved to the Security page)', () => {
expect(SETTINGS_ITEMS.some(i => (i.id as string) === 'security')).toBe(false);
});
it('opens Registries to Community while keeping it admin-only', () => {
+1 -1
View File
@@ -14,7 +14,7 @@ export { SupportSection } from './SupportSection';
export { AboutSection } from './AboutSection';
export { RecoverySection } from './RecoverySection';
// Paid-tier sections (UsersSection, WebhooksSection, SecuritySection,
// Paid-tier sections (UsersSection, WebhooksSection,
// LabelsSection, CloudBackupSection, NotificationRoutingSection) are NOT
// re-exported from this barrel. They are dynamically imported with
// React.lazy in SettingsPage.tsx so their JSX, copy, and prop shapes do not
@@ -8,7 +8,6 @@ export type SettingsGroupId =
| 'notifications'
| 'automation'
| 'organization'
| 'security'
| 'operations'
| 'help';
@@ -27,7 +26,6 @@ export const SETTINGS_GROUPS: readonly SettingsGroupMeta[] = [
{ id: 'notifications', label: 'Notifications', glyph: '\u25C7' },
{ id: 'automation', label: 'Automation', glyph: '\u25C7' },
{ id: 'organization', label: 'Organization', glyph: '\u25C7' },
{ id: 'security', label: 'Security', glyph: '\u25C6' },
{ id: 'operations', label: 'Operations', glyph: '\u25C7' },
{ id: 'help', label: 'Help', glyph: '\u25C7' },
];
@@ -225,17 +223,6 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
tier: null,
scope: 'node',
},
// Security
{
id: 'security',
group: 'security',
label: 'Vulnerability Scanning',
description: 'Image scanning, suppressions, and posture defaults.',
keywords: ['scan', 'cve', 'trivy', 'suppressions', 'hardening', 'vulnerability', 'misconfig'],
tier: null,
scope: 'node',
adminOnly: true,
},
// Operations
{
id: 'data-retention',
@@ -54,7 +54,6 @@ export type SectionId =
| 'fleet-mesh'
| 'notifications'
| 'webhooks'
| 'security'
| 'cloud-backup'
| 'developer'
| 'data-retention'