Appearance
[UX] customer-portal — Account selection
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Adalen-Truck/customer-portal · Branch:
develop@693a76c· Files reviewed: 10 Patterns:forms/selection-input
Summary
The picker page itself is thin but the real problem is the state it writes. The selected account is persisted to localStorage under admin-selected-account, and nothing ever clears or validates it — not sign-out, not the router guard, not the store. The guard's only test is "is the key present?" (router/index.ts:683), so a selection made by a previous user, or pointing at a link that has since been revoked, silently survives and drives every price, order and asset query in the app. On mobile a user left in that state can be structurally unable to correct it. Secondary to that, the page has no error state, no empty state and no keyboard-operable list items.
Findings
1. The selected account survives sign-out — the next user on the device inherits it — High · SEC-06 (proposed)
Where: apps/web/src/layouts/AppLayout.vue:355-358, apps/web/src/stores/admin-account.ts:43-59What: handleSignOut() calls authClient.signOut() then window.location.href = "/sign-in". It never calls accountStore.clear() and never removes the admin-selected-account key. The full page reload destroys the Pinia store but localStorage persists, and the store re-seeds itself from storage on the next instantiation (admin-account.ts:62). Grepping the whole of apps/web/src confirms store.clear() is called from exactly one place — AccountSwitcher.vue:38, a component that is exported from components/index.ts but never rendered anywhere. Why it matters: On a shared workshop laptop or a shared tablet, user B signs in and the app is already scoped to user A's company. The guard sees the key, skips the picker (router/index.ts:682-687), and the header chip renders user A's company name to user B (AccountChip.vue:22, 63). Backend CASL enforcement means B does not get A's rows, so what B actually sees is a portal that is empty everywhere with another company's name at the top — no data leak, but a leaked company name and an app that appears broken with no stated cause. Fix: Clear the account selection as part of sign-out, before the redirect — useAdminAccountStore().clear() in handleSignOut. Belt-and-braces: key the stored value by user id, or drop it whenever the /me user id differs from the one that wrote it.
2. The guard checks that a selection exists, never that it is still valid — High · NAV-06 (proposed)
Where: apps/web/src/router/index.ts:682-687What:
ts
if (hasLinks && activeLinks.length > 1 && to.name !== "select-account") {
const stored = localStorage.getItem("admin-selected-account");
if (!stored) {
return { name: "select-account", query: { redirect: to.fullPath } };
}
}The guard already holds activeLinks — the authoritative list from /me — one line above, and does not compare the stored id against it. Any non-empty string passes, including a JSON blob for a link whose status is no longer "linked", an account deleted in Dynamics, or (per finding 1) another user's account. Why it matters: A user whose access to Company A is revoked, and who is still linked to Company B, keeps browsing "as" Company A. Every domain collection is keyed on getAccountId() from this store (domains/*/**.collection.ts), so dashboards, orders, bids and assets all come back empty. The user is given no explanation and no prompt to re-choose; the app just looks dead. The revoked-access case is precisely when a wrong scope matters most. Fix: Parse the stored value and require activeLinks.some(l => l.customerAccount?.id === stored.id). On mismatch, clear the key and fall through to select-account with the redirect query already being built here. While in this code: the auto-link branch at :700 writes localStorage directly, bypassing the store, so the store's in-memory ref and its invalidateDomainQueries watcher (admin-account.ts:66-68) never see the write. Route both reads and writes through the store so there is one code path that can be made correct.
3. On mobile, a user with one remaining link cannot change a stale selection at all — High · MSG-04
Where: apps/web/src/components/mobile/MoreScreen.vue:41-56, apps/web/src/layouts/AppLayout.vue:479What: The mobile shell has no top bar, so AccountChip is not rendered and the only account affordance is the More sheet's account row. Its interactivity is accountRowInteractive = adminAccountSearch || canSwitchAccount (MoreScreen.vue:45), and AppLayout.vue:479 passes :can-switch-account="linkedAccounts.length > 1". A customer user with exactly one active link therefore gets a row that displays the selected account but does nothing on tap, and openAccountPicker returns without doing anything (MoreScreen.vue:47-56). Why it matters: Combine with findings 1 and 2: a mobile user with one link, holding a stored account that is stale or belonged to the previous user, sees the wrong company name, sees empty data, taps the one control that looks relevant, and nothing happens. There is no route out short of clearing site data or reinstalling the app. That is an unrecoverable dead end. Fix: Make the row interactive whenever the stored account is not the user's single link — or simply always, so a single-link user can confirm/repair the selection. The correct end state is that findings 1 and 2 make this unreachable; this row should still not be a no-op.
4. If the page's own /me call fails, the user gets a heading and nothing else — High · MSG-03
Where: apps/web/src/pages/SelectAccountPage.vue:21-32, :66-98What: onMounted swallows every error into links.value = [] (:27-29). The template has no v-else for the empty case — an empty links array renders an empty <div class="grid gap-3">. The guard's copy of /me succeeded (that is why the user is here), so the empty-because-no-links case is already handled upstream at router/index.ts:689-691; the empty render here means a transient network failure. There is no error message, no retry, no sign-out, no link anywhere in the page body. Why it matters: The user is on a blocking route — the guard will bounce them back here from anywhere else — staring at "Choose your company" with no companies and no explanation. A reload is the only way forward and nothing on screen says so. This also fails MSG-01 (no role="alert" block exists to carry the error) and MSG-04 (a dead end with no route out). Fix: Track error alongside isLoading; render a role="alert" block with one generic sentence and a "Try again" button that re-runs the fetch. Add a distinct empty state for links.length === 0 on a successful fetch. Prefer @tanstack/vue-query here so retry and error state come for free — the page is currently the only account surface hand-rolling its own fetch (AppLayout.vue:317 and useAccountSearch do the same thing separately).
5. If the localStorage write throws, clicking a company does nothing, forever, silently — Medium · MSG-03
Where: apps/web/src/stores/admin-account.ts:53-59, apps/web/src/pages/SelectAccountPage.vue:42-44What: loadFromStorage is defensively wrapped in try/catch (:44-51) but saveToStorage is not. store.select(account) therefore throws out of selectAccount() before router.push on :44 is reached, whenever localStorage.setItem fails — quota exceeded, or storage blocked/partitioned in an embedded or restricted WebView. The same repo handles this correctly elsewhere: WarrantySubmissionPage.vue:80 has an explicit comment about localStorage being unavailable in private browsing. Why it matters: Every card click becomes a no-op with zero feedback, on a route the guard will not let the user leave. The app is bricked for that user with no message to act on or report. Fix: Wrap saveToStorage in try/catch like its sibling; surface a failure to the caller so SelectAccountPage can show an alert; and navigate on the in-memory selection regardless, so a storage failure degrades to "selection is not remembered next visit" rather than "cannot continue".
6. The account cards are click-handled <div>s; focus and the accessible name land on a repeated "Continue" — Medium · A11Y-03
Where: apps/web/src/pages/SelectAccountPage.vue:70-97What: <Card ... @click="selectAccount(link)"> renders a PrimeVue Card, i.e. a plain div. It has cursor-pointer and hover:shadow-md but no role, no tabindex, no keydown handler and no focus style. The only focusable element is the nested <Button label="Continue"> (:89-94), which carries no handler of its own and works only because its click bubbles to the card. So keyboard operation happens to work, but the focus ring sits on a small text (borderless) button rather than the target the user is choosing, and every row's accessible name is the identical string "Continue" — a screen reader user tabbing the list hears "Continue, Continue, Continue" with no company name attached. The list is also a bare div.grid, not a list, so the number of choices is not announced. Why it matters: This is the one screen that decides whose data the user sees for the rest of the session, and a non-visual or keyboard user cannot tell which control corresponds to which company. Picking wrong shows another company's prices and orders. Note also A11Y-02: the "Continue" text button is the only real target for assistive tech and pointer users who do not discover that the whole card is clickable. Fix: Make each row a single <button type="button"> (or <a>) wrapping the whole card content, as AccountPickerSheet.vue:73-94 already does correctly for mobile — that component is the model to copy. Wrap the rows in <ul>/<li>, give each button an accessible name that includes the company name, and drop the inner Button to a decorative aria-hidden chevron.
7. The loading state is an unlabelled spinner in no live region — Medium · MSG-01
Where: apps/web/src/pages/SelectAccountPage.vue:59-64What: <i class="pi pi-spin pi-spinner" /> — an empty <i>, no text, no aria-label, no role="status", and the swap from spinner to card list happens in a container with no aria-live. A screen reader user hears nothing on arrival and nothing when the choices appear. Why it matters: Silent DOM swap (WCAG 4.1.3). The user does not know whether the page is working or empty. Also fails CONTENT-04 — the wait names nothing; AccountPickerSheet.vue:57-62 gets this right with a common.loading string. Fix: Wrap in role="status" with visible text (t("common.loading")), and put aria-live="polite" on the results container.
8. The desktop account chip and switcher are hard-coded English, with Norwegian translations sitting unused — Medium · CONTENT-01
Where: apps/web/src/components/AccountChip.vue:22, 84, 97, 103; apps/web/src/components/AccountSwitcher.vue:50, 61What: AccountChip — the control every desktop user sees in the header — renders "Select account", "Search account...", "Loading…" and "No accounts found." as literals; it does not import useI18n at all. AccountSwitcher likewise has "Select account..." and "Filter by account...". The keys already exist and are translated: selectAccount.searchPlaceholder / selectAccount.noResults / common.loading (i18n/messages/en.yml:520-525 and nb.yml:520-525), and the mobile sheet uses them. Why it matters: An nb user gets a Norwegian page ("Velg bedrift") whose header control and popover are in English. The fix is a lookup, not a translation job. Fix: Import useI18n in AccountChip and reuse the existing selectAccount.* / common.loading keys; add selectAccount.placeholder for the "Select account" fallback label. AccountSwitcher.vue appears to be dead code (exported from components/index.ts, rendered nowhere) — deleting it is also an acceptable fix.
9. No distinct document title — Low · NAV-03
Where: apps/web/src/router/index.ts:317-320, apps/web/index.html:21What: The route record carries no meta.title and there is no afterEach/document.title handler anywhere in apps/web/src (grep for document.title returns zero hits). Every route in the app is titled "Ådalen App". Why it matters: Browser tabs, history entries and bookmarks are indistinguishable, and a screen reader announces the same title on every route change. App-wide, not specific to this feature — recorded here because this route is the one users are most likely to have open in a second tab while comparing companies. Fix: Add meta.title per route and a router.afterEach that sets document.title.
10. The redirect query parameter is pushed unvalidated — Low · SEC-07 (proposed)
Where: apps/web/src/pages/SelectAccountPage.vue:43-44What: route.query.redirect is passed straight to router.push with no check that it is a same-origin, in-app path. The value is attacker-controllable via a crafted /select-account?redirect=… link. Why it matters: A protocol-relative or absolute value either navigates the user off-site or makes history.pushState throw a SecurityError, in which case the card click silently does nothing (compare finding 5). I have not rendered the page, so which of the two happens is unverified — but neither is acceptable, and the same raw-redirect pattern is used by the sign-in guard at router/index.ts:643-646. Fix: Accept only values starting with a single / and not //; resolve through router.resolve and fall back to { name: "dashboard" } on anything unrecognised. Share one helper with the sign-in flow.
Unverified
- A11Y-01 (contrast). Cannot be settled from code. Needs checking on the rendered page for
text-text-2/text-text-3onpaper(subtitleSelectAccountPage.vue:54, org number:82-87) and for thetext-severity secondary Button label at:89-94, which is the lowest-emphasis styling available and is carrying the page's primary action. - A11Y-06 (short viewport / mobile keyboard). Cannot be settled from code. Worth checking specifically: this page renders inside
AppLayout, so on mobile the bottom tab bar overlays the card list, and the page applies no bottom padding (:49) — the last company in a long list may sit under the tab bar. Also check the page at 320px, where the card'sflex items-center justify-betweenrow (:77) puts a long company name beside the "Continue" button with nomin-w-0on the text column. - Whether the
select-accountpage should render insideAppLayoutat all. It is a child of the layout route (router/index.ts:63-69, 317), so the full sidebar, tab bar and theAccountChipare all present on a screen whose whole purpose is to force a choice — meaning two different account pickers are on screen at once and the chip's selection does not trigger the pending redirect. Confirming the visual result needs a rendered page.
Baseline additions
Three proposed. The baseline was seeded from an auth/password-reset audit and has no rules covering a persisted, app-wide scope selection (which account, tenant, or organisation the user is acting for) — that is the entire subject of this feature and the source of its three worst findings.
- SEC-06 — Sign-out clears client-side user state. Signing out removes every
localStorage/sessionStorage/store value scoped to the departing user, including tenant or account selections. Nothing a previous user chose may be visible to, or in effect for, the next user on the device. - NAV-06 — A persisted scope selection is validated, not merely detected. Where a stored value determines whose data the app shows, guards compare it against the authoritative server list on entry, discard an invalid or revoked value, and re-prompt. Presence is never taken as validity.
- SEC-07 — Redirect parameters are validated before navigation. A
redirect/next/returnToquery value is accepted only as a same-origin in-app path, resolved through the router, with a safe default fallback.
Also worth considering, seen twice on this feature: A11Y-07 — repeated controls in a list carry unique accessible names (findings 6 and, in the mobile sheet, the selected-state check icon at AccountPickerSheet.vue:90-93, which is conveyed by an icon's visibility alone with no aria-current).
Cross-project note
- SEC-06 (sign-out does not clear client state) — check every project's sign-out handler for what it leaves in
localStorage. In this repo the only other persisted key is the locale (i18n/index.ts:19, 36), which is correctly device-scoped rather than user-scoped, so the pattern is "we never thought about clearing" rather than a deliberate choice. tt-time-tracker and members both persist client-side UI state and are the likely carriers. - NAV-06 (scope selection validated) — applies to any project with a tenant, organisation or customer switcher. tt-time-tracker (multi-client time entry) is the most likely match; playout should be checked for an equivalent organisation selector.
- NAV-03 (no distinct document titles) — app-wide in customer-portal, so likely to repeat in every feature of this project and worth one project-level finding rather than 37 copies. Confirm whether playout, which is further along on routing hygiene, already sets titles.
- Finding 6 (click-handled
divwith the real control nested inside) is a generic PrimeVueCard-as-button habit; expect it wherever a card grid is clickable — marketplace (#9), catalog (#12) and customer assets (#14) in this same project, and any card grid in tt-time-tracker.