Appearance
[UX] customer-portal — Onboarding
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Adalen-Truck/customer-portal · Branch:
develop@693a76c· Files reviewed: 8 Patterns:user-feedback/progress-indicator
Summary
OnboardingPage.vue is a single 7-field form that has to serve three different outcomes of the auto-link guard (no_match, multiple_matches, and "auto-link threw"), and it only really serves one of them. The most important defect is that a successful onboarding can be reported to the user as a failure — the post-success meMe() call sits inside the same try as the command itself (OnboardingPage.vue:130-138) — and the retry the error copy invites is not idempotent, because the idempotency key is regenerated on every click (:110). One transient /me blip after a successful submit therefore creates a second company in Dynamics. Secondary but close behind: the multiple_matches branch tells the user to "confirm your details below" when the only control on the page creates a new company, and the pending "Request sent" state is a total dead end — OnboardingLayout.vue has no sign-out, no back, and no way to re-check status.
SEC-01…05, FORM-01/02/03/07/08/10, NAV-01/02/04 and A11Y-02 are not applicable or pass here (no credentials, no tokens, no timers on this surface). CONTENT-01 passes: every string goes through t() and the onboarding.* block has full en/nb parity — with the one exception in finding 5.
Findings
1. A successful onboard can be reported as a failure, and the retry it invites creates a duplicate company — Blocker · MSG-03
Where: apps/web/src/pages/OnboardingPage.vue:130-138, key generation at :110What: After the command returns SUCCEEDED, the handler still runs await meMe({ throwOnError: true }) and await router.push(...) inside the same try. If that /me call fails for any reason — a network blip, a 500, or the newly created link simply not being readable yet — control falls into the catch at :137 and the user is shown onboarding.errors.failed → "We couldn't complete onboarding. Please try again." The company has in fact been created. Because generateRequestId() runs fresh on every invocation of handleOnboard (:110), the idempotency-key differs on the retry, so the backend's idempotency guard does not collapse it: pressing the button again creates a second account + contact in Dynamics. Why it matters: The user is told a successful, irreversible ERP write failed, and the only action offered performs it again. Duplicate customer accounts in Dynamics have to be merged by hand, and the user meanwhile has no way to tell which of the two they are linked to. Fix: Move the post-success work out of the try, or wrap it in its own try/catch that still navigates (or shows a success state) on failure. Generate the idempotency key once per form session (const requestId = ref(generateRequestId()), reset only after a confirmed success) so a retry is genuinely idempotent. Related: customerLinkingOnboard at :121-127 is called without throwOnError, so an HTTP error yields data === undefined and result.status throws a TypeError into the same catch — the server's own error body is never read.
2. multiple_matches offers no way to pick a match — the only action creates a duplicate — High · MSG-03, NAV-05
Where: apps/web/src/pages/OnboardingPage.vue:43-45 and :144-150; guard at apps/web/src/router/index.ts:715-718What: When auto-linking finds several candidate accounts, the guard redirects to ?status=multiple_matches and the page shows "We found multiple matches for your company. Please confirm your details below." There is nothing to confirm. isNoMatch is false for this status, so handleSubmit routes to handleOnboard — the button reads "Create company" and the submit creates a new Dynamics account. The API already returns the candidate list (LinkingResultResponse.candidates → { accounts, contacts }, apps/web/src/api/types.gen.ts:1530-1539) but the guard discards the whole response body and passes only the status string, and candidates is referenced nowhere in apps/web/src. Why it matters: The user whose company already exists — several times over — is funnelled into creating yet another record, and is asked to retype account name, org number, email and phone that the backend has already matched (FORM-09). This is the exact scenario the status was invented to detect, and it produces the worst possible outcome. Fix: Give multiple_matches its own state: pass the candidates through (store the LinkingResultResponse rather than only its status, or have the page re-call auto-link to fetch them), render them as a selection list, and make the primary action "This is us" → link, with "None of these — create a new company" demoted to a secondary control.
3. "Request sent" is an unrecoverable dead end — High · MSG-04
Where: apps/web/src/pages/OnboardingPage.vue:191-205; shell at apps/web/src/layouts/OnboardingLayout.vue:8-27What: Once a link_request exists, checkExistingRequest (:152-165) locks the page to the "Request sent!" card on every subsequent visit. That card has no controls at all. OnboardingLayout renders only the logo and the language switcher — no sign-out, no back, no support link — and the guard keeps sending the user back here because they have no active links (router/index.ts:693). A user who signed in with the wrong account, or whose request is never approved, cannot leave the page or start over; the only exit is clearing cookies. Why it matters: An unrecoverable dead end on the very first screen a new customer sees, with an unbounded wait (approval is manual and out of band). Fix: Add a sign-out control to OnboardingLayout (it is the shell for verify-email too, which has the same problem), and give the "Request sent" card at least a "Check again" action and a support/contact route. Add the failure path to onboarding.requestSent.message per CONTENT-03 — a rough timeframe and what to do if no welcome email arrives.
4. A transient auto-link failure is presented as a definitive "no match" — High · MSG-03
Where: apps/web/src/router/index.ts:618-631 and :715-718What: attemptAutoLink catches everything: only a 403 is mapped (pending_verification); every other failure — offline, 500, timeout, CORS — returns null. The guard then redirects to onboard with no status query (:715-718), so resolveStatusMessage() returns null and the user sees only the generic subtitle "We could not automatically link your account. Complete the details below to get started." and a "Create company" button. Nothing distinguishes "the server was briefly unreachable" from "your company genuinely isn't in our system". Why it matters: A five-second outage during the guard's auto-link call can walk an existing customer straight into creating a duplicate company record. There is no retry affordance anywhere on the page. Fix: Return a distinct error/unavailable outcome from attemptAutoLink on non-403 failures, pass it through as ?status=, and give it copy that says the check failed and offers a Try again button rather than the create form. Note also that the pending_verification branch on the page (:47-49) is dead: the guard converts that status into a verify-email redirect at router/index.ts:712-714, so onboarding.status.pendingVerification can never render. The page handles a status it never receives and does not handle the one it receives most often on failure.
5. Raw Dynamics command errors are shown verbatim, untranslated — Medium · MSG-02, CONTENT-01
Where: apps/web/src/pages/OnboardingPage.vue:131What: errorMessage.value = result.errorMessage || t("onboarding.errors.failed") puts LinkingCommandResponse.errorMessage — a backend/Dataverse command failure string — directly into the UI. LinkingCommandResponse also carries errorCategory (api/types.gen.ts:1557-1562), which is exactly the mappable code MSG-02 asks for, and it is never read. Why it matters: A Norwegian user gets an English (possibly Dynamics-internal) sentence in an otherwise fully localised page, and the string says what went wrong inside the integration rather than what the user should do. Fix: Switch on errorCategory, mapping known categories to onboarding.errors.* keys in both en.yml and nb.yml; fall back to onboarding.errors.failed. Keep errorMessage for the console/telemetry only.
6. Submit is disabled while invalid and again while submitting — Medium · FORM-05, FORM-06
Where: apps/web/src/pages/OnboardingPage.vue:357What: :disabled="!canSubmit || isSubmitting". canSubmit is only "account name is non-empty" (:36), so before the user types anything the single action on the page is dead with no explanation of why — and the onboarding.errors.required message the handler would produce (:72-75, :102-105) is unreachable. The second half disables the button the user has just activated, which drops focus to <body> mid-submit; keyboard and screen-reader users lose their place, and on the onboard path the wait is advertised as up to a minute (onboarding.loading). Why it matters: A disabled control gives the user nothing to act on, and the focus drop means the "Creating your company in Dynamics…" message is not reachable by the user who most needs it. Fix: Drop the disabled binding entirely. Keep the existing guard in handleSubmit, surface onboarding.errors.required (and move focus to the account-name field) when it fires, and use :loading="isSubmitting" plus aria-busy for the in-flight state — PrimeVue's Button already shows the spinner without needing disabled.
7. Required-ness and field formats are never shown; email validation falls through to the browser — Medium · FORM-04
Where: apps/web/src/pages/OnboardingPage.vue:238-250 (account name), :226-229 (form), :271-278 and :320-333 (email fields) What: "Company name" is the only mandatory field, but its label carries no required marker, and the input has no required / aria-required. The locale files already contain the copy for this and it is unused: onboarding.sections.required ("Required", apps/web/src/i18n/messages/en.yml:491) and a full set of seven onboarding.placeholders.* examples (en.yml:500-507, mirrored in nb.yml) — the template binds no placeholder at all. Meanwhile the two email inputs use type="email" on a <form> with no novalidate, so a malformed address makes the browser block submission with its own native bubble; the @submit.prevent handler never runs and no app-styled message is shown. Why it matters: The user learns which field is mandatory only by pressing a button that is disabled (finding 6), and learns the email format only from an unstyled browser tooltip that does not match the rest of the page's error treatment. Org number and phone have no format guidance at all despite the locale file containing examples for both. Fix: Wire up the unused placeholders.* keys, mark the account-name label with sections.required and set required + aria-required="true" on the input, and add inline per-field validation for the email fields (PrimeVue Messageseverity="error" under the field, tied via aria-describedby) rather than leaving it to native constraint validation.
8. No state change on this page is announced — Medium · MSG-01, CONTENT-04
Where: apps/web/src/pages/OnboardingPage.vue:185-189 (spinner), :191-205 (success card), :208-222 (status/error messages) What: The initial gate is a bare <span class="pi pi-spin pi-spinner"> with no text, no role="status" and no accessible name — for a screen-reader user the page is empty and silent while checkExistingRequest runs. The form → "Request sent!" swap is a plain <Card> with no live region. The error/status blocks rely on whatever PrimeVue 4's Message renders by default rather than declaring role="alert" explicitly, which is what the rest of this repo does (components/internal/CommandErrorNotice.vue:38-41, components/internal/ReceptionProgress.vue:72-77, layouts/AppLayout.vue:439). Why it matters: WCAG 4.1.3 — the outcome of a submission that creates a company in the customer's ERP is conveyed by a silent DOM swap. The progress-indicator pattern's "skipping announcement strategy" anti-pattern is exactly this. Fix: Give the spinner role="status" and visible or sr-only text naming what is being waited on ("Checking your access request…"); put role="status" on the requestSent card; add role="alert" to the error Message and role="status"/aria-live="polite" to the info Message, matching the convention already used elsewhere in this app.
9. Prefill can overwrite what the user has already typed, and /me is fetched twice on mount — Medium · FORM-09
Where: apps/web/src/pages/OnboardingPage.vue:58-67, :152-165, :167-171What: onMounted fires checkExistingRequest() and loadPrefill() in parallel — two separate meMe() round-trips for the same payload. The form becomes editable as soon as the first of them (checkExistingRequest) resolves, but loadPrefill then assigns contactName, contactEmail and accountEmail unconditionally when it lands. On a slow connection the user can have typed a company email into accountEmail and have it silently replaced by their own login address. Why it matters: Values the user has supplied are destroyed and must be retyped, and — worse — the replacement is silent, so a wrong company email can be submitted to Dynamics without the user noticing. Fix: Fetch /me once and derive both the prefill and the pending-request check from it (ideally via @tanstack/vue-query, per the app's own frontend rules); assign prefill values only where the field is still untouched/empty.
10. No route on this flow sets a document title — Medium · NAV-03
Where: apps/web/src/router/index.ts:512-529 (route records), apps/web/index.html:21What: There is no afterEach title hook, no meta.title, and no useTitle anywhere in apps/web/src (grep for document.title / useTitle / afterEach returns nothing). Every route in the app — including onboard, verify-email and select-account — is titled "Ådalen App". Why it matters: Tab titles, browser history and screen-reader page announcements are identical for every step of the funnel, so the user cannot tell onboarding from email verification from the dashboard in their history. Fix: Add meta.title to each route record and a small router.afterEach that sets document.title from the i18n key. This is app-wide, not onboarding-specific — worth filing once against the router.
11. The multi-route onboarding funnel has no progress or step indicator — Low · CONTENT-04
Where: apps/web/src/layouts/OnboardingLayout.vue:23-25; page wait states at apps/web/src/pages/OnboardingPage.vue:367-373What: verify-email → onboard → (select-account) are three real routes under one layout with no indication of position, count or remaining work. Within the page, the honest "This can take a minute" copy (onboarding.loading) is bound to v-if="isSubmitting && !isNoMatch", so the request-access path shows no waiting copy at all — only the in-button spinner — and the onboard path's wait is indeterminate with no elapsed/step detail. Why it matters: The progress-indicator pattern's "mismatching timing to the job" anti-pattern: a minute-long wait with nothing but a spinner reads as abandoned, and users re-submit — which, per finding 1, is not idempotent. Fix: Add a step indicator to OnboardingLayout (step progress is the pattern's named variation for onboarding flows), and show a waiting message on both submit paths, naming what is being waited on.
12. The first field is not focused on mount — Low · FORM-11
Where: apps/web/src/pages/OnboardingPage.vue:245-249What: This is a single-purpose form and account-name is its only required field, but nothing focuses it once isCheckingExisting flips to false. Why it matters: Keyboard users must tab past the header, logo and language switcher on every visit. Fix: Focus #account-name in a watch on isCheckingExisting (not in onMounted — the input is not in the DOM yet at that point).
13. The logo image has no alt attribute — Low · A11Y-05
Where: apps/web/src/layouts/OnboardingLayout.vue:13-16What: <img class="h-10" src="/aadalen-logo.svg"> — no alt. Screen readers announce the filename. (A11Y-05 is written for controls; this is a non-interactive image, so the correct fix is an empty alt, not a name.) Why it matters: "aadalen-logo dot ess vee gee" is read out before the page heading on the first screen a new customer meets. Fix: alt="" — the adjacent "Portal" text already names the brand.
Unverified
- A11Y-01 (contrast).
text-text-2/text-text-3onbg-paperandbg-sunken, and thetext-xs text-text-2footer atOnboardingPage.vue:352-354, need computed colour values from a rendered page. Not asserted. - A11Y-06 (responsive / short viewport). The form is two-column from
sm:and the layout is capped atmax-w-3xl; behaviour at 320 px with a mobile keyboard open, and whether the submit row stays reachable, needs a real viewport. - PrimeVue 4
Messagedefault ARIA.node_modulesis not installed in this checkout, so I could not read whatprimevue@^4.5.4renders forMessage's root element. Finding 8 therefore asserts only the hand-rolled cases (the spinner and the successCard), which are unambiguously non-announcing, and treats theMessageroles as a convention gap rather than a proven failure. - Post-onboard redirect loop.
fetchMeWithLinks(router/index.ts:594-603) preferssession.data.user.customerLinksover a fresh/me. If the better-auth session cookie is not refreshed by the onboard command, thedashboardguard could see zero links immediately after a successful onboard and bounce the user back to/onboard(and, per finding 4, with no status). Whether the session is re-issued server-side cannot be settled from the frontend; worth one manual run-through.
Baseline additions
- MSG-06 — A transient failure and a determinate negative result are never presented identically. When a lookup/precondition check fails to complete, the UI must say the check failed and offer a retry, not report the negative outcome ("not found", "no match") that it never actually established. Seen here at
router/index.ts:618-631; the same shape is likely in any guard that swallows errors into a status enum. - FORM-12 — Non-credential fields that map to a standard autofill token set
autocomplete(organization,email,tel,name,organization-title). FORM-02 covers credentials only; this seven-field identity form sets none, so browser autofill cannot help the user at all. - NAV-06 — A flow that spans multiple routes tells the user where they are in it (step count or named steps), rather than presenting each route as a standalone page. Covers
verify-email→onboard→select-accounthere, and applies to every wizard in the queue.
Cross-project note
- NAV-03 (no document titles) is app-wide in customer-portal and worth checking in all three others — playout and members both use a router without an obvious title hook.
- FORM-05 / FORM-06 (disabled submit) is the most likely to repeat: it is a one-line habit, and both customer-portal and tt-time-tracker use PrimeVue
Button, whoseloadingprop makesdisabledfeel redundant-but-harmless. Expect it in tt-time-tracker and members. - Finding 1's shape — post-success work inside the submit
try, plus an idempotency key regenerated per click — should be grepped for in every mutation flow in customer-portal (generateRequestIdfrom@aadalen/uiis used across the app) and in tt-time-tracker, which has the same command/idempotency convention. - MSG-04 (dead-end shells) applies to any auth/onboarding layout without a sign-out: check playout's and members' pre-authenticated shells.