mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-30 12:09:15 +00:00
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:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user