From 1fa9160c5923ac7697f8ae4ce51214fa85c96088 Mon Sep 17 00:00:00 2001
From: mrhid6
Date: Mon, 10 Aug 2026 09:25:53 +0100
Subject: [PATCH] fix(web,adminsite): accessible dialogs, real confirmations,
shared async UI
Four correctness/accessibility defects and the destructive-action flow.
- Button: the loading spinner carried xmlns="http://www.w3.instance/2000/svg",
a find/replace of "org" that landed inside a URL. Button also grows an href
form, because nested a button inside an anchor at nineteen
call sites: invalid markup, two tab stops, and Enter firing only the anchor.
- Fleet status was four meanings carried by hue with the distinction living in
a title attribute, which touch never shows and screen readers need not
announce. It now carries a text label and an accessible name, which is the
one rule the design system states outright.
- Modal had no focus management at all: no trap, no initial focus, no restore,
no scroll lock, no aria-labelledby. Dialogs nest (a confirm over an edit), so
a stack decides which panel owns Escape and Tab.
- Seven destructive actions went through window.confirm(). ConfirmDialog
replaces them and can say what is about to happen; deleting a secret group,
a shared base step or a workflow now requires typing the name, since those
have no undo and a wide blast radius. adminsite keeps its own inline idiom
rather than importing a dialog system it does not have.
Adds Toast, AsyncBoundary/EmptyState/ErrorState/TableSkeleton and
friendlyMessage, replacing per-page loading ternaries and raw
(error as Error).message text. Wired here only where a call site was already
being edited; the remaining pages follow.
---
.../app/(customer)/users/InvitePanel.tsx | 67 +++++--
adminsite/components/MembersPanel.tsx | 57 ++++--
web/app/(app)/keys/page.tsx | 9 +-
web/app/(app)/monitors/[id]/page.tsx | 14 +-
web/app/(app)/monitors/page.tsx | 20 +-
web/app/(app)/secrets/[group]/page.tsx | 120 +++++++++---
web/app/(app)/secrets/page.tsx | 7 +-
web/app/(app)/servers/page.tsx | 82 +++++---
web/app/(app)/settings/page.tsx | 12 +-
web/app/(app)/workflows/page.tsx | 17 +-
web/components/Providers.tsx | 5 +-
web/components/settings/MembersCard.tsx | 54 +++++-
web/components/ui/Async.tsx | 172 ++++++++++++++++
web/components/ui/Button.tsx | 115 +++++++----
web/components/ui/ConfirmDialog.tsx | 96 +++++++++
web/components/ui/Modal.tsx | 183 ++++++++++++++----
web/components/ui/Toast.tsx | 140 ++++++++++++++
web/components/ui/index.ts | 11 ++
web/components/workflows/EditStepModal.tsx | 36 +++-
.../workflows/EditWorkflowModal.tsx | 33 +++-
20 files changed, 1029 insertions(+), 221 deletions(-)
create mode 100644 web/components/ui/Async.tsx
create mode 100644 web/components/ui/ConfirmDialog.tsx
create mode 100644 web/components/ui/Toast.tsx
diff --git a/adminsite/app/(customer)/users/InvitePanel.tsx b/adminsite/app/(customer)/users/InvitePanel.tsx
index 08b1711..4e95500 100644
--- a/adminsite/app/(customer)/users/InvitePanel.tsx
+++ b/adminsite/app/(customer)/users/InvitePanel.tsx
@@ -24,6 +24,7 @@ export function InvitePanel() {
const [email, setEmail] = useState("");
const [role, setRole] = useState("member");
const [error, setError] = useState(null);
+ const [confirming, setConfirming] = useState(null);
const users = useQuery({ queryKey: ["account-users"], queryFn: api.accountUsers });
const refresh = () => qc.invalidateQueries({ queryKey: ["account-users"] });
@@ -46,8 +47,14 @@ export function InvitePanel() {
});
const remove = useMutation({
mutationFn: (id: string) => api.removeAccountUser(id),
- onSuccess: refresh,
- onError: fail,
+ onSuccess: () => {
+ setConfirming(null);
+ refresh();
+ },
+ onError: (e) => {
+ setConfirming(null);
+ fail(e);
+ },
});
if (users.error instanceof NotConnected) return ;
@@ -175,22 +182,46 @@ export function InvitePanel() {
: "Invitation pending"}
- {canManage && !isSelf && (
- {
- if (
- confirm(
- `Remove ${u.email}? They lose access to every instance on this account.`,
- )
- )
- remove.mutate(u.user_id);
- }}
- >
- Remove
-
- )}
+ {canManage &&
+ !isSelf &&
+ /*
+ * Inline rather than window.confirm(): removing
+ * someone here revokes them from every instance
+ * on the account, which is more than the word
+ * "Remove" beside one row implies, and the
+ * browser dialog cannot show the consequence
+ * where the eye already is.
+ */
+ (confirming === u.user_id ? (
+
+
+ Removes access to every instance.
+
+ remove.mutate(u.user_id)}
+ >
+ {remove.isPending ? "Removing…" : "Remove"}
+
+ setConfirming(null)}
+ >
+ Keep
+
+
+ ) : (
+ setConfirming(u.user_id)}
+ >
+ Remove {u.email}
+
+ ))}
);
diff --git a/adminsite/components/MembersPanel.tsx b/adminsite/components/MembersPanel.tsx
index 1cec338..8d9cde9 100644
--- a/adminsite/components/MembersPanel.tsx
+++ b/adminsite/components/MembersPanel.tsx
@@ -18,6 +18,7 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
const [selected, setSelected] = useState("");
const [role, setRole] = useState("member");
const [error, setError] = useState(null);
+ const [confirming, setConfirming] = useState(null);
const members = useQuery({
queryKey: ["members", instanceId],
@@ -44,8 +45,14 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
});
const revoke = useMutation({
mutationFn: (uid: string) => api.revokeMember(instanceId, uid),
- onSuccess: refresh,
- onError: fail,
+ onSuccess: () => {
+ setConfirming(null);
+ refresh();
+ },
+ onError: (e) => {
+ setConfirming(null);
+ fail(e);
+ },
});
const myRole = session?.account_role;
@@ -89,17 +96,41 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
) : (
{m.role}
)}
- {canManage && (
- {
- if (confirm(`Remove ${m.email} from this instance?`)) revoke.mutate(m.customer_user_id);
- }}
- >
- Remove
-
- )}
+ {canManage &&
+ /*
+ * Confirming inline rather than through
+ * window.confirm(), and in the row itself
+ * rather than a dialog: this is the panel's own
+ * idiom, the same one ConfirmPlanChange uses,
+ * and it can say what revoking actually does.
+ */
+ (confirming === m.customer_user_id ? (
+
+ Revoke access?
+ revoke.mutate(m.customer_user_id)}
+ >
+ {revoke.isPending ? "Removing…" : "Remove"}
+
+ setConfirming(null)}>
+ Keep
+
+
+ ) : (
+ {
+ setError(null);
+ setConfirming(m.customer_user_id);
+ }}
+ >
+ Remove {m.email}
+
+ ))}
))}
diff --git a/web/app/(app)/keys/page.tsx b/web/app/(app)/keys/page.tsx
index 26cd967..3f1e323 100644
--- a/web/app/(app)/keys/page.tsx
+++ b/web/app/(app)/keys/page.tsx
@@ -164,11 +164,10 @@ export default function KeysPage() {
{new Date(key.created_at).toLocaleDateString()}
-
-
- View →
-
-
+
+ View →
+ {key.label}
+
))}
diff --git a/web/app/(app)/monitors/[id]/page.tsx b/web/app/(app)/monitors/[id]/page.tsx
index 2b08573..d7c940e 100644
--- a/web/app/(app)/monitors/[id]/page.tsx
+++ b/web/app/(app)/monitors/[id]/page.tsx
@@ -283,9 +283,9 @@ export default function MonitorDetailPage() {
{monitor.state.message && {monitor.state.message}
}
-
-
Edit
-
+
+ Edit
+
toggleEnabled(!monitor.enabled)}>
{monitor.enabled ? "Pause checks" : "Resume checks"}
@@ -384,11 +384,9 @@ export default function MonitorDetailPage() {
))}
)}
-
-
- Manage channels
-
-
+
+ Manage channels
+
diff --git a/web/app/(app)/monitors/page.tsx b/web/app/(app)/monitors/page.tsx
index 62f100d..4547db3 100644
--- a/web/app/(app)/monitors/page.tsx
+++ b/web/app/(app)/monitors/page.tsx
@@ -138,12 +138,12 @@ export default function MonitorsPage() {
Monitors
-
- Notification channels
-
-
- New monitor
-
+
+ Notification channels
+
+
+ New monitor
+
@@ -158,11 +158,9 @@ export default function MonitorsPage() {
Add a check and Vantage records uptime and response time on your interval, opens an incident when it fails, and tells the
channels you pick.
-
-
- Add your first check
-
-
+
+ Add your first check
+
) : (
<>
diff --git a/web/app/(app)/secrets/[group]/page.tsx b/web/app/(app)/secrets/[group]/page.tsx
index 1fdf4c9..37b497f 100644
--- a/web/app/(app)/secrets/[group]/page.tsx
+++ b/web/app/(app)/secrets/[group]/page.tsx
@@ -5,7 +5,18 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { api, Secret } from "@/lib/api";
-import { Button, Card, CardHeader, CardTitle } from "@/components/ui";
+import {
+ AsyncBoundary,
+ Button,
+ Card,
+ CardHeader,
+ CardTitle,
+ ConfirmDialog,
+ EmptyState,
+ TableSkeleton,
+ friendlyMessage,
+ useToast,
+} from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
const inputClass =
@@ -116,17 +127,28 @@ spec:
function SecretRow({ group, secret }: { group: string; secret: Secret }) {
const queryClient = useQueryClient();
+ const toast = useToast();
const [revealed, setRevealed] = useState(null);
const [copied, setCopied] = useState(false);
+ const [confirming, setConfirming] = useState(false);
const { mutate: reveal, isPending: revealing } = useMutation({
mutationFn: () => api.revealSecret(group, secret.key),
onSuccess: (res) => setRevealed(res.value),
+ onError: toast.error,
});
- const { mutate: remove, isPending: removing } = useMutation({
+ const {
+ mutate: remove,
+ isPending: removing,
+ error: removeError,
+ } = useMutation({
mutationFn: () => api.deleteSecret(group, secret.key),
- onSuccess: () => queryClient.invalidateQueries({ queryKey: ["secret-group", group] }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["secret-group", group] });
+ setConfirming(false);
+ toast.success(`Deleted ${secret.key}.`);
+ },
});
async function copy() {
@@ -164,15 +186,31 @@ function SecretRow({ group, secret }: { group: string; secret: Secret }) {
{
- if (confirm(`Delete key "${secret.key}"?`)) remove();
- }}
+ onClick={() => setConfirming(true)}
>
- Delete
+ Delete {secret.key}
+
+ setConfirming(false)}
+ onConfirm={() => remove()}
+ body={
+ <>
+
+ {secret.key} will be removed from the{" "}
+ {group} group.
+
+ Anything reading this key — a workflow step, an External Secrets sync — starts failing at its next run.
+ >
+ }
+ />
);
@@ -225,17 +263,24 @@ export default function SecretGroupPage() {
const router = useRouter();
const queryClient = useQueryClient();
const group = decodeURIComponent(String(params.group));
+ const toast = useToast();
const [showYaml, setShowYaml] = useState(false);
+ const [confirmingGroup, setConfirmingGroup] = useState(false);
- const { data, isLoading, error } = useQuery({
+ const { data, isLoading, error, refetch } = useQuery({
queryKey: ["secret-group", group],
queryFn: () => api.getSecretGroup(group),
});
- const { mutate: deleteGroup, isPending: deleting } = useMutation({
+ const {
+ mutate: deleteGroup,
+ isPending: deleting,
+ error: deleteError,
+ } = useMutation({
mutationFn: () => api.deleteSecretGroup(group),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["secret-groups"] });
+ toast.success(`Deleted the ${group} group.`);
router.push("/secrets");
},
});
@@ -262,30 +307,49 @@ export default function SecretGroupPage() {
ExternalSecret YAML
- {
- if (confirm(`Delete the entire "${group}" group and all its keys?`)) deleteGroup();
- }}
- >
+ setConfirmingGroup(true)}>
Delete Group
+ setConfirmingGroup(false)}
+ onConfirm={() => deleteGroup()}
+ body={
+ <>
+
+ This deletes {group} and all{" "}
+ {data ? `${data.secrets.length} of its keys` : "of its keys"}. The values cannot be recovered.
+
+
+ Every workflow step referencing this group, and any External Secrets sync reading{" "}
+ /api/secrets/{group}/values , fails at its next run.
+
+ >
+ }
+ />
+
- {isLoading ? (
-
- ) : error ? (
- Failed to load group. It may have been deleted.
- ) : data && data.secrets.length > 0 ? (
+ }
+ isEmpty={!data || data.secrets.length === 0}
+ empty={ }
+ >
@@ -296,14 +360,12 @@ export default function SecretGroupPage() {
- {data.secrets.map((s: Secret) => (
+ {data?.secrets.map((s: Secret) => (
))}
- ) : (
- This group has no keys. Add one above.
- )}
+
diff --git a/web/app/(app)/secrets/page.tsx b/web/app/(app)/secrets/page.tsx
index f8c28ad..4f68ee0 100644
--- a/web/app/(app)/secrets/page.tsx
+++ b/web/app/(app)/secrets/page.tsx
@@ -153,9 +153,10 @@ export default function SecretsPage() {
-
- View →
-
+
+ View →
+ {g.group}
+
))}
diff --git a/web/app/(app)/servers/page.tsx b/web/app/(app)/servers/page.tsx
index 3473d41..db7b76a 100644
--- a/web/app/(app)/servers/page.tsx
+++ b/web/app/(app)/servers/page.tsx
@@ -2,7 +2,6 @@
import { Suspense } from "react";
import { useQuery } from "@tanstack/react-query";
-import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { api, Server } from "@/lib/api";
import { Button, Card } from "@/components/ui";
@@ -37,10 +36,37 @@ const DOT_LABELS: Record = {
ok: "OK",
};
+const DOT_TEXT: Record = {
+ offline: "text-danger",
+ "needs-update": "text-warning",
+ "has-package-updates": "text-accent",
+ ok: "text-success",
+};
+
+// Short forms for the desktop column, which is narrow. The full sentence is
+// still the accessible name, so nothing is lost to a screen reader.
+const DOT_SHORT: Record = {
+ offline: "Offline",
+ "needs-update": "Agent stale",
+ "has-package-updates": "Updates",
+ ok: "OK",
+};
+
+/*
+ * The dot alone was the whole control: four meanings carried by hue, with the
+ * distinction living in a `title` a touch user never sees and a screen reader
+ * is not obliged to announce. This is the one rule the design system states
+ * outright — state never reads by colour alone — so the label is now part of
+ * the component rather than something each page remembers to add.
+ */
function StatusDot({ status }: { status: DotStatus }) {
return (
-
-
+
+
+
+ {DOT_SHORT[status]}
+
+ {DOT_LABELS[status]}
);
}
@@ -104,14 +130,12 @@ function ServersPageBody() {
{servers?.length ?? 0} registered server{servers?.length !== 1 ? "s" : ""}
-
-
-
-
-
- Add Server
-
-
+
+
+
+
+ Add Server
+
@@ -161,18 +185,26 @@ function ServersPageBody() {
-
- {server.last_seen
- ? formatLastSeen(server.last_seen)
- : "Never"}
-
+ {server.last_seen ? (
+ // "3d ago" is the useful reading; the exact instant is
+ // what someone correlating an incident needs, so it is on
+ // the element rather than gone.
+
+ {formatLastSeen(server.last_seen)}
+
+ ) : (
+ Never
+ )}
-
-
- View →
-
-
+
+ View →
+ {server.hostname}
+
))}
@@ -186,11 +218,9 @@ function ServersPageBody() {
No servers registered yet.
-
-
- Add your first server
-
-
+
+ Add your first server
+
)}
diff --git a/web/app/(app)/settings/page.tsx b/web/app/(app)/settings/page.tsx
index e824ceb..fd9842a 100644
--- a/web/app/(app)/settings/page.tsx
+++ b/web/app/(app)/settings/page.tsx
@@ -229,12 +229,12 @@ export default function SettingsPage() {
}>
-
- Manage notification channels
-
-
- View monitors
-
+
+ Manage notification channels
+
+
+ View monitors
+
Webhook, email (SMTP), Discord, Slack, and Telegram destinations are configured under Notification Channels and attached per monitor.
diff --git a/web/app/(app)/workflows/page.tsx b/web/app/(app)/workflows/page.tsx
index b870d69..7ecf152 100644
--- a/web/app/(app)/workflows/page.tsx
+++ b/web/app/(app)/workflows/page.tsx
@@ -113,16 +113,13 @@ export default function WorkflowsPage() {
-
-
- Runs
-
-
-
-
- Open →
-
-
+
+ Runs for {w.name}
+
+
+ Open →
+ {w.name}
+
diff --git a/web/components/Providers.tsx b/web/components/Providers.tsx
index b22440c..3a00431 100644
--- a/web/components/Providers.tsx
+++ b/web/components/Providers.tsx
@@ -2,9 +2,12 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "@/lib/query-client";
+import { ToastProvider } from "@/components/ui/Toast";
export function Providers({ children }: { children: React.ReactNode }) {
return (
- {children}
+
+ {children}
+
);
}
diff --git a/web/components/settings/MembersCard.tsx b/web/components/settings/MembersCard.tsx
index 702b4e4..e355763 100644
--- a/web/components/settings/MembersCard.tsx
+++ b/web/components/settings/MembersCard.tsx
@@ -4,12 +4,16 @@ import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, type InstanceUser, type Role } from "@/lib/api";
import { useAuth } from "@/components/AuthProvider";
-import { Badge, Button, Modal, Table, Tbody, Td, Th, Thead, Tr } from "@/components/ui";
+import { Badge, Button, ConfirmDialog, Modal, Table, Tbody, Td, Th, Thead, Tr, friendlyMessage, useToast } from "@/components/ui";
import { Field, inputClass } from "./Field";
import { SectionCard } from "./SectionCard";
const ROLES: Role[] = ["owner", "admin", "member"];
+/** The member a pending removal refers to, carried so the dialog and the
+ * confirmation message name a person rather than a user_id. */
+type Member = { id: string; email: string };
+
function UsersIcon() {
return (
@@ -31,7 +35,9 @@ function roleVariant(role: Role) {
export function MembersCard() {
const queryClient = useQueryClient();
const { user } = useAuth();
+ const toast = useToast();
const [addOpen, setAddOpen] = useState(false);
+ const [removing, setRemoving] = useState(null);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState("member");
@@ -48,6 +54,7 @@ export function MembersCard() {
mutationFn: () => api.createInstanceUser({ email, password, role }),
onSuccess: () => {
invalidate();
+ toast.success(`Added ${email} as ${role}.`);
setAddOpen(false);
setEmail("");
setPassword("");
@@ -63,12 +70,23 @@ export function MembersCard() {
onError: invalidate,
});
- const { mutate: removeUser, error: removeError } = useMutation({
- mutationFn: (userId: string) => api.deleteInstanceUser(userId),
- onSuccess: invalidate,
+ const {
+ mutate: removeUser,
+ isPending: isRemoving,
+ error: removeError,
+ } = useMutation({
+ mutationFn: (member: Member) => api.deleteInstanceUser(member.id),
+ onSuccess: (_data, member) => {
+ invalidate();
+ toast.success(`Removed ${member.email}.`);
+ setRemoving(null);
+ },
});
- const actionError = (roleError ?? removeError) as Error | null;
+ // Removal failures are shown inside the confirm dialog that raised them, so
+ // only the inline role change lands here — otherwise the same sentence
+ // appears twice on screen.
+ const actionError = roleError as Error | null;
const isOwner = user?.role === "owner";
const assignableRoles = isOwner ? ROLES : ROLES.filter((r) => r !== "owner");
@@ -154,11 +172,10 @@ export function MembersCard() {
{
- if (confirm(`Remove ${u.email} from this instance?`)) removeUser(u.user_id);
- }}
+ className="text-danger hover:text-danger"
+ onClick={() => setRemoving({ id: u.user_id, email: u.email })}
>
- Remove
+ Remove {u.email}
)
)}
@@ -170,6 +187,25 @@ export function MembersCard() {
)}
+ setRemoving(null)}
+ onConfirm={() => removing && removeUser(removing)}
+ body={
+ <>
+
+ {removing?.email} loses access to this instance immediately, including any
+ open session.
+
+ Their audit history is kept. Adding them again later creates a new member.
+ >
+ }
+ />
+
setAddOpen(false)}>
);
}
diff --git a/web/components/workflows/EditWorkflowModal.tsx b/web/components/workflows/EditWorkflowModal.tsx
index ffe65df..64827af 100644
--- a/web/components/workflows/EditWorkflowModal.tsx
+++ b/web/components/workflows/EditWorkflowModal.tsx
@@ -4,7 +4,7 @@ import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { api, Workflow } from "@/lib/api";
-import { Button, Modal } from "@/components/ui";
+import { Button, ConfirmDialog, Modal, friendlyMessage, useToast } from "@/components/ui";
import { ScheduleCard } from "./ScheduleCard";
import { DualListBox } from "./DualListBox";
@@ -20,6 +20,8 @@ export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open:
const [tagRows, setTagRows] = useState<[string, string][]>(Object.entries(workflow.target_tags ?? {}));
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
+ const [confirmingDelete, setConfirmingDelete] = useState(false);
+ const toast = useToast();
const { data: servers } = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() });
const { data: knownTags } = useQuery({ queryKey: ["server-tags"], queryFn: () => api.listKnownTags(), staleTime: 60_000 });
@@ -42,23 +44,25 @@ export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open:
target_tags: Object.fromEntries(tagRows.filter(([k, v]) => k && v)),
});
onSaved(updated);
+ toast.success(`Saved ${updated.name}.`);
onClose();
} catch (e) {
- setError((e as Error).message);
+ setError(friendlyMessage(e));
} finally {
setBusy(false);
}
};
const del = async () => {
- if (!window.confirm("Delete this workflow? This cannot be undone.")) return;
setBusy(true);
setError(null);
try {
await api.deleteWorkflow(workflow.workflow_id);
+ toast.success(`Deleted ${workflow.name}.`);
router.push("/workflows");
} catch (e) {
- setError((e as Error).message);
+ setError(friendlyMessage(e));
+ setConfirmingDelete(false);
setBusy(false);
}
};
@@ -154,7 +158,7 @@ export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open:
-
+ setConfirmingDelete(true)}>
Delete workflow
@@ -167,6 +171,25 @@ export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open:
+
+ setConfirmingDelete(false)}
+ onConfirm={del}
+ body={
+ <>
+
+ {workflow.name} and its schedule are removed. Its base steps stay
+ in the library.
+
+ Past runs and their logs are kept, but nothing new can be run from this workflow.
+ >
+ }
+ />
);
}