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 <Link><Button> 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.
This commit is contained in:
2026-08-10 09:25:53 +01:00
parent d559cccd44
commit 1fa9160c59
20 changed files with 1029 additions and 221 deletions
+4 -5
View File
@@ -164,11 +164,10 @@ export default function KeysPage() {
<span className="text-text-secondary text-xs">{new Date(key.created_at).toLocaleDateString()}</span>
</Td>
<Td>
<Link href={`/keys/${key.key_id}`}>
<Button variant="ghost" size="sm">
View
</Button>
</Link>
<Button href={`/keys/${key.key_id}`} variant="ghost" size="sm">
View <span aria-hidden="true"></span>
<span className="sr-only">{key.label}</span>
</Button>
</Td>
</Tr>
))}
+6 -8
View File
@@ -283,9 +283,9 @@ export default function MonitorDetailPage() {
{monitor.state.message && <p className="mt-1.5 text-sm text-text-secondary">{monitor.state.message}</p>}
</div>
<div className="flex flex-wrap gap-2">
<Link href={`/monitors/${monitorId}/edit`}>
<Button variant="secondary">Edit</Button>
</Link>
<Button href={`/monitors/${monitorId}/edit`} variant="secondary">
Edit
</Button>
<Button variant="secondary" loading={isToggling} onClick={() => toggleEnabled(!monitor.enabled)}>
{monitor.enabled ? "Pause checks" : "Resume checks"}
</Button>
@@ -384,11 +384,9 @@ export default function MonitorDetailPage() {
))}
</dl>
)}
<Link href="/settings/notifications">
<Button variant="secondary" size="sm" className="mt-4">
Manage channels
</Button>
</Link>
<Button href="/settings/notifications" variant="secondary" size="sm" className="mt-4">
Manage channels
</Button>
</Panel>
<div className="rounded-lg border border-border bg-surface px-5 py-4">
+9 -11
View File
@@ -138,12 +138,12 @@ export default function MonitorsPage() {
<h1 className="mt-1 text-2xl font-bold tracking-tight text-text-primary">Monitors</h1>
</div>
<div className="flex gap-2">
<Link href="/settings/notifications">
<Button variant="secondary">Notification channels</Button>
</Link>
<Link href="/monitors/new">
<Button variant="primary">New monitor</Button>
</Link>
<Button href="/settings/notifications" variant="secondary">
Notification channels
</Button>
<Button href="/monitors/new" variant="primary">
New monitor
</Button>
</div>
</div>
@@ -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.
</p>
<Link href="/monitors/new">
<Button variant="primary" className="mt-4">
Add your first check
</Button>
</Link>
<Button href="/monitors/new" variant="primary" className="mt-4">
Add your first check
</Button>
</div>
) : (
<>
+91 -29
View File
@@ -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<string | null>(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 }) {
<Button
variant="ghost"
size="sm"
loading={removing}
className="text-danger hover:text-danger"
onClick={() => {
if (confirm(`Delete key "${secret.key}"?`)) remove();
}}
onClick={() => setConfirming(true)}
>
Delete
Delete<span className="sr-only"> {secret.key}</span>
</Button>
</div>
<ConfirmDialog
open={confirming}
title="Delete key"
confirmLabel="Delete key"
loading={removing}
error={removeError ? friendlyMessage(removeError) : null}
onClose={() => setConfirming(false)}
onConfirm={() => remove()}
body={
<>
<p>
<span className="font-mono text-text-primary">{secret.key}</span> will be removed from the{" "}
<span className="font-mono text-text-primary">{group}</span> group.
</p>
<p>Anything reading this key a workflow step, an External Secrets sync starts failing at its next run.</p>
</>
}
/>
</Td>
</Tr>
);
@@ -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() {
</svg>
ExternalSecret YAML
</Button>
<Button
variant="ghost"
className="text-danger hover:text-danger"
loading={deleting}
onClick={() => {
if (confirm(`Delete the entire "${group}" group and all its keys?`)) deleteGroup();
}}
>
<Button variant="ghost" className="text-danger hover:text-danger" onClick={() => setConfirmingGroup(true)}>
Delete Group
</Button>
</div>
</div>
<ConfirmDialog
open={confirmingGroup}
title="Delete secret group"
confirmLabel="Delete group"
// No undo, and the blast radius is every consumer of the group
// rather than one key — so the name has to be typed.
requireTyped={group}
loading={deleting}
error={deleteError ? friendlyMessage(deleteError) : null}
onClose={() => setConfirmingGroup(false)}
onConfirm={() => deleteGroup()}
body={
<>
<p>
This deletes <span className="font-mono text-text-primary">{group}</span> and all{" "}
{data ? `${data.secrets.length} of its keys` : "of its keys"}. The values cannot be recovered.
</p>
<p>
Every workflow step referencing this group, and any External Secrets sync reading{" "}
<span className="font-mono">/api/secrets/{group}/values</span>, fails at its next run.
</p>
</>
}
/>
<div className="space-y-6">
<AddKeyCard group={group} />
<Card padding={false}>
{isLoading ? (
<div className="flex items-center justify-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
) : error ? (
<div className="py-20 text-center text-danger">Failed to load group. It may have been deleted.</div>
) : data && data.secrets.length > 0 ? (
<AsyncBoundary
isLoading={isLoading}
error={error}
onRetry={refetch}
skeleton={<TableSkeleton columns={4} />}
isEmpty={!data || data.secrets.length === 0}
empty={<EmptyState title="This group has no keys yet." description="Add one above and it becomes available to workflow steps and External Secrets straight away." />}
>
<Table>
<Thead>
<Tr>
@@ -296,14 +360,12 @@ export default function SecretGroupPage() {
</Tr>
</Thead>
<Tbody>
{data.secrets.map((s: Secret) => (
{data?.secrets.map((s: Secret) => (
<SecretRow key={s.key} group={group} secret={s} />
))}
</Tbody>
</Table>
) : (
<div className="py-16 text-center text-text-secondary">This group has no keys. Add one above.</div>
)}
</AsyncBoundary>
</Card>
</div>
</div>
+4 -3
View File
@@ -153,9 +153,10 @@ export default function SecretsPage() {
</span>
</Td>
<Td>
<Link href={`/secrets/${encodeURIComponent(g.group)}`}>
<Button variant="ghost" size="sm">View </Button>
</Link>
<Button href={`/secrets/${encodeURIComponent(g.group)}`} variant="ghost" size="sm">
View <span aria-hidden="true"></span>
<span className="sr-only">{g.group}</span>
</Button>
</Td>
</Tr>
))}
+56 -26
View File
@@ -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<DotStatus, string> = {
ok: "OK",
};
const DOT_TEXT: Record<DotStatus, string> = {
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<DotStatus, string> = {
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 (
<span title={DOT_LABELS[status]} className="flex items-center">
<span className={`inline-block h-2.5 w-2.5 rounded-full ${DOT_CLASSES[status]}`} />
<span className={`inline-flex items-center gap-2 whitespace-nowrap ${DOT_TEXT[status]}`}>
<span className={`inline-block h-2.5 w-2.5 shrink-0 rounded-full ${DOT_CLASSES[status]}`} aria-hidden="true" />
<span className="font-mono text-[0.65rem] uppercase tracking-[0.08em]" aria-hidden="true">
{DOT_SHORT[status]}
</span>
<span className="sr-only">{DOT_LABELS[status]}</span>
</span>
);
}
@@ -104,14 +130,12 @@ function ServersPageBody() {
{servers?.length ?? 0} registered server{servers?.length !== 1 ? "s" : ""}
</p>
</div>
<Link href="/servers/new">
<Button variant="primary">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Add Server
</Button>
</Link>
<Button href="/servers/new" variant="primary">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Add Server
</Button>
</div>
<TagFilterBar value={selected} onChange={setSelected} />
@@ -161,18 +185,26 @@ function ServersPageBody() {
<StatusDot status={resolveStatus(server, latestVersion)} />
</Td>
<Td label="Last Seen">
<span className="text-text-secondary">
{server.last_seen
? formatLastSeen(server.last_seen)
: "Never"}
</span>
{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.
<time
dateTime={server.last_seen}
title={new Date(server.last_seen).toLocaleString()}
className="text-text-secondary"
>
{formatLastSeen(server.last_seen)}
</time>
) : (
<span className="text-text-secondary">Never</span>
)}
</Td>
<Td>
<Link href={`/servers/${server.server_id}`}>
<Button variant="ghost" size="sm">
View
</Button>
</Link>
<Button href={`/servers/${server.server_id}`} variant="ghost" size="sm">
View <span aria-hidden="true"></span>
<span className="sr-only">{server.hostname}</span>
</Button>
</Td>
</Tr>
))}
@@ -186,11 +218,9 @@ function ServersPageBody() {
</svg>
</div>
<p className="text-text-secondary">No servers registered yet.</p>
<Link href="/servers/new">
<Button variant="primary" size="sm" className="mt-4">
Add your first server
</Button>
</Link>
<Button href="/servers/new" variant="primary" size="sm" className="mt-4">
Add your first server
</Button>
</div>
)}
</Card>
+6 -6
View File
@@ -229,12 +229,12 @@ export default function SettingsPage() {
<Group label="Monitoring">
<SectionCard title="Alerting" description="Alerts are delivered through notification channels, triggered by service monitors and by servers going offline." icon={<BellIcon />}>
<div className="flex flex-wrap gap-3">
<Link href="/settings/notifications">
<Button variant="secondary">Manage notification channels</Button>
</Link>
<Link href="/monitors">
<Button variant="ghost">View monitors</Button>
</Link>
<Button href="/settings/notifications" variant="secondary">
Manage notification channels
</Button>
<Button href="/monitors" variant="ghost">
View monitors
</Button>
</div>
<p className="mt-4 text-xs text-text-tertiary">
Webhook, email (SMTP), Discord, Slack, and Telegram destinations are configured under Notification Channels and attached per monitor.
+7 -10
View File
@@ -113,16 +113,13 @@ export default function WorkflowsPage() {
</Td>
<Td>
<div className="flex items-center justify-end gap-2">
<Link href={`/workflows/${w.workflow_id}/runs`}>
<Button variant="ghost" size="sm">
Runs
</Button>
</Link>
<Link href={`/workflows/${w.workflow_id}`}>
<Button variant="ghost" size="sm">
Open
</Button>
</Link>
<Button href={`/workflows/${w.workflow_id}/runs`} variant="ghost" size="sm">
Runs<span className="sr-only"> for {w.name}</span>
</Button>
<Button href={`/workflows/${w.workflow_id}`} variant="ghost" size="sm">
Open <span aria-hidden="true"></span>
<span className="sr-only">{w.name}</span>
</Button>
</div>
</Td>
</Tr>