Skip to content

[UX] customer-portal — Error states ​

Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Adalen-Truck/customer-portal · Branch: develop @ 693a76c · Files reviewed: 5 Patterns: user-feedback/empty-states

Summary ​

The 403 surface (ForbiddenPage.vue, route forbidden) is in good shape on the things that matter most: it renders inside AppLayout (full sidebar present), its copy tells the user what to do next ("contact an administrator"), it offers an explicit non-looping route out ("Back to dashboard"), and both en and nb locale keys exist. The real gaps are accessibility: the page exposes no heading element, and the guard's silent redirect here is never announced or focus-managed, so a screen-reader user bounced to /forbidden gets no signal their action was denied. This feature also owns the confirmed project-wide "no catch-all 404 route" gap.

Findings ​

1. Forbidden page has no heading element (no <h1>) — Medium · A11Y-04 ​

Where: apps/web/src/pages/ForbiddenPage.vue:14-34 (+ apps/web/src/layouts/AppLayout.vue:445) What: The only title text ("Access denied") is placed in PrimeVue Card's #title slot, which renders as a <div class="p-card-title">, not a heading. The page template declares no <h1>/<h2>, and AppLayout wraps <RouterView> (AppLayout.vue:445) without supplying one. The page also hand-rolls the header as a bare Card inside a mx-auto max-w-xl wrapper instead of the mandated PageLayout/PageHero shell (apps/web/CLAUDE.md — "these are the ONLY two page shells… no ad-hoc mx-auto max-w-* width caps"), and that shell (PageLayout.vue:103) is exactly what renders the real <h1> elsewhere. Why it matters: A screen-reader user navigating by heading, or scanning page structure, finds nothing marking this page's purpose; the denial reads as unlabelled body text. The empty-states pattern's accessibility guidance is to connect status text with structural headings. Fix: Render the page through PageLayout (title = t('forbidden.title')), or at minimum wrap the title in a real <h1>. This also removes the off-shell mx-auto max-w-xl inconsistency.

2. Denial is silently redirected, never announced or focus-managed — Medium · MSG-01 ​

Where: apps/web/src/router/index.ts:727-728 → apps/web/src/pages/ForbiddenPage.vue:20-23What: The guard returns { name: "forbidden" } on a denied ability check. On the landed page the message sits in a plain <p> with no role="alert", no aria-live region, and no focus is moved to the heading/container on mount. SPA route changes are not announced by default, so the transition is inaudible. Why it matters: A keyboard/screen-reader user who clicks a control and is bounced here perceives nothing — no error, no context, focus stranded where the old page was. This is the empty-states "skipping announcement strategy" anti-pattern applied to a denial the user specifically needs to hear. Fix: Move focus to the page heading on mount (ref + focus() on an tabindex="-1" <h1>), and/or mark the message container role="alert". Pairs naturally with finding 1's heading.

Project-level references (not re-filed) ​

  • NAV-03 — fails project-wide (one static document title for the whole app); the forbidden route sets no title. See PROJECT-LEVEL.md.
  • No catch-all 404 route — confirmed at inventory and in PROJECT-LEVEL.md: router/index.ts has no /:pathMatch(.*)* record and no NotFoundPage.vue, so an unmatched URL renders an empty layout rather than a recoverable error page. Belongs to this feature (#37 Error states) but is already recorded; noted here, not re-derived. playout has a not-found; customer-portal and members do not.

Passes worth recording (not defects) ​

  • MSG-03 / MSG-04 — copy states the next step ("contact an administrator") and the page offers an explicit route out ("Back to dashboard") plus the full sidebar, so this is a recoverable surface, not a chrome-less dead end (contrast playout's ErrorHero). handleBack targets dashboard, which has no abilityCheck (router/index.ts:70-73), so the button cannot loop back through the deny branch.
  • NAV-01 — no timed/auto redirect; navigation here is guard- or user-initiated.
  • CONTENT-01 — forbidden.{title,message,back} exist with real translations in both en.yml:475-478 and nb.yml:475-478 (no English-in-nb fallthrough here).

Unverified ​

  • A11Y-01 (contrast) — text-sm text-text-2 message on paper/card surface; needs a contrast tool on rendered colours.
  • A11Y-06 (responsive / short viewport) — needs a rendered viewport.
  • PrimeVue Card title element — that #title renders as a non-heading <div> (basis of finding 1) is standard PrimeVue behaviour but node_modules is not installed in the checkout, so it is asserted from library convention, not read from source. The page/layout declaring no <h1> is asserted from source.
  • PrimeVue Button focus/role — the "Back" control's rendered semantics (accessible name is supplied via :label, so likely fine) cannot be confirmed from source.

Baseline additions ​

none — findings map cleanly to A11Y-04 and MSG-01. (A candidate cross-cutting theme: "an auth guard that redirects to an error route must move focus and announce the redirect" — but that is MSG-01 + A11Y applied, not a new rule.)

Cross-project note ​

The "denied surface exists but lacks a heading and announces nothing on redirect" shape likely recurs in members (has a forbidden/unauthorized surface per the alignment table) and playout (its unauthorized/ErrorHero route is already flagged as a chrome-less dead end with zero interactive elements). tt-time-tracker is worse on this class — it silently redirects with no forbidden surface at all.