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
+172
View File
@@ -0,0 +1,172 @@
"use client";
import { clsx } from "clsx";
import { Button } from "./Button";
/*
* Twenty-three copies of the same spinner div existed across app/ and
* components/, each with the loading / error / empty branch rewritten by hand
* beside it. They had already drifted: some said "Failed to load servers. Is
* the backend running?", some rendered the raw exception message, some showed
* nothing at all while a list was empty.
*/
export function Spinner({ className, label = "Loading" }: { className?: string; label?: string }) {
return (
<span role="status" className="inline-flex items-center">
<span
className={clsx("inline-block animate-spin rounded-full border-2 border-border border-t-accent", className ?? "h-8 w-8")}
aria-hidden="true"
/>
<span className="sr-only">{label}</span>
</span>
);
}
export function CenteredSpinner({ label }: { label?: string }) {
return (
<div className="flex items-center justify-center py-20">
<Spinner label={label} />
</div>
);
}
/*
* A skeleton rather than a spinner wherever the shape of what is coming is
* already known: the table does not collapse and re-expand, so the page stops
* jumping under the pointer as data lands.
*/
export function TableSkeleton({ rows = 5, columns = 4 }: { rows?: number; columns?: number }) {
return (
<div className="animate-pulse p-4" aria-hidden="true">
{Array.from({ length: rows }).map((_, r) => (
<div key={r} className="flex gap-4 border-b border-border/40 py-3 last:border-0">
{Array.from({ length: columns }).map((_, c) => (
<div
key={c}
className="h-3 rounded bg-surface-2"
style={{ width: `${[28, 20, 16, 12, 10, 8][c % 6]}%` }}
/>
))}
</div>
))}
</div>
);
}
export function EmptyState({
title,
description,
icon,
action,
}: {
title: string;
description?: string;
icon?: React.ReactNode;
action?: { label: string; href?: string; onClick?: () => void };
}) {
return (
<div className="px-6 py-16 text-center">
{icon && (
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-surface-2 text-text-secondary">
{icon}
</div>
)}
<p className="text-[15px] font-semibold text-text-primary">{title}</p>
{description && <p className="mx-auto mt-2 max-w-[46ch] text-sm text-text-secondary">{description}</p>}
{action &&
(action.href ? (
<Button href={action.href} variant="primary" size="sm" className="mt-4">
{action.label}
</Button>
) : (
<Button variant="primary" size="sm" className="mt-4" onClick={action.onClick}>
{action.label}
</Button>
))}
</div>
);
}
export function ErrorState({ error, onRetry }: { error: unknown; onRetry?: () => void }) {
return (
<div className="px-6 py-16 text-center" role="alert">
<p className="text-[15px] font-semibold text-text-primary">{friendlyMessage(error)}</p>
{detailOf(error) && <p className="mx-auto mt-2 max-w-[52ch] font-mono text-xs text-text-secondary">{detailOf(error)}</p>}
{onRetry && (
<Button variant="secondary" size="sm" className="mt-4" onClick={onRetry}>
Try again
</Button>
)}
</div>
);
}
/**
* One loading/error/empty decision instead of the same ternary chain rewritten
* in every list page. `isEmpty` is passed rather than inferred, because only
* the caller knows whether an empty array is empty or simply filtered to
* nothing.
*/
export function AsyncBoundary({
isLoading,
error,
isEmpty,
onRetry,
skeleton,
empty,
children,
}: {
isLoading: boolean;
error?: unknown;
isEmpty?: boolean;
onRetry?: () => void;
skeleton?: React.ReactNode;
empty?: React.ReactNode;
children: React.ReactNode;
}) {
if (isLoading) return <>{skeleton ?? <CenteredSpinner />}</>;
if (error) return <ErrorState error={error} onRetry={onRetry} />;
if (isEmpty && empty) return <>{empty}</>;
return <>{children}</>;
}
/*
* Backend messages went straight to the screen. Some are written for an
* operator and are the most useful thing available; some are a Go error string
* or a bare "Failed to fetch" from a dropped connection, which tells the
* customer nothing and reads as a crash. Classify first, then show the detail
* underneath rather than instead of an explanation.
*/
export function friendlyMessage(error: unknown): string {
const status = (error as { status?: number } | null)?.status;
const raw = error instanceof Error ? error.message : typeof error === "string" ? error : "";
// The backend writes its 4xx messages for an operator and they are usually
// the most specific thing available ("default steps cannot be edited",
// "vulnerability scanning is not licensed"). Keep them; only replace the
// ones that are a status code wearing a coat.
const useful = raw && !/^HTTP \d{3}$/.test(raw) && !/^[A-Z][a-z]+( [A-Z]?[a-z]+)*$/.test(raw) ? raw : "";
if (status === 401) return "Your session has expired. Sign in again to continue.";
if (status === 403) return useful || "You do not have permission to do this.";
if (status === 404) return useful || "That is no longer here.";
if (status === 409) return useful || "That conflicts with the current state.";
if (status === 429) return "Too many requests. Wait a moment and try again.";
if (typeof status === "number" && status >= 500) return "The server could not complete that. Try again shortly.";
if (typeof status === "number" && status >= 400) return useful || "That request was rejected.";
// fetch() rejects with a TypeError and no status when the request never
// reached the server at all.
if (!status && /failed to fetch|networkerror|load failed/i.test(raw)) {
return "Cannot reach the server. Check your connection.";
}
return raw || "Something went wrong.";
}
function detailOf(error: unknown): string | null {
const raw = error instanceof Error ? error.message : null;
if (!raw) return null;
return raw === friendlyMessage(error) ? null : raw;
}
+78 -37
View File
@@ -1,13 +1,32 @@
import { ButtonHTMLAttributes, forwardRef } from "react";
import { AnchorHTMLAttributes, ButtonHTMLAttributes, forwardRef } from "react";
import Link from "next/link";
import { clsx } from "clsx";
type Variant = "primary" | "secondary" | "danger" | "ghost";
type Size = "sm" | "md" | "lg";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
interface CommonProps {
variant?: Variant;
size?: Size;
className?: string;
children?: React.ReactNode;
}
interface ButtonProps extends CommonProps, Omit<ButtonHTMLAttributes<HTMLButtonElement>, keyof CommonProps> {
loading?: boolean;
href?: undefined;
}
interface LinkButtonProps extends CommonProps, Omit<AnchorHTMLAttributes<HTMLAnchorElement>, keyof CommonProps> {
/**
* Renders a next/link styled as this button instead of a <button>.
*
* <Link><Button/></Link> nests an interactive element inside an anchor: the
* markup is invalid, the pair takes two tab stops, and a keyboard Enter fires
* only the outer anchor. Nineteen call sites did that. Pass href here instead.
*/
href: string;
loading?: undefined;
}
/*
@@ -32,45 +51,67 @@ const sizeClasses: Record<Size, string> = {
lg: "px-5 py-2.5 text-base",
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{ variant = "primary", size = "md", loading, className, children, disabled, ...props },
ref
) => {
const baseClasses =
"inline-flex items-center gap-2 rounded border font-semibold no-underline transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 focus:ring-offset-background disabled:opacity-50 disabled:cursor-not-allowed active:translate-y-px";
function classesFor(variant: Variant, size: Size, className?: string) {
return clsx(baseClasses, variantClasses[variant], sizeClasses[size], className);
}
function Spinner() {
return (
<svg
className="h-4 w-4 animate-spin"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
focusable="false"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
);
}
export const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps | LinkButtonProps>(
({ variant = "primary", size = "md", className, children, ...rest }, ref) => {
if (typeof rest.href === "string") {
const { href, ...anchorProps } = rest as LinkButtonProps;
return (
<Link
ref={ref as React.Ref<HTMLAnchorElement>}
href={href}
className={classesFor(variant, size, className)}
{...anchorProps}
>
{children}
</Link>
);
}
const { loading, disabled, ...buttonProps } = rest as ButtonProps;
return (
<button
ref={ref}
ref={ref as React.Ref<HTMLButtonElement>}
disabled={disabled || loading}
className={clsx(
"inline-flex items-center gap-2 rounded border font-semibold transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 focus:ring-offset-background disabled:opacity-50 disabled:cursor-not-allowed active:translate-y-px",
variantClasses[variant],
sizeClasses[size],
className
)}
{...props}
// A control that is busy is still a control; announce it rather than
// leaving a screen reader on the pre-click label with nothing happening.
aria-busy={loading || undefined}
className={classesFor(variant, size, className)}
{...buttonProps}
>
{loading && (
<svg
className="animate-spin h-4 w-4"
xmlns="http://www.w3.instance/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
)}
{loading && <Spinner />}
{children}
</button>
);
+96
View File
@@ -0,0 +1,96 @@
"use client";
import { useEffect, useId, useState } from "react";
import { Button } from "./Button";
import { Modal } from "./Modal";
/*
* Destructive actions used to go through window.confirm(). That dialog is
* browser chrome: it cannot say what is about to be deleted beyond one line of
* plain text, it looks nothing like the product, it cannot show the error when
* the delete then fails, and it offers the same two buttons whether the action
* removes one key or an entire secret group.
*
* `requireTyped` is for the cases with no undo — deleting a secret group, a
* step used by every workflow. Typing the name is not friction for its own
* sake: it is what stops a muscle-memory Enter from destroying something whose
* name the operator never actually read.
*/
export function ConfirmDialog({
open,
title,
body,
confirmLabel = "Delete",
requireTyped,
destructive = true,
loading,
error,
onConfirm,
onClose,
}: {
open: boolean;
title: string;
body: React.ReactNode;
confirmLabel?: string;
/** When set, the confirm button stays disabled until this exact string is typed. */
requireTyped?: string;
destructive?: boolean;
loading?: boolean;
error?: string | null;
onConfirm: () => void;
onClose: () => void;
}) {
const [typed, setTyped] = useState("");
const inputId = useId();
// A reopened dialog must not carry the previous attempt's typing.
useEffect(() => {
if (open) setTyped("");
}, [open]);
const armed = !requireTyped || typed === requireTyped;
return (
<Modal open={open} title={title} onClose={onClose}>
<div className="space-y-4 text-sm text-text-secondary">
<div className="space-y-2">{body}</div>
{requireTyped && (
<div className="space-y-1.5">
<label htmlFor={inputId} className="block text-xs text-text-secondary">
Type <span className="font-mono text-text-primary">{requireTyped}</span> to confirm
</label>
<input
id={inputId}
value={typed}
onChange={(e) => setTyped(e.target.value)}
autoComplete="off"
spellCheck={false}
className="w-full rounded border border-border bg-surface-2 px-3 py-2 font-mono text-sm text-text-primary outline-none focus:border-accent"
/>
</div>
)}
{error && (
<p role="alert" className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-danger">
{error}
</p>
)}
<div className="flex justify-end gap-2 pt-1">
<Button variant="secondary" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button
variant={destructive ? "danger" : "primary"}
onClick={onConfirm}
loading={loading}
disabled={!armed}
>
{confirmLabel}
</Button>
</div>
</div>
</Modal>
);
}
+148 -35
View File
@@ -1,45 +1,158 @@
"use client";
import { useEffect } from "react";
import { useCallback, useEffect, useId, useRef } from "react";
/*
* Dialogs nest: a confirm sits on top of the edit modal that raised it. Both
* listen on document, so without a stack Escape would close the pair at once
* and the trap of the covered dialog would fight the top one for focus. Only
* the last opened panel acts.
*/
const stack: symbol[] = [];
const FOCUSABLE = [
"a[href]",
"button:not([disabled])",
"input:not([disabled]):not([type='hidden'])",
"select:not([disabled])",
"textarea:not([disabled])",
"[tabindex]:not([tabindex='-1'])",
].join(",");
export function Modal({
open,
title,
onClose,
children,
wide,
open,
title,
onClose,
children,
wide,
}: {
open: boolean;
title: string;
onClose: () => void;
children: React.ReactNode;
wide?: boolean;
open: boolean;
title: string;
onClose: () => void;
children: React.ReactNode;
wide?: boolean;
}) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
const panelRef = useRef<HTMLDivElement>(null);
const restoreRef = useRef<HTMLElement | null>(null);
const titleId = useId();
const idRef = useRef<symbol>(Symbol("modal"));
if (!open) return null;
/*
* Everything below is what an accessible dialog owes the person using it,
* and none of it was here: focus stayed on the page behind, Tab walked out
* of the dialog into content the overlay had covered, the background
* scrolled under the panel, and closing left focus on <body> so the next
* Tab restarted from the top of the document.
*/
return (
<div className="fixed inset-0 z-50 flex items-end justify-center p-0 sm:items-center sm:p-4">
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
<div
className={`relative z-10 w-full ${wide ? "sm:max-w-2xl" : "sm:max-w-md"} max-h-[85dvh] overflow-auto rounded rounded-b-none border border-b-0 border-border bg-surface shadow-panel sm:rounded sm:border-b`}
role="dialog"
aria-modal="true"
>
<div className="flex items-center justify-between border-b border-border px-5 py-3">
<h2 className="text-sm font-bold text-text-primary">{title}</h2>
<button onClick={onClose} className="text-text-secondary hover:text-text-primary" aria-label="Close">
</button>
// Escape closes. Tab is confined to the panel.
const onKeyDown = useCallback(
(e: KeyboardEvent) => {
// Only the topmost dialog reacts.
if (stack[stack.length - 1] !== idRef.current) return;
if (e.key === "Escape") {
e.stopPropagation();
onClose();
return;
}
if (e.key !== "Tab" || !panelRef.current) return;
const items = Array.from(panelRef.current.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
(el) => el.offsetParent !== null || el === document.activeElement,
);
if (items.length === 0) {
// Nothing focusable inside; keep focus on the panel rather than
// letting Tab escape to the page underneath.
e.preventDefault();
panelRef.current.focus();
return;
}
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement as HTMLElement | null;
if (e.shiftKey && (active === first || active === panelRef.current)) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
},
[onClose],
);
useEffect(() => {
if (!open) return;
const id = idRef.current;
stack.push(id);
restoreRef.current = document.activeElement as HTMLElement | null;
// The page behind must not scroll while a dialog is over it. Padding
// replaces the scrollbar's width so the layout does not jump sideways
// as it disappears.
const { body } = document;
const prevOverflow = body.style.overflow;
const prevPadding = body.style.paddingRight;
const gap = window.innerWidth - document.documentElement.clientWidth;
body.style.overflow = "hidden";
if (gap > 0) body.style.paddingRight = `${gap}px`;
document.addEventListener("keydown", onKeyDown, true);
// Focus the first real control, falling back to the panel itself. The
// close button is deliberately not preferred: opening a dialog on its
// dismiss control reads as "are you sure you want to be here".
const target =
panelRef.current?.querySelector<HTMLElement>(FOCUSABLE) ?? panelRef.current;
target?.focus();
return () => {
const at = stack.lastIndexOf(id);
if (at !== -1) stack.splice(at, 1);
document.removeEventListener("keydown", onKeyDown, true);
// Each dialog restores what it found, so an inner one closing over
// an outer one puts back "hidden" rather than releasing the page.
body.style.overflow = prevOverflow;
body.style.paddingRight = prevPadding;
// Return focus to whatever opened the dialog, if it is still there.
if (restoreRef.current?.isConnected) restoreRef.current.focus();
};
}, [open, onKeyDown]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-end justify-center p-0 sm:items-center sm:p-4">
<div className="absolute inset-0 bg-black/60" onClick={onClose} aria-hidden="true" />
<div
ref={panelRef}
tabIndex={-1}
className={`relative z-10 w-full ${wide ? "sm:max-w-2xl" : "sm:max-w-md"} max-h-[85dvh] overflow-auto rounded rounded-b-none border border-b-0 border-border bg-surface shadow-panel focus:outline-none sm:rounded sm:border-b`}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<div className="flex items-center justify-between border-b border-border px-5 py-3">
<h2 id={titleId} className="text-sm font-bold text-text-primary">
{title}
</h2>
<button
type="button"
onClick={onClose}
className="rounded p-1 text-text-secondary transition-colors hover:bg-surface-2 hover:text-text-primary"
aria-label="Close dialog"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
<div className="p-5">{children}</div>
</div>
</div>
<div className="p-5">{children}</div>
</div>
</div>
);
);
}
+140
View File
@@ -0,0 +1,140 @@
"use client";
import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
import { clsx } from "clsx";
import { friendlyMessage } from "./Async";
/*
* Mutations succeeded silently. Copying an install one-liner, generating a key,
* restarting a container, rotating the ESO token — all of them changed
* something and said nothing, so the only way to know it worked was to watch
* for the list to redraw. Failures were worse: each page wired its own
* `onError: setError` into its own inline div, so an error raised by a modal
* that then closed had nowhere to land at all.
*
* No dependency for this. It is a context, a list and a fixed div; sonner would
* be 12KB to render three lines of text in a palette we would then have to
* override anyway.
*/
type ToastKind = "success" | "error" | "info";
interface Toast {
id: number;
kind: ToastKind;
message: string;
}
interface ToastApi {
success: (message: string) => void;
info: (message: string) => void;
/** Accepts a thrown value directly, so call sites do not each re-derive a message. */
error: (error: unknown) => void;
}
const ToastContext = createContext<ToastApi | null>(null);
const DURATION: Record<ToastKind, number> = {
// An error stays four times as long as a confirmation: it is the one the
// reader has to act on, and it may be the only record of what failed.
success: 4000,
info: 5000,
error: 12000,
};
const KIND_CLASSES: Record<ToastKind, string> = {
success: "border-success/40 text-success",
error: "border-danger/40 text-danger",
info: "border-accent/40 text-accent",
};
const KIND_LABEL: Record<ToastKind, string> = {
success: "Success",
error: "Error",
info: "Note",
};
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const nextId = useRef(1);
const timers = useRef(new Map<number, ReturnType<typeof setTimeout>>());
const dismiss = useCallback((id: number) => {
const timer = timers.current.get(id);
if (timer) {
clearTimeout(timer);
timers.current.delete(id);
}
setToasts((list) => list.filter((t) => t.id !== id));
}, []);
const push = useCallback(
(kind: ToastKind, message: string) => {
const id = nextId.current++;
// Cap the stack. A mutation looping on a failing endpoint would
// otherwise paper over the screen with the same sentence.
setToasts((list) => [...list.slice(-2), { id, kind, message }]);
timers.current.set(
id,
setTimeout(() => dismiss(id), DURATION[kind]),
);
},
[dismiss],
);
const api = useMemo<ToastApi>(
() => ({
success: (message) => push("success", message),
info: (message) => push("info", message),
error: (error) => push("error", friendlyMessage(error)),
}),
[push],
);
return (
<ToastContext.Provider value={api}>
{children}
<div
className="pointer-events-none fixed inset-x-0 bottom-0 z-[60] flex flex-col items-center gap-2 p-4 sm:items-end"
// Announce without stealing focus. Errors are assertive because
// they usually mean the thing the operator asked for did not
// happen; a success can wait for a pause in speech.
aria-live="polite"
aria-atomic="false"
>
{toasts.map((t) => (
<div
key={t.id}
role={t.kind === "error" ? "alert" : "status"}
className={clsx(
"pointer-events-auto flex w-full max-w-sm items-start gap-3 rounded border bg-surface px-4 py-3 text-sm shadow-panel",
KIND_CLASSES[t.kind],
)}
>
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-current" aria-hidden="true" />
<span className="min-w-0 flex-1 break-words text-text-primary">
<span className="sr-only">{KIND_LABEL[t.kind]}: </span>
{t.message}
</span>
<button
type="button"
onClick={() => dismiss(t.id)}
aria-label="Dismiss notification"
className="shrink-0 rounded p-0.5 text-text-secondary transition-colors hover:text-text-primary"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast(): ToastApi {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToast must be used inside <ToastProvider>");
return ctx;
}
+11
View File
@@ -3,4 +3,15 @@ export { Badge } from "./Badge";
export { Card, CardHeader, CardTitle } from "./Card";
export { Table, Thead, Tbody, Tr, Th, Td } from "./Table";
export { Modal } from "./Modal";
export { ConfirmDialog } from "./ConfirmDialog";
export { Pagination, usePagination, PAGE_SIZES } from "./Pagination";
export {
AsyncBoundary,
CenteredSpinner,
EmptyState,
ErrorState,
Spinner,
TableSkeleton,
friendlyMessage,
} from "./Async";
export { ToastProvider, useToast } from "./Toast";