Skip to content

[UX] customer-portal — Admin configuration ​

Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Adalen-Truck/customer-portal · Branch: develop @ 693a76c · Files reviewed: 3 (SettingsPage, StandardsPage, AdminTermsPage; + router excerpt, terms domain index) Patterns: forms/rich-text-editor

Summary ​

Three sibling admin config screens — Settings (default role + notification emails), Standards (CRUD list of global skills/machine categories), and Terms (per-locale rich-text editor with a publish toggle). They are functionally sound and follow the repo's token/PageLayout conventions, but share a consistent accessibility gap: save/error/success feedback is swapped silently into the DOM with no live region, and the icon-only row controls on Standards have no accessible name. The single most important item is MSG-01 — a screen-reader admin gets no confirmation that a save succeeded or failed on any of the three pages.

Findings ​

1. Save/error/success feedback is announced to no one — High · MSG-01 ​

Where: apps/web/src/pages/admin/SettingsPage.vue:128-133,204-216; apps/web/src/pages/admin/AdminTermsPage.vue:97-102,160-171What: Every status banner on Settings and Terms is a plain v-if <div> with no role="alert" (errors) or role="status"/aria-live="polite" (success). The load-error, saveError and saveSuccess blocks all mount silently. "Settings saved successfully." (:215) and "Terms saved successfully." (Terms :170) appear with no announcement, and the save-failure banners likewise. Standards routes its feedback through PrimeVue Toast (:61-66 etc.), which does carry a live region, so this is specific to the two banner-based pages. Why it matters: A keyboard/screen-reader admin presses "Save changes", the button then disables (see #5) dropping focus to <body>, and nothing is spoken — they cannot tell whether the write succeeded, failed, or is still running (WCAG 4.1.3). On a config page whose entire purpose is a one-shot save, that is a real barrier. The rich-text-editor pattern explicitly calls for announcing "errors, loading, or completion… with the right politeness." Fix: Add role="alert" to the error/load-error banners and role="status" (or aria-live="polite") to the success banners on both pages.

2. Icon-only row controls have no accessible name — Medium · A11Y-05 ​

Where: apps/web/src/pages/admin/StandardsPage.vue:229-260,316-347What: The edit (pi pi-pencil), delete (pi pi-trash), confirm-edit (pi pi-check) and cancel-edit (pi pi-times) buttons on every skill and category row are icon-only PrimeVue Buttons with no label, aria-label or aria-labelledby. PrimeVue does not synthesise a name from the icon class, so each is exposed to assistive tech as an unnamed "button". Why it matters: A screen-reader admin hears "button, button" per row and cannot tell rename from the destructive delete. node_modules is not installed so PrimeVue internals can't be read from source, but the call site supplies no name, so the gap is at the call site regardless. Fix: Add aria-label="Rename skill" / "Delete skill" (and the category equivalents) — ideally interpolating the item name — to each icon-only button.

3. Placeholder-only labels and an unassociated editor label — Medium · FORM-01 ​

Where: apps/web/src/pages/admin/StandardsPage.vue:188-192,222-228,275-279,309-315; apps/web/src/pages/admin/AdminTermsPage.vue:145-152What: The "add skill", "add category" and both inline-edit InputTexts carry only a placeholder ("Skill name", "Category name") and no <label>, so the field's name vanishes once typing starts and is never exposed to AT. On Terms, the "Content" <label> (:145-147) has no for and the Editor below it has no matching id, so the rich-text surface is an unlabeled control (the "Menu item name" field above it is correctly associated via :for/:id, showing the intended pattern was simply missed here). Why it matters: Placeholder-as-label fails FORM-01 and the rich-text-editor pattern's "keep labels… connected with for" guidance; the editor announces no purpose to a screen reader. Fix: Give each Standards input a real (optionally visually-hidden) <label>; add an id to the Terms Editor and point the Content <label for> at it.

4. Notification-email fields accept anything — no format validation — Medium · forms/form-validation ​

Where: apps/web/src/pages/admin/SettingsPage.vue:192-198,103-119What: The four notification-email inputs use type="email", but they are not inside a <form> and Save is a plain @click handler (:218-224), so the browser's native email validity check never fires. handleSave only .trim()s the values — a typo like ops@aadlen is accepted and persisted with no warning, no affirmative confirmation, and no constraint shown up front (FORM-04). Why it matters: These addresses route access-request, warranty, service and partner-booking notifications. A silently malformed address means those emails go nowhere, with nothing on screen to reveal it — a data-quality trap on a low- traffic config page few people revisit. Fix: Validate each field's format on blur/submit and surface a per-field message; block Save (with an explanation, not a bare disable) while any address is malformed.

5. Submit/discard buttons disable themselves mid-submit — Medium · FORM-06 ​

Where: apps/web/src/pages/admin/SettingsPage.vue:218-232; apps/web/src/pages/admin/AdminTermsPage.vue:173-187; apps/web/src/pages/admin/StandardsPage.vue:193-197,280-285What: Save/Discard/Add are all :disabled="… || <mutation>.isPending.value". Disabling the just-activated control moves focus to <body> the instant it is pressed. Combined with #1 (no live region), the keyboard user is left with no focus and no announcement during the save. Why it matters: FORM-06 — a disabled-on-submit button drops focus and the user loses their place; here it compounds the silent-feedback problem. Fix: Keep the button enabled and use aria-busy + an in-handler guard against double submission; the label already flips to "Saving…".

6. Admin-config chrome is hardcoded English, bypassing i18n — Medium · CONTENT-01 ​

Where: apps/web/src/pages/admin/SettingsPage.vue (all copy, e.g. :124,137,169); StandardsPage.vue:132,153,175-176; AdminTermsPage.vue:78-79,91,108What: None of the three pages import useI18n; every label, heading, placeholder, confirm-dialog and toast string is an English literal. customer- portal (en/nb) is held to CONTENT-01 per feature per BASELINE.md. Why it matters / caveat: An nb admin sees English throughout. That said, only 1 of 15 pages/admin/*.vue files uses useI18n, so admin surfaces appear to be English-only in practice across the section — this is likely a project-level decision rather than a per-feature miss, and is worth confirming against product intent before filing. Notably the Terms page itself manages customer-facing content per locale (TERMS_LOCALES) while its own chrome is not localised. Fix: Either route the copy through vue-i18n, or record admin-section English-only as an explicit project-level exception so future audits stop re-raising it.

7. Success-reset setTimeout is not cleared on unmount — Low · NAV-02 ​

Where: apps/web/src/pages/admin/SettingsPage.vue:115; apps/web/src/pages/admin/AdminTermsPage.vue:19What: After a save, a bare setTimeout(… , 3000) clears saveSuccess and is never captured or cleared on unmount. Navigating away within the window fires a .value write on a torn-down component (a dev-time Vue warning; harmless in prod). Fix: Store the handle and clearTimeout in onBeforeUnmount.

8. Settings page hand-rolls its header and uses non-heading section titles — Low · A11Y-04 ​

Where: apps/web/src/pages/admin/SettingsPage.vue:122-126,136-138,168-170What: Unlike its two siblings (which use <PageLayout>), Settings hand-rolls <h1>Settings</h1> — contrary to the apps/web CLAUDE.md "never hand-roll a page header" rule — and its section titles ("User Defaults", "Notification Emails") are PrimeVue Card #title slots (rendered as non-heading <div>s), so the page has an <h1> and then no <h2> structure for its two sections. Fix: Adopt <PageLayout> for consistency and expose the section titles as real <h2>s.

Project-level items affecting this feature (referenced, not re-filed) ​

  • MSG-02 — Settings/Terms error fallbacks use getErrorMessage (SettingsPage.vue:117,132, AdminTermsPage.vue:71,101), which prefers the raw backend string. See PROJECT-LEVEL.md (customer-portal).
  • NAV-03 — one static document title app-wide; none of these routes set a title. See PROJECT-LEVEL.md.

Unverified ​

  • A11Y-01 (contrast) — status banners use status-*-fg/-bg token pairs and muted text-2/text-3; not verifiable from class names.
  • A11Y-06 (short viewport / mobile keyboard) — needs a rendered viewport; the fixed Editor height of 360px (AdminTermsPage.vue:150) and w-96/w-72 fixed input widths are worth checking at 320px but cannot be settled by reading.
  • PrimeVue Editor toolbar/keyboard a11y — the Quill toolbar's button names, focus order and keyboard operability live in PrimeVue internals; node_modules is not installed, so this cannot be asserted from source.
  • Offboarding/revocation & last-admin hypotheses — not applicable: these screens manage settings, standard lists and terms, not users/sessions/roles. The defaultRoleId here only affects future registrations.

Baseline additions ​

  • FORM-VALIDATE-CONTEXT (proposed) — A field that relies on native input validity (type="email", pattern, required) must sit inside a <form> and submit through it, or provide equivalent scripted validation; a type="email" input saved via a click handler validates nothing. Grounds finding #4; likely reusable across the other three projects' non-<form> save patterns.

Cross-project note ​

  • MSG-01 (silent status banners) is the same shape flagged in playout's VAlert (missing role="alert"); very likely present in members and tt-time-tracker admin save screens — worth a targeted grep for success/error <div>s lacking role/aria-live.
  • A11Y-05 icon-only controls already tracked as a cross-project theme (click-only/unnamed controls) — StandardsPage adds a confirmed customer-portal instance.
  • CONTENT-01 admin-chrome-English — if confirmed as an intentional admin-only exception here, check whether playout's admin surfaces make the same choice before treating it as a defect.