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 - )} + {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. + + + + + ) : ( + + ))} ); 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 && ( - - )} + {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? + + + + ) : ( + + ))} ))} 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()} - - - + ))} 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}

}
- - - + @@ -384,11 +384,9 @@ export default function MonitorDetailPage() { ))} )} - - - +
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

- - - - - - + +
@@ -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.

- - - + ) : ( <> 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 }) { + + 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 - + 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() { - - - + ))} 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 ( - - + + ); } @@ -104,14 +130,12 @@ function ServersPageBody() { {servers?.length ?? 0} registered server{servers?.length !== 1 ? "s" : ""}

- - - + @@ -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. + + ) : ( + Never + )} - - - + ))} @@ -186,11 +218,9 @@ function ServersPageBody() {

No servers registered yet.

- - - + )} 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() { }>
- - - - - - + +

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() {

- - - - - - + +
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() { {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/ui/Async.tsx b/web/components/ui/Async.tsx new file mode 100644 index 0000000..e5c0542 --- /dev/null +++ b/web/components/ui/Async.tsx @@ -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 ( + + + ); +} + +export function CenteredSpinner({ label }: { label?: string }) { + return ( +
+ +
+ ); +} + +/* + * 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 ( +