refactor(frontend): migrate Routing and Security dialogs to Modal chrome (#948)

NotificationRoutingSection:
- Routing rule create/edit form -> Modal at lg with kicker
  ROUTING · NEW RULE or ROUTING · EDIT RULE
- Per-row delete AlertDialog -> a single parent-level destructive
  ConfirmModal driven by deleteRouteId state, opened by each row's
  trash button (no more inline AlertDialogTrigger pattern)
- handleDelete tightened to close from finally so the dialog clears on
  errors too

SecuritySection:
- Policy create/edit form -> Modal at md with kicker
  SECURITY · NEW POLICY or SECURITY · EDIT POLICY
- Policy delete confirm -> destructive ConfirmModal with kicker
  SECURITY · DELETE · IRREVERSIBLE
- Trivy uninstall confirm -> destructive ConfirmModal with kicker
  TRIVY · REMOVE · IRREVERSIBLE
- handleDelete already used finally; handleUninstallTrivy already
  closes the dialog before awaiting, so it works with the
  ConfirmModal Promise-aware path
This commit is contained in:
Anso
2026-05-06 20:17:18 -04:00
committed by GitHub
parent 165caf2102
commit 7c2cbd0882
2 changed files with 146 additions and 189 deletions
@@ -10,23 +10,7 @@ import type { ComboboxOption } from '@/components/ui/combobox';
import { Tabs, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from '@/components/ui/tabs'; import { Tabs, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from '@/components/ui/tabs';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { springs } from '@/lib/motion'; import { springs } from '@/lib/motion';
import { import { Modal, ModalHeader, ModalBody, ModalFooter, ConfirmModal } from '@/components/ui/modal';
Dialog,
DialogContent,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { toast } from '@/components/ui/toast-store'; import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api'; import { apiFetch } from '@/lib/api';
import { useNodes } from '@/context/NodeContext'; import { useNodes } from '@/context/NodeContext';
@@ -76,6 +60,7 @@ export function NotificationRoutingSection() {
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null); const [editingId, setEditingId] = useState<number | null>(null);
const [testingId, setTestingId] = useState<number | null>(null); const [testingId, setTestingId] = useState<number | null>(null);
const [deleteRouteId, setDeleteRouteId] = useState<number | null>(null);
const [stackOptions, setStackOptions] = useState<ComboboxOption[]>([]); const [stackOptions, setStackOptions] = useState<ComboboxOption[]>([]);
const [labelOptions, setLabelOptions] = useState<StackLabel[]>([]); const [labelOptions, setLabelOptions] = useState<StackLabel[]>([]);
@@ -202,9 +187,10 @@ export function NotificationRoutingSection() {
} }
}; };
const handleDelete = async (id: number) => { const handleDelete = async () => {
if (deleteRouteId == null) return;
try { try {
const res = await apiFetch(`/notification-routes/${id}`, { method: 'DELETE' }); const res = await apiFetch(`/notification-routes/${deleteRouteId}`, { method: 'DELETE' });
if (res.ok) { if (res.ok) {
toast.success('Route deleted.'); toast.success('Route deleted.');
fetchRoutes(); fetchRoutes();
@@ -214,9 +200,13 @@ export function NotificationRoutingSection() {
} }
} catch { } catch {
toast.error('Network error.'); toast.error('Network error.');
} finally {
setDeleteRouteId(null);
} }
}; };
const deleteTargetRoute = deleteRouteId != null ? routes.find(r => r.id === deleteRouteId) : null;
const handleTest = async (id: number) => { const handleTest = async (id: number) => {
setTestingId(id); setTestingId(id);
try { try {
@@ -317,14 +307,13 @@ export function NotificationRoutingSection() {
</SettingsPrimaryButton> </SettingsPrimaryButton>
</div> </div>
<Dialog open={showForm} onOpenChange={(open) => { if (!open) resetForm(); }}> <Modal open={showForm} onOpenChange={(open) => { if (!open) resetForm(); }} size="lg">
<DialogContent className="sm:max-w-[500px]"> <ModalHeader
<DialogTitle>{editingId ? 'Edit Route' : 'New Routing Rule'}</DialogTitle> kicker={editingId ? 'ROUTING · EDIT RULE' : 'ROUTING · NEW RULE'}
<DialogDescription className="sr-only"> title={editingId ? 'Edit routing rule' : 'New routing rule'}
{editingId ? 'Edit a notification routing rule' : 'Create a notification routing rule'} description={editingId ? 'Edit a notification routing rule' : 'Create a notification routing rule'}
</DialogDescription> />
<ModalBody>
<div className="space-y-4 pt-2">
<div className="space-y-2"> <div className="space-y-2">
<Label>Name</Label> <Label>Name</Label>
<Input <Input
@@ -484,15 +473,18 @@ export function NotificationRoutingSection() {
</div> </div>
</div> </div>
<div className="flex justify-end gap-2 pt-2"> </ModalBody>
<Button variant="outline" size="sm" onClick={resetForm}>Cancel</Button> <ModalFooter
<SettingsPrimaryButton size="sm" onClick={handleSave} disabled={saving}> secondary={
{saving ? <><RefreshCw className="w-4 h-4 animate-spin" />Saving</> : editingId ? 'Update' : 'Create'} <Button variant="outline" size="sm" onClick={resetForm}>Cancel</Button>
</SettingsPrimaryButton> }
</div> primary={
</div> <SettingsPrimaryButton size="sm" onClick={handleSave} disabled={saving}>
</DialogContent> {saving ? <><RefreshCw className="w-4 h-4 animate-spin" />Saving</> : editingId ? 'Update' : 'Create'}
</Dialog> </SettingsPrimaryButton>
}
/>
</Modal>
{loading && ( {loading && (
<div className="space-y-3"> <div className="space-y-3">
@@ -554,35 +546,15 @@ export function NotificationRoutingSection() {
<Button variant="ghost" size="sm" onClick={() => startEdit(route)} title="Edit"> <Button variant="ghost" size="sm" onClick={() => startEdit(route)} title="Edit">
<Pencil className="w-4 h-4" strokeWidth={1.5} /> <Pencil className="w-4 h-4" strokeWidth={1.5} />
</Button> </Button>
<AlertDialog> <Button
<AlertDialogTrigger asChild> variant="ghost"
<Button size="sm"
variant="ghost" className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
size="sm" title="Delete"
className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground" onClick={() => setDeleteRouteId(route.id)}
title="Delete" >
> <Trash2 className="w-4 h-4" strokeWidth={1.5} />
<Trash2 className="w-4 h-4" strokeWidth={1.5} /> </Button>
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete routing rule?</AlertDialogTitle>
<AlertDialogDescription>
Deleting <strong>{route.name}</strong> will remove this routing rule. Alerts for the associated stacks will fall back to your global notification channels.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDelete(route.id)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground"> <div className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
@@ -616,6 +588,20 @@ export function NotificationRoutingSection() {
</div> </div>
</div> </div>
))} ))}
<ConfirmModal
open={deleteRouteId != null}
onOpenChange={(open) => { if (!open) setDeleteRouteId(null); }}
variant="destructive"
kicker="ROUTING · DELETE · IRREVERSIBLE"
title="Delete routing rule"
confirmLabel="Delete"
onConfirm={handleDelete}
>
<p className="text-sm text-stat-subtitle">
Deletes <span className="font-medium text-stat-value">{deleteTargetRoute?.name ?? 'this rule'}</span>. Alerts for the associated stacks will fall back to your global notification channels.
</p>
</ConfirmModal>
</div> </div>
</CapabilityGate> </CapabilityGate>
</AdmiralGate> </AdmiralGate>
@@ -6,24 +6,7 @@ import { Badge } from '@/components/ui/badge';
import { TogglePill } from '@/components/ui/toggle-pill'; import { TogglePill } from '@/components/ui/toggle-pill';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Combobox } from '@/components/ui/combobox'; import { Combobox } from '@/components/ui/combobox';
import { import { Modal, ModalHeader, ModalBody, ModalFooter, ConfirmModal } from '@/components/ui/modal';
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 { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api'; import { apiFetch } from '@/lib/api';
import { ShieldCheck, Plus, Trash2, Pencil, Download, RefreshCw, Loader2, Info } from 'lucide-react'; import { ShieldCheck, Plus, Trash2, Pencil, Download, RefreshCw, Loader2, Info } from 'lucide-react';
@@ -462,120 +445,108 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
{isPaid && ( {isPaid && (
<> <>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> <Modal open={dialogOpen} onOpenChange={setDialogOpen} size="md">
<DialogContent className="sm:max-w-md"> <ModalHeader
<DialogHeader> kicker={editingId ? 'SECURITY · EDIT POLICY' : 'SECURITY · NEW POLICY'}
<DialogTitle>{editingId ? 'Edit Policy' : 'New Policy'}</DialogTitle> title={editingId ? 'Edit policy' : 'New policy'}
<DialogDescription className="sr-only"> description="Configure the severity threshold and scope for this scan policy."
Configure the severity threshold and scope for this scan policy. />
</DialogDescription> <ModalBody>
</DialogHeader> <div className="space-y-2">
<div className="space-y-4 py-2"> <Label htmlFor="policy-name">Name</Label>
<div className="space-y-2"> <Input
<Label htmlFor="policy-name">Name</Label> id="policy-name"
<Input placeholder="Production block on critical"
id="policy-name" value={form.name}
placeholder="Production block on critical" onChange={(e) => setForm({ ...form, name: e.target.value })}
value={form.name} />
onChange={(e) => setForm({ ...form, name: e.target.value })} </div>
/> <div className="space-y-2">
</div> <Label htmlFor="policy-pattern">Stack pattern (optional)</Label>
<div className="space-y-2"> <Input
<Label htmlFor="policy-pattern">Stack pattern (optional)</Label> id="policy-pattern"
<Input placeholder="e.g. prod-* or leave blank for all"
id="policy-pattern" value={form.stack_pattern}
placeholder="e.g. prod-* or leave blank for all" onChange={(e) => setForm({ ...form, stack_pattern: e.target.value })}
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"> <p className="text-xs text-muted-foreground">
Emit a critical alert when this policy is violated after a deploy. Glob-style pattern matched against stack names. Leave blank to apply to all stacks.
</p> </p>
</div> </div>
<TogglePill <div className="space-y-2">
checked={form.block_on_deploy} <Label>Max severity</Label>
onChange={(c) => setForm({ ...form, block_on_deploy: c })} <Combobox
/> options={SEVERITY_OPTIONS}
</div> value={form.max_severity}
<div className="flex items-center justify-between rounded-lg border border-glass-border px-3 py-2.5"> onValueChange={(v) => setForm({ ...form, max_severity: v as VulnSeverity })}
<div> />
<Label className="text-sm">Enabled</Label>
<p className="text-xs text-muted-foreground">Disabled policies are skipped during evaluation.</p>
</div> </div>
<TogglePill <div className="flex items-center justify-between rounded-lg border border-glass-border px-3 py-2.5">
checked={form.enabled} <div>
onChange={(c) => setForm({ ...form, enabled: c })} <Label className="text-sm">Block on deploy</Label>
/> <p className="text-xs text-muted-foreground">
</div> Emit a critical alert when this policy is violated after a deploy.
</div> </p>
<DialogFooter> </div>
<Button variant="outline" onClick={() => setDialogOpen(false)}> <TogglePill
Cancel checked={form.block_on_deploy}
</Button> onChange={(c) => setForm({ ...form, block_on_deploy: c })}
<SettingsPrimaryButton onClick={handleSave} disabled={saving}> />
{saving ? 'Saving...' : editingId ? 'Update' : 'Create'} </div>
</SettingsPrimaryButton> <div className="flex items-center justify-between rounded-lg border border-glass-border px-3 py-2.5">
</DialogFooter> <div>
</DialogContent> <Label className="text-sm">Enabled</Label>
</Dialog> <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>
<AlertDialog open={deleteId != null} onOpenChange={(open) => !open && setDeleteId(null)}> <ConfirmModal
<AlertDialogContent> open={deleteId != null}
<AlertDialogHeader> onOpenChange={(open) => !open && setDeleteId(null)}
<AlertDialogTitle>Delete scan policy?</AlertDialogTitle> variant="destructive"
<AlertDialogDescription> kicker="SECURITY · DELETE · IRREVERSIBLE"
This removes the policy immediately. Existing scans are not affected. title="Delete scan policy"
</AlertDialogDescription> confirmLabel="Delete"
</AlertDialogHeader> onConfirm={handleDelete}
<AlertDialogFooter> >
<AlertDialogCancel>Cancel</AlertDialogCancel> <p className="text-sm text-stat-subtitle">
<AlertDialogAction Removes the policy immediately. Existing scans are not affected.
onClick={handleDelete} </p>
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" </ConfirmModal>
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</> </>
)} )}
<AlertDialog open={uninstallConfirm} onOpenChange={setUninstallConfirm}> <ConfirmModal
<AlertDialogContent> open={uninstallConfirm}
<AlertDialogHeader> onOpenChange={setUninstallConfirm}
<AlertDialogTitle>Remove Trivy?</AlertDialogTitle> variant="destructive"
<AlertDialogDescription> kicker="TRIVY · REMOVE · IRREVERSIBLE"
This removes the managed Trivy binary. Vulnerability scanning will stop working until title="Remove Trivy"
Trivy is reinstalled or a host binary is provided. confirmLabel="Remove"
</AlertDialogDescription> onConfirm={handleUninstallTrivy}
</AlertDialogHeader> >
<AlertDialogFooter> <p className="text-sm text-stat-subtitle">
<AlertDialogCancel>Cancel</AlertDialogCancel> Removes the managed Trivy binary. Vulnerability scanning stops working until Trivy is reinstalled or a host binary is provided.
<AlertDialogAction </p>
onClick={handleUninstallTrivy} </ConfirmModal>
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
); );
} }