mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 18:05:10 +00:00
eead195529
* 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.
594 lines
22 KiB
TypeScript
594 lines
22 KiB
TypeScript
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 {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
} from '@/components/ui/dialog';
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from '@/components/ui/alert-dialog';
|
|
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';
|
|
import { useTrivyStatus } from '@/hooks/useTrivyStatus';
|
|
import { SuppressionsPanel } from './SuppressionsPanel';
|
|
|
|
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 [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 { license } = useLicense();
|
|
const isAdmiral = isPaid && license?.variant === 'admiral';
|
|
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'>(null);
|
|
const [uninstallConfirm, setUninstallConfirm] = useState(false);
|
|
const [fleetRole, setFleetRole] = useState<FleetRole>('control');
|
|
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 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) return;
|
|
const data = await res.json();
|
|
if (!cancelled && (data?.role === 'control' || data?.role === 'replica')) {
|
|
setFleetRole(data.role);
|
|
}
|
|
} catch {
|
|
/* fallback: treat as control if the check fails */
|
|
}
|
|
})();
|
|
return () => { cancelled = true; };
|
|
}, [isRemote]);
|
|
|
|
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
|
|
: [
|
|
{ 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">
|
|
<PaidGate featureName="Scan Policies">
|
|
<div className="space-y-3">
|
|
<div className="h-16 rounded-lg border bg-card" />
|
|
<div className="h-16 rounded-lg border bg-card" />
|
|
</div>
|
|
</PaidGate>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{!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 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">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>
|
|
)}
|
|
|
|
<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>
|
|
{isAdmiral && (
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
{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>
|
|
)}
|
|
{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>
|
|
)}
|
|
{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' && isAdmiral && (
|
|
<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>
|
|
)}
|
|
|
|
{!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."
|
|
/>
|
|
)}
|
|
|
|
{!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>
|
|
{!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>
|
|
))}
|
|
|
|
{!isRemote && <SuppressionsPanel isReplica={isReplica} />}
|
|
|
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>{editingId ? 'Edit Policy' : 'New Policy'}</DialogTitle>
|
|
<DialogDescription className="sr-only">
|
|
Configure the severity threshold and scope for this scan policy.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-2">
|
|
<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">
|
|
Emit a critical alert when this policy is violated after a deploy.
|
|
</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>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<SettingsPrimaryButton onClick={handleSave} disabled={saving}>
|
|
{saving ? 'Saving...' : editingId ? 'Update' : 'Create'}
|
|
</SettingsPrimaryButton>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<AlertDialog open={deleteId != null} onOpenChange={(open) => !open && setDeleteId(null)}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Delete scan policy?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
This removes the policy immediately. Existing scans are not affected.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={handleDelete}
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
>
|
|
Delete
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
<AlertDialog open={uninstallConfirm} onOpenChange={setUninstallConfirm}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Remove Trivy?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
This removes the managed Trivy binary. Vulnerability scanning will stop working until
|
|
Trivy is reinstalled or a host binary is provided.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={handleUninstallTrivy}
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
>
|
|
Remove
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
);
|
|
}
|