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:
@@ -3,7 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import { Button } from "@/components/ui";
|
||||
import { Button, useToast } from "@/components/ui";
|
||||
|
||||
/*
|
||||
* A tag is key:value, so the chip shows both halves with the key dimmed — the
|
||||
@@ -12,6 +12,7 @@ import { Button } from "@/components/ui";
|
||||
|
||||
export function TagChips({ serverId, tags, editable = false }: { serverId: string; tags?: Record<string, string>; editable?: boolean }) {
|
||||
const queryClient = useQueryClient();
|
||||
const toast = useToast();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState<[string, string][]>(Object.entries(tags ?? {}));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -35,7 +36,11 @@ export function TagChips({ serverId, tags, editable = false }: { serverId: strin
|
||||
queryClient.invalidateQueries({ queryKey: ["server-tags"] });
|
||||
setEditing(false);
|
||||
setError(null);
|
||||
toast.success("Tags saved.");
|
||||
},
|
||||
// Kept inline as well as being an editor that stays open: a rejected tag
|
||||
// is a correction to make in the rows still on screen (bad character,
|
||||
// too long, reserved sys: prefix), not a notice to read afterwards.
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { api, ServerWithKeys } from "@/lib/api";
|
||||
import { Badge, Button, Card, Table, Tbody, Td, Th, Thead, Tr } from "@/components/ui";
|
||||
import { Badge, Button, Card, ConfirmDialog, Table, Tbody, Td, Th, Thead, Tr } from "@/components/ui";
|
||||
|
||||
/*
|
||||
* Everything that changes what is installed on the machine: its OS packages,
|
||||
@@ -18,23 +18,21 @@ export function MaintenanceTab({
|
||||
latestVersion,
|
||||
onApplyUpdates,
|
||||
isApplying,
|
||||
applySuccess,
|
||||
onUpdateAgent,
|
||||
isUpdatingAgent,
|
||||
updateAgentSuccess,
|
||||
onDelete,
|
||||
isDeleting,
|
||||
deleteError,
|
||||
}: {
|
||||
server: ServerWithKeys;
|
||||
latestVersion?: string;
|
||||
onApplyUpdates: () => void;
|
||||
isApplying: boolean;
|
||||
applySuccess: boolean;
|
||||
onUpdateAgent: () => void;
|
||||
isUpdatingAgent: boolean;
|
||||
updateAgentSuccess: boolean;
|
||||
onDelete: () => void;
|
||||
isDeleting: boolean;
|
||||
deleteError?: string | null;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
@@ -87,7 +85,7 @@ export function MaintenanceTab({
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 border-t border-border px-6 py-4">
|
||||
<Button variant="primary" loading={isApplying} onClick={onApplyUpdates} disabled={server.status !== "active"} title={server.status !== "active" ? "Agent must be online to apply updates" : undefined}>
|
||||
{applySuccess ? "Sent!" : "Apply updates"}
|
||||
Apply updates
|
||||
</Button>
|
||||
<p className="text-xs text-text-tertiary">Upgrade runs in the background and may take several minutes.</p>
|
||||
</div>
|
||||
@@ -135,7 +133,7 @@ export function MaintenanceTab({
|
||||
disabled={server.status !== "active"}
|
||||
title={server.status !== "active" ? "Agent must be online to update" : undefined}
|
||||
>
|
||||
{updateAgentSuccess ? "Update sent!" : "Update agent"}
|
||||
Update agent
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -148,23 +146,41 @@ export function MaintenanceTab({
|
||||
<p className="text-sm text-text-secondary">
|
||||
Deletes this server and its history from Vantage. The agent stays installed on the machine and keeps trying to connect until you uninstall it there.
|
||||
</p>
|
||||
{!confirmDelete ? (
|
||||
<Button variant="danger" onClick={() => setConfirmDelete(true)}>
|
||||
Remove server
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-sm text-danger">Remove {server.hostname}?</span>
|
||||
<Button variant="danger" loading={isDeleting} onClick={onDelete}>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Button variant="danger" onClick={() => setConfirmDelete(true)}>
|
||||
Remove server
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/*
|
||||
* Typing the hostname, rather than a second danger button in the
|
||||
* same place the first one was. The inline two-step armed and
|
||||
* confirmed under the same pointer, so a double click on
|
||||
* "Remove server" deleted the machine and its whole history
|
||||
* without the operator reading which machine it was.
|
||||
*/}
|
||||
<ConfirmDialog
|
||||
open={confirmDelete}
|
||||
title="Remove server"
|
||||
confirmLabel="Remove server"
|
||||
requireTyped={server.hostname}
|
||||
loading={isDeleting}
|
||||
error={deleteError}
|
||||
onClose={() => setConfirmDelete(false)}
|
||||
onConfirm={onDelete}
|
||||
body={
|
||||
<>
|
||||
<p>
|
||||
<span className="font-mono text-text-primary">{server.hostname}</span> and its history — keys,
|
||||
inventory, workflow runs and findings — are removed from Vantage.
|
||||
</p>
|
||||
<p>
|
||||
The agent stays installed on the machine and keeps trying to connect until you uninstall it
|
||||
there.
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, type AuthProvider, type AuthPreset } from "@/lib/api";
|
||||
import { Button, Card } from "@/components/ui";
|
||||
import { Button, Card, ConfirmDialog, friendlyMessage, useToast } from "@/components/ui";
|
||||
import { Field, inputClass } from "./Field";
|
||||
import { SectionCard } from "./SectionCard";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
@@ -42,6 +42,7 @@ function CallbackRow({ url }: { url: string }) {
|
||||
}
|
||||
|
||||
function AddProviderForm({ presets, onDone }: { presets: AuthPreset[]; onDone: () => void }) {
|
||||
const toast = useToast();
|
||||
const [preset, setPreset] = useState<string>("google");
|
||||
const [name, setName] = useState("");
|
||||
const [issuerInput, setIssuerInput] = useState("");
|
||||
@@ -60,7 +61,11 @@ function AddProviderForm({ presets, onDone }: { presets: AuthPreset[]; onDone: (
|
||||
client_secret: clientSecret,
|
||||
enabled: true,
|
||||
}),
|
||||
onSuccess: onDone,
|
||||
onSuccess: () => {
|
||||
toast.success(`${name || chosen?.label || "Provider"} added. Register its callback URL with your identity provider before signing in.`);
|
||||
onDone();
|
||||
},
|
||||
// Error stays inline on the form: a rejected issuer is corrected here.
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -116,28 +121,46 @@ function AddProviderForm({ presets, onDone }: { presets: AuthPreset[]; onDone: (
|
||||
|
||||
function ProviderRow({ p }: { p: AuthProvider }) {
|
||||
const queryClient = useQueryClient();
|
||||
const toast = useToast();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [secret, setSecret] = useState("");
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ["auth-providers"] });
|
||||
|
||||
const { mutate: update, error: updateError } = useMutation({
|
||||
mutationFn: (patch: Parameters<typeof api.updateAuthProvider>[1]) => api.updateAuthProvider(p.provider_id, patch),
|
||||
onSuccess: () => {
|
||||
onSuccess: (_data, patch) => {
|
||||
setSecret("");
|
||||
invalidate();
|
||||
// The lockout guard answers 409 here — the last way in cannot be
|
||||
// switched off — so the outcome of this toggle is worth stating
|
||||
// rather than leaving to a checkbox that may have sprung back.
|
||||
toast.success(patch.client_secret ? `Client secret updated for ${p.name}.` : patch.enabled ? `${p.name} enabled.` : `${p.name} disabled.`);
|
||||
},
|
||||
});
|
||||
const { mutate: remove, error: deleteError } = useMutation({
|
||||
const {
|
||||
mutate: remove,
|
||||
isPending: removing,
|
||||
error: deleteError,
|
||||
} = useMutation({
|
||||
mutationFn: () => api.deleteAuthProvider(p.provider_id),
|
||||
onSuccess: invalidate,
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
toast.success(`Removed ${p.name}.`);
|
||||
setConfirming(false);
|
||||
},
|
||||
});
|
||||
const { mutate: test, isPending: testing } = useMutation({
|
||||
mutationFn: () => api.testAuthProvider(p.provider_id),
|
||||
// Stays inline: this one carries a diagnostic worth re-reading against
|
||||
// the fields beside it, which is not what a toast that expires is for.
|
||||
onSuccess: setTestResult,
|
||||
onError: toast.error,
|
||||
});
|
||||
const { mutate: ack } = useMutation({
|
||||
mutationFn: () => api.ackAuthProviderNotice(p.provider_id),
|
||||
onSuccess: invalidate,
|
||||
onError: toast.error,
|
||||
});
|
||||
|
||||
const error = (updateError ?? deleteError) as Error | null;
|
||||
@@ -198,10 +221,36 @@ function ProviderRow({ p }: { p: AuthProvider }) {
|
||||
<Button type="button" variant="ghost" size="sm" loading={testing} onClick={() => test()}>
|
||||
Test connection
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => remove()}>
|
||||
Remove
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setConfirming(true)}>
|
||||
Remove<span className="sr-only"> {p.name}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
* One click used to remove a working sign-in method for everyone in
|
||||
* the instance. Typed, unlike a channel: the client secret is not
|
||||
* recoverable from here afterwards, so putting this back means
|
||||
* going to the identity provider for a new one.
|
||||
*/}
|
||||
<ConfirmDialog
|
||||
open={confirming}
|
||||
title="Remove sign-in provider"
|
||||
confirmLabel="Remove provider"
|
||||
requireTyped={p.name}
|
||||
loading={removing}
|
||||
error={deleteError ? friendlyMessage(deleteError) : null}
|
||||
onClose={() => setConfirming(false)}
|
||||
onConfirm={() => remove()}
|
||||
body={
|
||||
<>
|
||||
<p>
|
||||
<span className="font-mono text-text-primary">{p.name}</span> is removed and anyone signing in
|
||||
through it loses that route into this instance.
|
||||
</p>
|
||||
<p>The stored client secret goes with it; restoring this provider means issuing a new one.</p>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, Workflow } from "@/lib/api";
|
||||
import { Button } from "@/components/ui";
|
||||
import { Button, useToast } from "@/components/ui";
|
||||
|
||||
/*
|
||||
* Presets write cron underneath rather than being their own storage format:
|
||||
@@ -23,6 +23,7 @@ const ZONES = ["UTC", "Europe/London", "Europe/Berlin", "America/New_York", "Ame
|
||||
|
||||
export function ScheduleCard({ workflow }: { workflow: Workflow }) {
|
||||
const queryClient = useQueryClient();
|
||||
const toast = useToast();
|
||||
const [enabled, setEnabled] = useState(workflow.schedule?.enabled ?? false);
|
||||
const [cron, setCron] = useState(workflow.schedule?.cron ?? "0 2 * * 0");
|
||||
const [tz, setTz] = useState(workflow.schedule?.tz ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC");
|
||||
@@ -42,7 +43,10 @@ export function ScheduleCard({ workflow }: { workflow: Workflow }) {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["workflows"] });
|
||||
setError(null);
|
||||
toast.success(enabled ? "Schedule saved. The next run is shown below." : "Schedule saved and disabled.");
|
||||
},
|
||||
// Inline, beside the cron field: a rejected expression or zone name is
|
||||
// corrected here, and this editor does not close on failure.
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button, Card } from "@/components/ui";
|
||||
import { Button, Card, useToast } from "@/components/ui";
|
||||
import { workloads, type Workload, type WorkloadAction, type WorkloadKind } from "@/lib/api";
|
||||
import { WorkloadRow } from "./WorkloadRow";
|
||||
import { LogDialog } from "./LogDialog";
|
||||
@@ -37,9 +37,17 @@ function group(list: Workload[]) {
|
||||
};
|
||||
}
|
||||
|
||||
/* The agent has acted by the time the call returns, so these read as done
|
||||
* rather than as sent — unlike an OS update, which it only accepts. */
|
||||
const ACTION_PAST: Record<WorkloadAction, string> = {
|
||||
start: "Started",
|
||||
stop: "Stopped",
|
||||
restart: "Restarted",
|
||||
};
|
||||
|
||||
export function WorkloadList({ serverId, canControl }: { serverId: string; canControl: boolean }) {
|
||||
const qc = useQueryClient();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const toast = useToast();
|
||||
const [logTarget, setLogTarget] = useState<Workload | null>(null);
|
||||
|
||||
const snapshot = useQuery({
|
||||
@@ -51,20 +59,23 @@ export function WorkloadList({ serverId, canControl }: { serverId: string; canCo
|
||||
mutationFn: () => workloads.refresh(serverId),
|
||||
// The refresh returns no data — the agent reports through the normal
|
||||
// path, so the only correct move is to refetch the stored document.
|
||||
onSuccess: () => {
|
||||
setError(null);
|
||||
setTimeout(() => qc.invalidateQueries({ queryKey: ["workloads", serverId] }), 1500);
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
onSuccess: () => setTimeout(() => qc.invalidateQueries({ queryKey: ["workloads", serverId] }), 1500),
|
||||
// Silent when it works: this fires on every panel open, and a toast
|
||||
// saying so on arrival is noise about something nobody asked for.
|
||||
onError: toast.error,
|
||||
});
|
||||
|
||||
const control = useMutation({
|
||||
mutationFn: ({ w, action }: { w: Workload; action: WorkloadAction }) => workloads.control(serverId, w.kind as WorkloadKind, w.id, action),
|
||||
onSuccess: () => {
|
||||
setError(null);
|
||||
onSuccess: (_data, { w, action }) => {
|
||||
qc.invalidateQueries({ queryKey: ["workloads", serverId] });
|
||||
toast.success(`${ACTION_PAST[action]} ${w.name}.`);
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
// Toasted rather than banner-ed. The 409 for a protected workload — the
|
||||
// agent refusing to stop itself — is the one an operator most needs to
|
||||
// read, and it arrives from a button that may be scrolled well away
|
||||
// from where the banner sat.
|
||||
onError: toast.error,
|
||||
});
|
||||
|
||||
// Opening the panel asks for a fresh list: this page carries a Restart
|
||||
@@ -101,8 +112,6 @@ export function WorkloadList({ serverId, canControl }: { serverId: string; canCo
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4">
|
||||
{error && <p className="mb-3 text-sm text-danger">{error}</p>}
|
||||
|
||||
{snapshot.isLoading ? (
|
||||
<p className="text-sm text-text-secondary">Loading…</p>
|
||||
) : !data ? (
|
||||
|
||||
Reference in New Issue
Block a user