Appearance
[UX] customer-portal — Email verification
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Adalen-Truck/customer-portal · Branch:
develop@693a76c· Files reviewed: 9 Patterns:authentication/signup
Summary
verify-email is a hard gate: router/index.ts:664-669 bounces every non-internal, unverified user back to this page from any route they attempt, and the page itself offers only two controls — Resend and I've verified my email. There is no sign-out, no way to correct a mistyped address, and no guidance for the case the audit exists to catch: the email never arrives. A user who registered with .con instead of .com, or whose mail lands in a quarantine, is permanently locked in a two-button loop with no route out and no support contact. That is the finding to fix first; the raw-SDK-error passthrough and the missing <h1> are real but secondary.
Findings
1. Unverified users are trapped on this page with no escape hatch — High · MSG-04
Where: apps/web/src/pages/VerifyEmailPage.vue:126-142, apps/web/src/layouts/OnboardingLayout.vue:8-27, apps/web/src/router/index.ts:664-669What: The guard returns { name: "verify-email" } for every route a non-internal unverified user requests, and returns true only when to.name === "verify-email". The sign-in and sign-up routes are publicOnly, so an authenticated user is redirected off them to dashboard, which the guard then bounces straight back to verify-email. authClient.signOut() is called in exactly one place in the app — layouts/AppLayout.vue:356 — and AppLayout is unreachable while unverified. OnboardingLayout renders only a logo and the LanguageSwitcher. The page's own error copy makes the gap explicit: pressing Resend with no session email shows verifyEmail.missingEmail — "We couldn't find your email. Please sign in again." (en.yml:533) — an instruction the user has no control to obey. Why it matters: A user who mistyped their address at sign-up, or who no longer has access to that mailbox, can never reach a state where verification is possible: Resend re-sends to the same wrong address forever, Continue always fails, and no other route renders. The only recovery is clearing cookies or contacting support — and no support route is offered either. This is the unrecoverable dead end MSG-04 exists to prevent. Fix: Add a Sign out button to OnboardingLayout's header (it already has a right-hand slot next to LanguageSwitcher) so every onboarding-gated page inherits it, calling authClient.signOut() then router.push({ name: "auth.sign-in" }). Additionally add a "Wrong email address?" affordance on VerifyEmailPage — either an inline edit that calls the change-email endpoint, or, minimally, a sign-out link plus a mailto:/contact route so the user can reach a human.
2. Confirmation copy never covers the failure path — Medium · CONTENT-03
Where: apps/web/src/i18n/messages/en.yml:528-537 (and nb.yml:528-537), surfaced at apps/web/src/pages/VerifyEmailPage.vue:106,110-116What: Every string in the verifyEmail block describes only the happy path. sent is "Verification email sent. Check your inbox."; notVerified is "Your email is still unverified. Please check your inbox and try again." Nothing mentions the spam/junk folder, how long delivery may take, how long the link stays valid, or what to do if it never arrives. The authentication/signup pattern names this exact scenario in its tracking section — "low verification rate → verification email may land in spam" — and its reference i18n bundle carries a dedicated verification block for it. Why it matters: Delivery failure is the single most common reason a user sits on this page, and it is the one outcome the page refuses to acknowledge. Combined with finding 1 the user has neither an explanation nor an action. In Norwegian corporate mail estates (the primary audience) quarantine holds are routine. Fix: Extend the copy in both locales: add a spamHint line rendered under the subtitle ("Not there? Check your spam or junk folder — delivery can take a few minutes."), state the link's validity window, and give notVerified a concrete next step rather than "try again". Keep en/nb parity.
3. Raw better-auth and API error strings are rendered to users — Medium · MSG-02
Where: apps/web/src/pages/VerifyEmailPage.vue:58, :66, :85What: Three passthrough sites, confirmed independently:
ts
errorMessage.value = result?.error?.message ?? t("verifyEmail.error"); // :58
errorMessage.value = getErrorMessage(error, t("verifyEmail.error")); // :66
errorMessage.value = getErrorMessage(error, t("verifyEmail.notVerified"));// :85Line 58 renders better-auth's server error.message verbatim whenever the SDK returns a structured failure — rate-limit and already-verified responses among them. Lines 66 and 85 route through getErrorMessage (packages/domain-helpers), which by design returns error.message or the API response body.message/body.error and only falls back to the localised string when neither exists — so the localised fallback is the last resort, not the default. All of these strings are untranslated English, which also breaches CONTENT-01 for nb users. Why it matters: A Norwegian user hitting the resend rate limit sees an English backend sentence with no instruction, on a page they cannot leave. Line 85 is worse than generic: a network failure or 401 on /me is reported as "Your email is still unverified", which is factually wrong and sends the user to check a mailbox that has nothing to do with the failure. Fix: Map the codes better-auth actually returns (rate limit, already verified, unknown user) to localised keys, and fall back to t("verifyEmail.error") for everything else — do not pass result.error.message or getErrorMessage output to the UI. At :85, distinguish "request failed" from "still unverified" and use a separate key for each.
4. The page has no <h1> — Medium · A11Y-04
Where: apps/web/src/pages/VerifyEmailPage.vue:101-107, apps/web/src/layouts/OnboardingLayout.vue:8-27What: The template contains no heading element at all. The page title goes through PrimeVue Card's #title slot, which Card renders inside a non-heading div.p-card-title; OnboardingLayout contributes only a <header> with the logo and a <main>. Sibling pages in this repo do use real headings (SelectAccountPage.vue, ProfilePage.vue, LandingPage.vue, HomePage.vue all contain <h1>), so this page is also inconsistent with the app's own convention. Why it matters: A screen-reader user landing here — often via an involuntary guard redirect, with no idea why the page changed — finds an empty heading list and no h1 to orient on. Heading navigation is the primary way non-visual users answer "where am I?", and this is precisely the page where that question is being asked. Fix: Wrap the title slot content in an <h1 class="…"> (PrimeVue's #title slot accepts arbitrary markup, so styling is unaffected), or move the title out of the Card into a real heading above it. Caveat: node_modules is not installed in this checkout, so PrimeVue 4's Card render output was not read from source. The absence of any heading element in the repo's own files is directly verified; the div-not-heading claim about Card rests on PrimeVue 4's documented markup.
5. Both action buttons disable themselves while busy, dropping focus — Medium · FORM-06
Where: apps/web/src/pages/VerifyEmailPage.vue:131 (:loading="isSending"), :138 (:loading="isChecking") What: PrimeVue's Button treats loading as disabling — the rendered button receives disabled while the prop is true. Both controls on the page use it. Why it matters: Activating a button that then disables itself removes it from the accessibility tree and drops focus to <body>. A keyboard or screen-reader user loses their place mid-action and, on this page, has nothing to tab back to except two buttons and a language menu. It also means the resulting success or error Message has no focus context to relate to. Fix: Keep the spinner but not the disabled state — bind aria-busy and guard re-entry inside sendVerificationEmail/handleContinue with the existing isSending/isChecking refs (an early if (isSending.value) return;) rather than relying on the disabled attribute.
6. Fallback subtitle claims an email was sent when none was — Medium · CONTENT-03
Where: apps/web/src/pages/VerifyEmailPage.vue:25-30, :91-96; apps/web/src/i18n/messages/en.yml:530What: When loadSessionEmail() cannot resolve an address, subtitle falls back to verifyEmail.subtitleFallback — "We sent a verification link to your email. Open it to continue." But the onMounted auto-send at :93 is gated on email.value being truthy, so in exactly this branch no email is sent. The page asserts a delivery that never happened. Why it matters: The user waits for mail that does not exist, then presses Resend and is told to "sign in again" with no way to do so (finding 1). The first honest signal they get is an error. Fix: In the no-email branch, replace the confirmation subtitle with the recovery state — say the session could not be read, and surface the sign-out / sign-in-again route directly rather than only as an error after a failed resend.
7. "Checking…" does not name what is being checked — Low · CONTENT-04
Where: apps/web/src/pages/VerifyEmailPage.vue:137; apps/web/src/i18n/messages/en.yml:537What: The I've verified my email button swaps to the bare word "Checking..." (nb: "Sjekker...") while /me is in flight. Why it matters: The label loses the only clue about what the wait is for, at the moment the button's own text is the user's only feedback. Minor, but it is the one waiting state on the page. Fix: Use "Checking verification status…" / "Sjekker bekreftelsesstatus…". Also prefer the ellipsis character over three periods, matching the pattern's reference copy.
8. No distinct document title — Low · NAV-03
Where: apps/web/index.html:21, apps/web/src/router/index.ts (no title meta, no afterEach title hook) What: <title>Ådalen App</title> is static; a grep for document.title / useTitle across apps/web/src returns nothing. Every route, including verify-email, shares one title. Why it matters: Tab titles, history entries and screen-reader page announcements are identical everywhere, so a user with several portal tabs open cannot find the one waiting on verification. This is an app-wide defect, not specific to this feature — file once at project level rather than per feature. Fix: Add meta.title per route plus a router.afterEach that sets document.title from the i18n key, falling back to the app name.
Unverified
- A11Y-01 (contrast) — needs computed colour values on rendered PrimeVue
Messageseverities (success,error) againstbg-paper. Not assertable from code. - A11Y-06 (short viewport / mobile keyboard) — needs a rendered viewport. The layout is a single centred card with a
flex-col sm:flex-rowbutton row, which reads as mobile-first correct, but that is not verification. - MSG-01 (live regions) — the status and error blocks are PrimeVue
Messagecomponents (:110,:118). PrimeVue 4'sMessageis documented to renderrole="alert", which would satisfy MSG-01, butnode_modulesis not installed in this checkout so the rendered ARIA was not read from source. Worth confirming in a browser; ifMessagedoes not carry a live-region role, both blocks are silent DOM swaps and this becomes a real MSG-01 failure. - Duplicate verification emails. The auto-send suppression key
aadalen:verify-email-sent(:23,:63; also written bySignUpPage.vue:51) lives insessionStorage, which is per-tab and per browser session. Opening the portal in a second tab, or returning after a browser restart, re-triggers the mount-time send at:93-95. Whether this actually reaches the user as duplicate mail or a rate-limit error depends on the backend's own throttling, which was not read — noted rather than filed.
Baseline additions
- MSG-06 — A gate the router enforces must expose the escape hatch that clears it. If a guard confines the user to one route until a condition is met, that route must itself carry every control needed to satisfy or abandon the condition — at minimum sign-out. Rationale: MSG-04 covers dead ends reached by content ("expired token"), but not dead ends created by the router, where the missing control lives in a layout the user can no longer reach. This one defect shape recurs anywhere an onboarding layout is split from the app layout, so it is worth a rule of its own.
Cross-project note
- MSG-04 / proposed MSG-06 — likely in playout and members, both of which use a separate pre-app layout for onboarding/verification; worth checking whether either exposes sign-out outside the authenticated shell.
- MSG-02 — near-certain across all four. In this repo the shared
getErrorMessagehelper inpackages/domain-helpersprefers the raw backend message over the localised fallback by design, so every call site in customer-portal inherits the defect. Any project with an equivalent "best-effort message from unknown error" helper has the same problem; the fix belongs in the helper's contract, not in each page. - NAV-03 — app-wide here; check whether playout, members and tt-time-tracker set per-route titles.
- FORM-06 — applies to every PrimeVue
Button :loadingbinding in customer-portal and tt-time-tracker (both PrimeVue 4). Likely dozens of sites.