Appearance
[UX] customer-portal — Sign up
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Adalen-Truck/customer-portal · Branch:
develop@693a76c· Files reviewed: 14 Patterns:authentication/signup,forms/password
Summary
SignUpPage.vue is a clean, well-labelled four-field form that gets the basics right (associated <label>s, correct autocomplete tokens, paste allowed, nothing focusable between the last field and submit). Everything around the password, however, is missing: no reveal toggle, no strength meter, no stated minimum length, and no live confirmation-match feedback — despite ProfilePage.vue already implementing all four with PrimeVue's Password component in the same repo. The single most important finding is that the two most common signup failures (email already registered, password too short) are never handled in the client: better-auth's raw English error.message is piped straight to the user, so a Norwegian-locale customer sees an untranslated SDK string with no instruction on how to fix it.
Findings
1. Raw better-auth error strings are shown to the user, untranslated — High · MSG-02 · MSG-03 · CONTENT-01
Where: apps/web/src/pages/SignUpPage.vue:47, and the catch path at apps/web/src/pages/SignUpPage.vue:59 → packages/domain-helpers/src/error.ts:12What: errorMessage.value = result?.error?.message || t("auth.signUp.error") prefers the SDK's own message over the localized string. getErrorMessage() does the same thing in the catch branch — it returns error.message or error.body.message first and only falls back to the t() string when both are absent. No error code is mapped. The two failures that actually happen on this form are the ones better-auth answers with English prose: an already-registered email, and a password below the server minimum. There is no auth.signUp.* locale key for either case (apps/web/src/i18n/messages/en.yml:459-473, nb.yml:459-473 — parity is otherwise complete for this feature). Why it matters: A Norwegian customer on the primary conversion path is shown an English sentence produced by a library, sitting inside an otherwise fully translated page. Worse, the copy is diagnostic rather than instructive: nothing tells them the minimum length, and nothing offers the obvious next action when the email is already registered ("sign in instead" / "reset your password") — the authentication/signup pattern names No Duplicate Account Detection as a top anti-pattern for exactly this reason. The only localized fallback, "Sign up failed" / "Registrering feilet", is a dead end on its own (MSG-03). Fix: Switch on result.error.code (better-auth exposes USER_ALREADY_EXISTS, PASSWORD_TOO_SHORT, etc.) and add auth.signUp.emailTaken, auth.signUp.passwordTooShort and a rate-limit key to both locales. Make emailTaken link to auth.sign-in (carrying the email). Never render error.message; keep t("auth.signUp.error") as the sole fallback, and give it a next step ("Try again, or contact support if this keeps happening").
2. No password guidance: no strength meter, no stated minimum, no client-side length check — Medium · SEC-05 · FORM-04
Where: apps/web/src/pages/SignUpPage.vue:110-123 (password field); apps/api/src/auth/auth.config.ts:182-188 (server config) What: The password field is a bare InputText type="password". There is no minlength, no hint text, no aria-describedby, and no strength meter. validatePasswords() (SignUpPage.vue:19-25) checks only equality — length is never checked client-side. Server-side, emailAndPassword does not set minPasswordLength, so the minimum is whatever better-auth defaults to (documented as 8; I could not confirm it against the installed package — this checkout has no node_modules). SEC-05's second clause ("prefer a strength meter") is unmet, even though ProfilePage.vue:366-393 already renders a four-bar strength meter, an "at least 8 characters" warning and a toggle-mask<Password> for the change-password form. Why it matters: The user cannot discover the rule before they break it. They type a short password, submit, and get finding #1's untranslated server error back. The authentication/signup reference implementation puts minlength="8", a visible "At least 8 characters" hint wired via aria-describedby, and a role="progressbar" strength meter on this field. Fix: Replace both InputText type="password" with PrimeVue <Password> (fluid, toggle-mask) and lift the meter + tooShort hint out of ProfilePage.vue into a shared component used by sign-up, reset-password and profile. Add minPasswordLength: 8 explicitly to auth.config.ts so the contract is stated in the repo rather than inherited from a library default.
3. Confirm-password gives no affirmative match feedback and only validates on submit — Medium · FORM-07
Where: apps/web/src/pages/SignUpPage.vue:19-25 and :125-138What: The mismatch check runs once, inside handleSubmit. Until the user presses "Create account" there is no signal either way, and when it fires the message is rendered in the shared error <Message> at :140-146 — below both password fields, not associated with the confirm input via aria-describedby, and with no aria-invalid on the field. There is no positive "passwords match" state at any point. ProfilePage.vue:138 + :411-417 at least validates live on the same page (still mismatch-only). Why it matters: With both fields masked (see #4) and no live feedback, the user has no way to tell whether they typed the second password correctly until they submit — the classic masked-confirm trap. A screen-reader user who lands back on the confirm field after the error hears nothing that connects the error to that field. Fix: Make the comparison a computed, show it from the first keystroke in the confirm field: a mismatch message and an affirmative match indicator (check icon + visually-hidden "Passwords match"). Bind :aria-invalid and aria-describedby="signup-confirm-password-error" on the input, and keep the shared <Message> for server errors only.
4. Neither password field has a reveal toggle — Medium · FORM-03
Where: apps/web/src/pages/SignUpPage.vue:115-122 and :130-137What: Both are plain InputText type="password". No show/hide control exists anywhere on the page. PrimeVue's Password (with toggle-mask) is already used three times in ProfilePage.vue:350-410, so the component is available and the house style for it is established. Why it matters: On the mobile-first surface this app targets (the repo's own apps/web/CLAUDE.md says phones are the primary device) a masked field the user cannot inspect is the main cause of a mistyped password — and here it is mistyped twice, into an account they then cannot sign into. The forms/password pattern lists the visibility toggle as core anatomy, not an enhancement. Fix: <Password v-model="password" toggle-mask fluid :feedback="false" /> for both fields (the meter from #2 replaces feedback). Confirm PrimeVue renders the toggle as type="button" with an accessible name and aria-pressed; add them via pt if not.
5. The page has no <h1> — Medium · A11Y-04
Where: apps/web/src/pages/SignUpPage.vue:68-71 (<Card> #title slot); apps/web/src/layouts/DefaultLayout.vue (no heading anywhere) What: "Create your account" is passed to PrimeVue Card's title slot, which renders inside a div.p-card-title — not a heading element. Neither the layout nor App.vue supplies one, so the document has zero h1 and no headings at all. (I could not read the installed PrimeVue source to re-confirm the Card title element — no node_modules in this checkout — but this matches PrimeVue 4's documented Card markup.) Why it matters: A screen-reader user navigating by heading (the normal way to orient on an unfamiliar page) finds nothing. The authentication/signup reference markup opens with <h1>Create your account</h1> for this reason. The same defect applies to SignInPage.vue, VerifyEmailPage.vue and every other Card-titled page, so the fix is worth doing centrally. Fix: Render the title as a real heading — either <template #title><h1 class="…">…</h1></template> on the auth pages, or a pt override on the shared Card registration that sets the title element to h1 (registered at main.ts:59).
6. First field is not focused on mount — Low · FORM-11
Where: apps/web/src/pages/SignUpPage.vue:86-92What: No autofocus and no onMounted focus call on #signup-name. This is a single-purpose page reached by an explicit "Sign up" action, so the user's next act is guaranteed to be typing into the first field. Why it matters: Every user — keyboard and mouse alike — pays an extra click/Tab. SignInPage.vue, ForgotPasswordPage.vue and ResetPasswordPage.vue share the omission, so it is an app-wide habit rather than an oversight here. Fix: Add autofocus to the name input (or inputRef.value?.$el.focus() in onMounted).
7. Every route shares one document title — Low · NAV-03
Where: apps/web/index.html:21 (<title>Ådalen App</title>); no document.title / meta.title handling exists anywhere in apps/web/src/router/index.ts, App.vue or main.ts (grep: zero hits). What: The sign-up route sets no title, so the tab reads "Ådalen App" identically to every other page. Why it matters: Browser history, tab switching, bookmarks and screen-reader page announcements are all undifferentiated. This is app-wide, not sign-up specific — filed here because it is the first feature audited that trips it, and best fixed once in the router. Fix: Add meta.title to each route record and set document.title = t(to.meta.title) + " · Ådalen" in an afterEach hook.
8. No re-entrancy guard on submit — Low · FORM-06
Where: apps/web/src/pages/SignUpPage.vue:27-34 and :148-154What: handleSubmit never checks isSubmitting before proceeding; the only thing standing between a double-click and two signUp.email calls is PrimeVue Button's loading prop. I could not read the installed PrimeVue source, so one of two rules fails: if loading applies disabled (PrimeVue 4's documented behaviour), the button the user just activated is disabled mid-flight and focus drops to <body> — a FORM-06 breach; if it does not, the form can be submitted twice. The authentication/signup pattern lists "No loading state on submit → duplicate submissions" among its anti-patterns. Why it matters: Either a lost focus position for keyboard/screen-reader users mid-submit, or a duplicate account-creation request on a flaky mobile connection. Fix: if (isSubmitting.value) return; at the top of handleSubmit, and express the busy state with aria-busy on the form plus a "Creating account…" label swap, rather than relying on the button's disabled state.
Unverified
- A11Y-01 (contrast).
text-text-2onbg-paperfor the subtitle and the "Already have an account?" line, andtext-accentfor the inline "Sign in" link, all resolve through the ÅDALEN token layer inpackages/ui/src/styles/theme.css. Needs a contrast tool against rendered values, in both the default and.my-app-darkthemes. - A11Y-06 (short viewport / responsive). The form is a
max-w-mdcolumn of four stacked fields; plausible at 320px, but the mobile-keyboard-open case and a ~700px-high viewport need a rendered page. - PrimeVue-internal markup. Three claims above depend on component internals I could not read (no
node_modulesin this checkout):Card's title element (finding #5),Button'sloading→disabledbehaviour (finding #8), and whetherMessagecarriesrole="alert"— if it does, MSG-01 passes for the error block; if it does not, the mismatch and server errors are silent DOM swaps for screen-reader users. Worth one pass with the app running. - Checked and deliberately not filed: A11Y-02 on the inline "Sign in" link (
:161-166) — its hit area is thetext-smline box (~20px), but WCAG 2.5.8 exempts targets inline in a sentence, so this passes. - SEC-01 tension, not a finding.
signUp.emailnecessarily reveals whether an email is registered, and theauthentication/signuppattern actively recommends saying so ("An account with this email already exists — sign in instead?"). SEC-01 as written would forbid that. Flagging for the cross-project pass: SEC-01 probably needs a carve-out for registration, where enumeration is a deliberate trade-off, versus sign-in and password-reset, where it is not.
Baseline additions
- NAV-06 — The post-auth destination survives every hop of the auth flow. Evidence here:
SignUpPage.vue:53-57carefully forwardsroute.query.redirecttoauth.sign-in, but (a) the only in-app link into sign-up,SignInPage.vue:176(to="/sign-up", mirrored byDefaultLayout.vue:42), hardcodes the path and drops the query, so the value is almost never present; and (b) the guard'sreturn { name: "verify-email" }(router/index.ts:666) forwards no query, so even when it is present the destination is discarded. A user deep-linked to/marketplace/123who signs up lands on the dashboard afterwards with no memory of where they were headed. Related: because better-auth auto-signs-in onsignUp.email, the push toauth.sign-inatSignUpPage.vue:54is never the page the user sees — the guard immediately bounces them toverify-email, costing an extra navigation and a/meround-trip. Pushing{ name: "verify-email" }directly would be both faster and honest about the intent. - CONTENT-05 — Account creation links the terms and privacy policy the user is agreeing to. The form has no terms text and no link, and grep finds no
acceptTerms/termsAcceptedanywhere inapps/web,apps/apior the Prisma schema — yet atermsroute and an admin terms editor both exist. Theauthentication/signuppattern treats terms-with-readable-links as standard anatomy, and GDPR expects a recorded consent timestamp. Likely relevant to all four projects. - FORM-12 — A sign-up page offers the same identity providers as its sign-in page.
SignInPage.vue:160-168offers "Continue with Microsoft";SignUpPage.vueoffers email/password only. Since a first Microsoft sign-in creates the account anyway, a customer who wants a Microsoft account has to guess that the sign-in page is where you sign up. (Deliberate for internal staff —auth.config.ts:219-230forces them to Microsoft — but customers get no such steer.)
Cross-project note
- MSG-02 (raw SDK strings) —
SignInPage.vue:31and:61andVerifyEmailPage.vue:58do exactly the same thing in this repo, so queue rows #2, #4 and #5 will trip it too. All four projects front a third-party auth SDK (better-auth here, Firebase in playout), so check playout, members and tt-time-tracker for the sameerror.messagepassthrough. - SEC-05 / FORM-03 (no meter, no reveal toggle) — tt-time-tracker is also on PrimeVue 4 and the baseline's per-project note flags the same "prefer
Passwordover hand-rolling" risk there. Expect the same defect. - A11Y-04 (no
h1) — a consequence of using a component library's card title as the page heading; likely repeats wherever the other three do the same. - NAV-03 (no per-route title) — worth checking in all four; it is a ten-line router fix in each.