feat(web): typed confirmations on destructive actions; toasts for API outcomes
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user