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:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user