feat(web): typed confirmations on destructive actions; toasts for API outcomes
Chart Release / chart (push) Successful in 22s
Server Deploy / deploy (push) Successful in 48s

Destructive actions were confirmed by a second danger button rendered where
the first one had been, so a double click on Remove deleted the thing without
the operator ever reading which thing it was. Servers, monitors, secret keys
and sign-in providers now go through ConfirmDialog with requireTyped, matching
the secret-group delete that already worked this way. Notification channels and
vulnerability alert rules get an untyped dialog: both are a name and a URL and
are rebuilt in a minute, but neither had any confirmation at all, and both
silently stop alerts that nobody misses until an incident goes unannounced.

Mutations otherwise succeeded in silence, or reported into whatever inline
banner the page happened to own. Two failure modes came of that: a modal that
closed on error left the message nowhere to land, and a save that was rejected
left the old values on screen looking exactly like a save that worked
(/settings had no error path at all). Every mutation now reports through the
existing toast context.

Errors stay inline where the surface that raised them is still on screen and
the message is a correction to make in it: form validation, the cron field,
the tag rows, a rejected licence blob, and the workflow designer's autosave,
which is a standing condition rather than an event. Everything else toasts.

Ad-hoc feedback removed in favour of it: the "Sent!" button labels on the
server maintenance tab, the settings "Saved!" flag, the channel test line, and
the steps page's notice/error pair.
This commit is contained in:
2026-08-10 14:26:02 +01:00
parent ef86ef04a1
commit 21238fe707
22 changed files with 393 additions and 125 deletions
@@ -2,7 +2,7 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Badge, Button, Card } from "@/components/ui";
import { Badge, Button, Card, ConfirmDialog, friendlyMessage, useToast } from "@/components/ui";
import { api, vulnerabilities, type Severity, type VulnAlertRule } from "@/lib/api";
import { SEVERITY_ORDER, SeverityBadge } from "./SeverityVisuals";
@@ -20,7 +20,9 @@ const labelClass = "mb-1.5 block text-sm font-medium text-text-secondary";
export function VulnAlertRulesCard() {
const qc = useQueryClient();
const toast = useToast();
const [adding, setAdding] = useState(false);
const [confirming, setConfirming] = useState<VulnAlertRule | null>(null);
const rules = useQuery({ queryKey: ["vuln-rules"], queryFn: () => vulnerabilities.listRules() });
const channels = useQuery({ queryKey: ["channels"], queryFn: () => api.listChannels() });
@@ -29,7 +31,11 @@ export function VulnAlertRulesCard() {
const remove = useMutation({
mutationFn: (id: string) => vulnerabilities.deleteRule(id),
onSuccess: invalidate,
onSuccess: (_data, id) => {
invalidate();
toast.success(`Deleted ${rules.data?.find((r) => r.id === id)?.name ?? "the rule"}.`);
setConfirming(null);
},
});
const toggle = useMutation({
@@ -41,7 +47,11 @@ export function VulnAlertRulesCard() {
tags: r.tags,
channel_ids: r.channel_ids,
}),
onSuccess: invalidate,
onSuccess: (_data, r) => {
invalidate();
toast.success(r.enabled ? `${r.name} disabled.` : `${r.name} enabled.`);
},
onError: toast.error,
});
const channelName = (id: string) => channels.data?.find((c) => c.channel_id === id)?.name ?? id;
@@ -91,20 +101,41 @@ export function VulnAlertRulesCard() {
<Button size="sm" variant="ghost" onClick={() => toggle.mutate(r)}>
{r.enabled ? "Disable" : "Enable"}
</Button>
<Button size="sm" variant="ghost" onClick={() => remove.mutate(r.id)}>
Delete
<Button size="sm" variant="ghost" onClick={() => setConfirming(r)}>
Delete<span className="sr-only"> {r.name}</span>
</Button>
</div>
</li>
))}
</ul>
)}
{/* Untyped, like the channel it points at: a rule is four fields
and is rebuilt in a minute. The confirmation is here because
deleting one silently stops alerts nobody then misses until a
critical finding goes unreported. */}
<ConfirmDialog
open={confirming !== null}
title="Delete alert rule"
confirmLabel="Delete rule"
loading={remove.isPending}
error={remove.error ? friendlyMessage(remove.error) : null}
onClose={() => setConfirming(null)}
onConfirm={() => confirming && remove.mutate(confirming.id)}
body={
<p>
<span className="font-mono text-text-primary">{confirming?.name}</span> is deleted. Findings at or
above {confirming?.min_severity} stop being announced through its channels.
</p>
}
/>
</Card>
);
}
function RuleForm({ onDone }: { onDone: () => void }) {
const qc = useQueryClient();
const toast = useToast();
const channels = useQuery({ queryKey: ["channels"], queryFn: () => api.listChannels() });
const [name, setName] = useState("");
@@ -123,8 +154,10 @@ function RuleForm({ onDone }: { onDone: () => void }) {
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["vuln-rules"] });
toast.success(`Created ${name.trim()}.`);
onDone();
},
// Error stays inline under the form, which does not close on failure.
});
const canSave = name.trim().length > 0 && selected.length > 0 && !create.isPending;