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
+45 -9
View File
@@ -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 (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
@@ -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<Member | null>(null);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState<Role>("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() {
<Button
variant="ghost"
size="sm"
onClick={() => {
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<span className="sr-only"> {u.email}</span>
</Button>
)
)}
@@ -170,6 +187,25 @@ export function MembersCard() {
</Table>
)}
<ConfirmDialog
open={removing !== null}
title="Remove member"
confirmLabel="Remove member"
loading={isRemoving}
error={removeError ? friendlyMessage(removeError) : null}
onClose={() => setRemoving(null)}
onConfirm={() => removing && removeUser(removing)}
body={
<>
<p>
<span className="text-text-primary">{removing?.email}</span> loses access to this instance immediately, including any
open session.
</p>
<p>Their audit history is kept. Adding them again later creates a new member.</p>
</>
}
/>
<Modal open={addOpen} title="Add member" onClose={() => setAddOpen(false)}>
<form
onSubmit={(e) => {