mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(a11y): clear axe-core violations on /admin and key sub-routes
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
`<PasswordInput>` whose toggle button had no accessible name.
Re-export the canonical `<PasswordInput>` 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
`<dd>` next to a "—" `<dt>` is not navigable and the `<dl>` 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 `<div>` 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) <noreply@anthropic.com>
This commit is contained in:
1 parent
265095b774
commit
987ce0d5f6
5 files changed
+46
-54
No files matched your search
+14
-27
@@ -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 — `<StatusCardGrid>`)
|
||||
* - `/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);
|
||||
});
|
||||
});
|
||||
@@ -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 <dl> has no zero-content <dd>. 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("—");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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<typeof Input>) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input {...props} type={visible ? "text" : "password"} />
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onClick={() => setVisible((v) => !v)}
|
||||
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-3 -translate-y-1/2"
|
||||
>
|
||||
{visible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -262,7 +262,7 @@ export function UserManagementSection({
|
||||
: t("admin.promoteToAdmin")
|
||||
}
|
||||
>
|
||||
<Shield className="mr-1 h-3 w-3" />
|
||||
<Shield className="mr-1 h-3 w-3" aria-hidden="true" />
|
||||
{u.role === "ADMIN"
|
||||
? t("admin.toUser")
|
||||
: t("admin.toAdmin")}
|
||||
@@ -273,8 +273,9 @@ export function UserManagementSection({
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => startEdit(u)}
|
||||
title={t("admin.editUser")}
|
||||
aria-label={t("admin.editUser")}
|
||||
>
|
||||
<Pencil className="mr-1 h-3 w-3" />
|
||||
<Pencil className="h-3 w-3" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -282,8 +283,9 @@ export function UserManagementSection({
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => startReset(u)}
|
||||
title={t("admin.resetPassword")}
|
||||
aria-label={t("admin.resetPassword")}
|
||||
>
|
||||
<KeyRound className="mr-1 h-3 w-3" />
|
||||
<KeyRound className="h-3 w-3" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -296,8 +298,13 @@ export function UserManagementSection({
|
||||
? t("admin.section.users.cannotLogoutSelf")
|
||||
: t("admin.section.users.forceLogout")
|
||||
}
|
||||
aria-label={
|
||||
u.id === currentUserId
|
||||
? t("admin.section.users.cannotLogoutSelf")
|
||||
: t("admin.section.users.forceLogout")
|
||||
}
|
||||
>
|
||||
<LogOut className="mr-1 h-3 w-3" />
|
||||
<LogOut className="h-3 w-3" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
Reference in new issue
Block a user