Skip to content

[UX] customer-portal — Roles & permissions (admin) ​

Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Adalen-Truck/customer-portal · Branch: develop @ 693a76c · Files reviewed: 7 Patterns: none (baseline sufficient; MCP budget unspent)

Summary ​

A read-only admin surface: a roles DataTable and a per-role detail page that renders a permission matrix (PermissionGrid) plus the users in that role. Roles are code-defined, so there is genuinely nothing to save — the whole editing half of PermissionGrid is unreachable on these two routes. The single most important defect is that the matrix conveys each cell's grant state by background colour only: the entire point of the page is invisible to a colour-blind or screen-reader user. The detail page also hand-rolls its header (no <h1>), misreports load failures as "Role not found", and no copy passes through i18n.

Findings ​

1. Permission grid conveys grant state by colour alone — High · A11Y-07 (proposed; state-not-colour-only) ​

Where: apps/web/src/components/admin/PermissionGrid.vue:140-158, :483-493What: Every matrix cell is a <button> whose visible text is just the action name (e.g. read), repeated identically in each column. Whether that permission is granted, restricted, or absent is signalled only by the button's background token — bg-status-ok-bg (green), bg-status-warn-bg (amber), or bg-sunken (grey) in readonly mode (chipClass, :143-148). The accessible name is the same string in all three states; there is no text, icon, or aria conveying status. Why it matters: The core information this page exists to show — which permissions a role holds — is unreadable to anyone who cannot distinguish the colours, and a screen reader announces "read" on a granted and a denied cell alike (WCAG 1.4.1 Use of Colour). This is an admin authorization surface, so misreading it has security consequences. Fix: Add a text/aria-label status per cell ("Machine — read: granted / restricted / not granted"), or a glyph (check / slash / dash) in addition to the colour. In readonly mode the cell need not be a button at all (see finding 3).

2. Detail page has no <h1> and hand-rolls its header — Medium · A11Y-04 ​

Where: apps/web/src/pages/admin/RoleDetailsPage.vue:45-61What: Unlike the list page (which uses @aadalen/ui PageLayout, rendering a real <h1> at PageLayout.vue:103), the detail page builds its own header from plain <div>s — the role name sits in a <div class="text-lg font-semibold"> (:51-53). The route therefore has no heading element at all; the two Cards below use PrimeVue #title slots whose element is unverifiable. Why it matters: A screen-reader user landing on the detail page has no page heading to orient by and cannot jump to it via heading navigation; heading structure is empty. It also violates the repo's own rule ("never hand-roll a page header … These are the ONLY two page shells", apps/web/CLAUDE.md). Fix: Use <PageLayout> (with :back-label + useBackNavigation, which also fixes finding 5) so the role name renders as the <h1>.

3. Readonly matrix is a field of focusable buttons that do nothing — Medium · A11Y-03 ​

Where: apps/web/src/components/admin/PermissionGrid.vue:483-493, :160-176What: In readonly mode the cells are still rendered as real <button type="button"> elements, but toggleCell returns immediately when props.readonly is true (:161). With ~5 actions × N subjects, a keyboard user tabs through dozens of buttons that take focus, show a focus ring, and produce no effect or feedback on activation. Why it matters: Dead interactive controls in the tab order make the page slow and confusing to operate by keyboard, and imply an editability the page does not have. Fix: When readonly, render the cells as non-interactive <span>/status badges (they are already styled as "static badges" per the comment at :142) and drop them from the tab order.

4. A failed or in-flight roles load is reported as "Role not found" — Medium · MSG-03 ​

Where: apps/web/src/pages/admin/RoleDetailsPage.vue:28-31, :98-104What: selectedRole is roles.find(...) ?? null; when the roles collection is still loading or has errored, roles is empty, so selectedRole is null and the page shows a warn Message: "Role not found." The detail page checks usersQuery and metadataQuery states but never reads rolesQuery.isError or rolesQuery.isLoading. Why it matters: A transient fetch failure tells the admin the role does not exist — the wrong diagnosis — with no retry and only a "Back to roles" way out (MSG-03: say what to do next; MSG-04: dead end). On a slow load the same false message flashes before data arrives. Fix: Branch on rolesQuery.isLoading (spinner) and rolesQuery.isError (error + retry) before falling through to the genuine "Role not found" case.

