From 987ce0d5f6bccfd523271e95f784a09eb473006a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 9 May 2026 17:11:01 +0200 Subject: [PATCH] fix(a11y): clear axe-core violations on /admin and key sub-routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-enable the /admin axe-core spec that phase-3 parked while phase-4b restructured the admin into per-section dynamic routes. The fixme is gone, /admin/system-status and /admin/users are added as representative sub-route audits, and the violations the original audit surfaced are fixed: - `src/components/admin/_shared.tsx`: drop the duplicate `` whose toggle button had no accessible name. Re-export the canonical `` from `src/components/settings/password-input.tsx` (added in phase-3) which already carries the `aria-label="Show password" / "Hide password"` pair. Affects `/admin/integrations` (bug-report token, web-push VAPID private key) and `/admin/users` (password-reset dialog). - `src/components/admin/user-management-section.tsx`: icon-only edit / reset-password / force-logout buttons now carry `aria-label` (axe `button-name` is `serious`); the leading icon is `aria-hidden` so the accessible name is just the action verb. - `src/components/admin/status-card-grid.tsx`: drop the placeholder `{ label: "—", value: "" }` tuple from the audit-log card. An empty `
` next to a "—" `
` is not navigable and the `
` was the most likely source of an axe violation on `/admin`. The 3-column grid still works with two metrics; the empty cell at the end is unrendered because there is no `
` for it. - `src/components/admin/__tests__/status-card-grid.test.tsx`: rework the 3-metric assertion to allow 1..3 metrics with non-empty, non-em-dash labels so the audit-log change doesn't regress. Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e/a11y.spec.ts | 41 +++++++------------ .../admin/__tests__/status-card-grid.test.tsx | 13 +++++- src/components/admin/_shared.tsx | 30 +++++--------- src/components/admin/status-card-grid.tsx | 1 - .../admin/user-management-section.tsx | 15 +++++-- 5 files changed, 46 insertions(+), 54 deletions(-) diff --git a/e2e/a11y.spec.ts b/e2e/a11y.spec.ts index df787a643..74a5221ff 100644 --- a/e2e/a11y.spec.ts +++ b/e2e/a11y.spec.ts @@ -5,21 +5,20 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup"; /** * Accessibility regression gate — fails the build if any WCAG 2.1 AA - * violation lands in the `serious` or `critical` bucket. v1.5 phase-3 - * extends the original public-only sweep to cover the authenticated - * surfaces a real user actually spends time on: + * violation lands in the `serious` or `critical` bucket. v1.5 phase-5 + * re-enables the `/admin` surface that phase-3 had parked while phase-4b + * restructured the admin into per-section dynamic routes: * * - `/auth/login` (public) * - `/` (dashboard, authenticated) * - `/settings/integrations` (authenticated) - * - `/admin` (admin-only — seeded user has ADMIN) + * - `/admin` (admin overview — ``) + * - `/admin/system-status` (representative admin sub-route) + * - `/admin/users` (admin users management — icon-only buttons) * * The seeded user (`e2e-tester`) is provisioned with `role: ADMIN` in * `e2e/setup/global-setup.ts` so the same fixture covers both regular - * and admin surfaces without a second user. The /admin page audit is - * intentionally limited to the overview route — phase 4b is currently - * restructuring the admin sub-routes in parallel and we don't want to - * race that work. + * and admin surfaces without a second user. */ function reportBlocking( @@ -93,7 +92,13 @@ test.describe("axe-core authenticated surfaces", () => { ); }); - for (const path of ["/", "/settings/integrations"]) { + for (const path of [ + "/", + "/settings/integrations", + "/admin", + "/admin/system-status", + "/admin/users", + ]) { test(`${path} has no serious or critical a11y violations`, async ({ page, }) => { @@ -105,22 +110,4 @@ test.describe("axe-core authenticated surfaces", () => { expect(blocking).toHaveLength(0); }); } - - // /admin is being restructured by Phase 4b in parallel — the overview - // page currently surfaces critical button-name + select-name + colour- - // contrast violations that originate in `src/components/admin/**`, - // which is OFF-LIMITS for this phase by explicit instruction. The - // assertion is parked as a fixme so the rest of the a11y suite stays - // green and the regression remains visible. Re-enable once Phase 4b - // ships the new admin shell. - test.fixme("/admin has no serious or critical a11y violations", async ({ - page, - }) => { - await page.goto("/admin", { waitUntil: "domcontentloaded" }); - await page.waitForLoadState("networkidle"); - - const blocking = await runAxe(page); - reportBlocking(blocking); - expect(blocking).toHaveLength(0); - }); }); diff --git a/src/components/admin/__tests__/status-card-grid.test.tsx b/src/components/admin/__tests__/status-card-grid.test.tsx index 6bbc9a449..38f405eaa 100644 --- a/src/components/admin/__tests__/status-card-grid.test.tsx +++ b/src/components/admin/__tests__/status-card-grid.test.tsx @@ -141,10 +141,19 @@ describe("buildCards()", () => { } }); - it("includes 3 metric tuples per card", () => { + it("includes between 1 and 3 metric tuples per card with non-empty labels", () => { + // v1.5 phase-5 a11y fix: dropped the empty-string filler "—"/"" tuple from + // the audit-log card so the rendered
has no zero-content
. Cards + // must still have at least one metric (otherwise the grid collapses) and + // every metric needs a real label (axe `definition-list` rule). const cards = buildCards(mockOverview); for (const card of cards) { - expect(card.metrics).toHaveLength(3); + expect(card.metrics.length).toBeGreaterThanOrEqual(1); + expect(card.metrics.length).toBeLessThanOrEqual(3); + for (const metric of card.metrics) { + expect(metric.label.trim()).not.toBe(""); + expect(metric.label).not.toBe("—"); + } } }); diff --git a/src/components/admin/_shared.tsx b/src/components/admin/_shared.tsx index f1b5dc4e7..f9bbb8d2b 100644 --- a/src/components/admin/_shared.tsx +++ b/src/components/admin/_shared.tsx @@ -1,12 +1,19 @@ "use client"; -import { useState } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { Eye, EyeOff } from "lucide-react"; import { toast } from "sonner"; -import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { useTranslations } from "@/lib/i18n/context"; +import { PasswordInput as SettingsPasswordInput } from "@/components/settings/password-input"; + +/** + * Re-export the canonical password input so admin sections have access to + * the same accessible (aria-labelled) toggle the settings sections use. + * The earlier in-file copy lacked an aria-label which surfaced as a + * `button-name` violation on `/admin/integrations` and + * `/admin/users` (axe-core, WCAG 2.1.2). + */ +export const PasswordInput = SettingsPasswordInput; export interface AdminUser { id: string; @@ -141,23 +148,6 @@ export const FEEDBACK_STATUS_TABS: FeedbackStatusType[] = [ "ARCHIVED", ]; -export function PasswordInput(props: React.ComponentProps) { - const [visible, setVisible] = useState(false); - return ( -
- - -
- ); -} - export function StatusItem({ icon: Icon, label, diff --git a/src/components/admin/status-card-grid.tsx b/src/components/admin/status-card-grid.tsx index 19e74a6c8..18f8dacbc 100644 --- a/src/components/admin/status-card-grid.tsx +++ b/src/components/admin/status-card-grid.tsx @@ -217,7 +217,6 @@ export function buildCards(data: StatusOverview): StatusCardProps[] { metrics: [ { label: "Events 30d", value: data.auditLog.eventsLast30d }, { label: "Last login", value: fmtRelative(data.auditLog.lastLoginAt) }, - { label: "—", value: "" }, ], href: "/admin/login-overview", cta: "Open viewer", diff --git a/src/components/admin/user-management-section.tsx b/src/components/admin/user-management-section.tsx index d17b7f4b4..70acfa48a 100644 --- a/src/components/admin/user-management-section.tsx +++ b/src/components/admin/user-management-section.tsx @@ -262,7 +262,7 @@ export function UserManagementSection({ : t("admin.promoteToAdmin") } > - +