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") } > - +