5. No copy passes through i18n — Medium · CONTENT-01 ​

Where: apps/web/src/pages/admin/RolesPermissionsPage.vue:26,30,35,44,49-55; RoleDetailsPage.vue:48-71,77-160; PermissionGrid.vue:457What: Every user-facing string on this feature is an English literal — column headers "Name"/"Description"/"Rules", "Roles & Permissions", "Failed to load roles", "No roles yet", "Back to roles", "Role details", "Role profile", "Users in this role", "No users assigned to this role", "Loading users…", "Loading permission metadata…", "Role not found", the grid's "Entity" header. None use t(). Why it matters: customer-portal ships en/nb and is held to CONTENT-01 per feature (BASELINE.md); an nb admin sees this entire feature in English. Fix: Route all strings through vue-i18n with nb parity.

6. "Back to roles" is a hardcoded push, not history-aware — Low · NAV (see project-level list-state finding) ​

Where: apps/web/src/pages/admin/RoleDetailsPage.vue:55-60What: Back is a PrimeVue Button that calls router.push({ name: 'admin.permissions' }) directly, rather than the mandated PageLayout @back + useBackNavigation. Combined with the project-wide "list state is component-local, no KeepAlive" defect (PROJECT-LEVEL.md), Back returns to a list whose search/scroll has been reset, and ignores the user's actual origin (e.g. arriving from a user's role link). Fix: Adopt PageLayout's back affordance wired to useBackNavigation (folds into finding 2).

Unverified ​

  • A11Y-01 (contrast). The status tokens status-ok/warn-fg on their -bg pairs, and text-text-3 on bg-sunken "none" chips, need a contrast tool.
  • A11Y-06 (responsive). The matrix is a wide <table> in overflow-x-auto (PermissionGrid.vue:452); the detail page's lg:grid-cols-[1.2fr,1fr] two-card layout needs a rendered short/narrow viewport to confirm.
  • A11Y-02 (target size). Matrix chips are px-3 py-1 text-xs — plausibly under 24px tall; needs measurement.
  • MSG-01 / A11Y-04 (PrimeVue internals). Whether PrimeVue Message carries role="alert" and what element Card #title renders cannot be read — node_modules absent (see PROJECT-LEVEL standing caveat).
  • NAV-03 — fails project-wide (one static document title), see PROJECT-LEVEL.md; not re-filed here.

Notes for the reviewer (not findings) ​

  • The editing half of PermissionGrid (SelectButton effect toggle, InputChips field restrictions, the AND/OR condition tree, raw-JSON mode) renders only when readonly is false. Both routes pass readonly, and there is no create/edit route, so the forms/checkbox and data-display/tree-view patterns in the queue have no reachable surface on this feature as shipped. Not audited as live UI.
  • The list page avoids the project-level DataTable status-interpolation defect: it passes a clean error-message and no status, so errorStateDescription stays undefined rather than rendering "Status error". Recorded so the alignment pass does not over-claim. (Side effect: the 403 forbiddenMessage branch is unreachable here, so an admin who lost access mid-session sees the generic "Failed to load roles" — minor.)
  • Last-admin / offboarding hypotheses (PROJECT-LEVEL "does archive revoke?" and "is there a last-admin guard?"): N/A on this feature. Roles are code-defined and read-only here — the UI cannot demote the last admin or archive anyone; role membership is not edited on these routes. The revocation question lives in #34 User administration, already answered PASS project-wide.

Baseline additions ​

  • A11Y-07 (state not colour-only) — selection/grant/status conveyed to the user must not rely on colour alone; provide text, icon, or aria. (Already proposed under the A11Y-07 collision in PROJECT-LEVEL.md; this feature is a clear instance.) Renumber per orchestrator.

Cross-project note ​

The colour-only status pattern (finding 1) is the customer-portal instance of the cross-project A11Y-07e / "selection state not colour-only" theme already tracked for playout, members and tt-time-tracker. Any role/permission matrix or status-chip grid in the other three should be checked the same way. CONTENT-01 (finding 5) is per-feature in customer-portal and playout only.