diff --git a/web/app/(app)/audit/page.tsx b/web/app/(app)/audit/page.tsx index 0d7e7de..cdb1bb9 100644 --- a/web/app/(app)/audit/page.tsx +++ b/web/app/(app)/audit/page.tsx @@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { api, AuditEvent } from "@/lib/api"; -import { Card } from "@/components/ui"; +import { AsyncBoundary, Card, EmptyState, TableSkeleton } from "@/components/ui"; import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui"; const EVENT_LABELS: Record = { @@ -42,7 +42,7 @@ function EventTypeBadge({ type }: { type: string }) { } export default function AuditPage() { - const { data: events, isLoading, error } = useQuery({ + const { data: events, isLoading, error, refetch } = useQuery({ queryKey: ["audit"], queryFn: () => api.listAuditEvents(200), refetchInterval: 30_000, @@ -58,13 +58,19 @@ export default function AuditPage() { - {isLoading ? ( -
-
-
- ) : error ? ( -
Failed to load audit log.
- ) : events && events.length > 0 ? ( + } + isEmpty={!events || events.length === 0} + empty={ + + } + > @@ -75,7 +81,7 @@ export default function AuditPage() { - {events.map((e: AuditEvent) => ( + {events?.map((e: AuditEvent) => (
@@ -95,11 +101,7 @@ export default function AuditPage() { ))}
- ) : ( -
-

No audit events recorded yet.

-
- )} +
); diff --git a/web/app/(app)/keys/page.tsx b/web/app/(app)/keys/page.tsx index 3f1e323..68939b3 100644 --- a/web/app/(app)/keys/page.tsx +++ b/web/app/(app)/keys/page.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import Link from "next/link"; import { api, Key } from "@/lib/api"; -import { Badge, Button, Card, CardHeader, CardTitle } from "@/components/ui"; +import { AsyncBoundary, Badge, Button, Card, CardHeader, CardTitle, EmptyState, TableSkeleton } from "@/components/ui"; import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui"; function UploadKeyModal({ onClose }: { onClose: () => void }) { @@ -100,6 +100,7 @@ export default function KeysPage() { data: keys, isLoading, error, + refetch, } = useQuery({ queryKey: ["keys"], queryFn: api.listKeys, @@ -125,13 +126,29 @@ export default function KeysPage() { - {isLoading ? ( -
-
-
- ) : error ? ( -
Failed to load keys. Is the backend running?
- ) : keys && keys.length > 0 ? ( + } + isEmpty={!keys || keys.length === 0} + empty={ +
); diff --git a/web/app/(app)/secrets/page.tsx b/web/app/(app)/secrets/page.tsx index 4f68ee0..5f85d00 100644 --- a/web/app/(app)/secrets/page.tsx +++ b/web/app/(app)/secrets/page.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import Link from "next/link"; import { api, SecretGroupSummary } from "@/lib/api"; -import { Button, Card } from "@/components/ui"; +import { AsyncBoundary, Button, Card, EmptyState, TableSkeleton } from "@/components/ui"; import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui"; const inputClass = @@ -93,7 +93,7 @@ function NewGroupModal({ onClose }: { onClose: () => void }) { export default function SecretsPage() { const [showNew, setShowNew] = useState(false); - const { data: groups, isLoading, error } = useQuery({ + const { data: groups, isLoading, error, refetch } = useQuery({ queryKey: ["secret-groups"], queryFn: api.listSecretGroups, }); @@ -118,15 +118,25 @@ export default function SecretsPage() { - {isLoading ? ( -
-
-
- ) : error ? ( -
- Failed to load secrets. Is the backend running? -
- ) : groups && groups.length > 0 ? ( + } + isEmpty={!groups || groups.length === 0} + empty={ +
); diff --git a/web/app/(app)/steps/page.tsx b/web/app/(app)/steps/page.tsx index df30c27..5902eff 100644 --- a/web/app/(app)/steps/page.tsx +++ b/web/app/(app)/steps/page.tsx @@ -3,7 +3,7 @@ import { useMemo, useRef, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { api, WorkflowStep } from "@/lib/api"; -import { Button, Card } from "@/components/ui"; +import { AsyncBoundary, Button, Card, EmptyState, TableSkeleton } from "@/components/ui"; import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui"; import { EditStepModal } from "@/components/workflows/EditStepModal"; @@ -18,7 +18,7 @@ function ShellBadge({ interpreter }: { interpreter: "bash" | "powershell" }) { export default function StepsPage() { const qc = useQueryClient(); - const { data: steps, isLoading, error: loadError } = useQuery({ queryKey: ["steps"], queryFn: api.listSteps }); + const { data: steps, isLoading, error: loadError, refetch } = useQuery({ queryKey: ["steps"], queryFn: api.listSteps }); const { data: usage } = useQuery({ queryKey: ["step-usage"], queryFn: api.stepUsage }); const [search, setSearch] = useState(""); @@ -134,13 +134,34 @@ export default function StepsPage() { - {isLoading ? ( -
-
-
- ) : loadError ? ( -
Failed to load steps. Is the backend running?
- ) : rows.length > 0 ? ( + } + isEmpty={rows.length === 0} + empty={ + // Filtered to nothing and owning nothing are different + // situations and want different ways out. + steps && steps.length > 0 ? ( + + ) : ( + {error}
} - {isLoading ? ( -
-
-
- ) : loadError ? ( -
Failed to load workflows. Is the backend running?
- ) : workflows && workflows.length > 0 ? ( + } + isEmpty={!workflows || workflows.length === 0} + empty={ +
); diff --git a/web/components/ui/Modal.tsx b/web/components/ui/Modal.tsx index a09e4b2..e7e768b 100644 --- a/web/components/ui/Modal.tsx +++ b/web/components/ui/Modal.tsx @@ -20,6 +20,9 @@ const stack: symbol[] = []; let lockCount = 0; let lockedOverflow = ""; let lockedPadding = ""; +let hidden: HTMLElement[] = []; + +const PORTAL_ATTR = "data-vantage-dialog"; function lockScroll() { const { body } = document; @@ -31,6 +34,18 @@ function lockScroll() { const gap = window.innerWidth - document.documentElement.clientWidth; body.style.overflow = "hidden"; if (gap > 0) body.style.paddingRight = `${gap}px`; + + /* + * aria-modal is a claim, not a mechanism. Portalled to the body, the + * app tree is a plain sibling of the dialog, so a screen reader's + * virtual cursor happily browses the page underneath — which is the + * exact thing the overlay exists to prevent. Hiding the siblings is + * what makes the claim true. + */ + hidden = Array.from(body.children).filter( + (el): el is HTMLElement => el instanceof HTMLElement && !el.hasAttribute(PORTAL_ATTR), + ); + for (const el of hidden) el.setAttribute("aria-hidden", "true"); } lockCount++; } @@ -40,6 +55,8 @@ function unlockScroll() { if (lockCount === 0) { document.body.style.overflow = lockedOverflow; document.body.style.paddingRight = lockedPadding; + for (const el of hidden) el.removeAttribute("aria-hidden"); + hidden = []; } } @@ -93,7 +110,11 @@ export function Modal({ useEffect(() => setMounted(true), []); useEffect(() => { - if (!open) return; + // `mounted` is a dependency, not just a guard: on the first client + // render it is false and the component returns null, so a Modal that + // mounts already open would run this against null refs and never take + // focus at all. + if (!open || !mounted) return; const id = idRef.current; stack.push(id); @@ -162,7 +183,7 @@ export function Modal({ if (restoreRef.current?.isConnected) restoreRef.current.focus(); restoreRef.current = null; }; - }, [open]); + }, [open, mounted]); if (!open || !mounted) return null; @@ -172,7 +193,7 @@ export function Modal({ * not part of the content it covers. */ return createPortal( -
+