Appearance
[UX] customer-portal — Password reset
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:authentication/password-reset
Summary
The backend half of this flow is largely right — better-auth is configured with a 1-hour token TTL, per-endpoint rate limits and revokeSessionsOnPasswordReset: true (SEC-03 passes cleanly, server-side). The frontend half is the weak side: the reset page never validates the token before rendering the form, has no link out of any error state, and hand-rolls two bare InputText type="password" fields with composition rules — while the same app's Profile page already uses PrimeVue <Password toggle-mask> with a strength meter and an 8-character rule. The single most important defect is SEC-02: a user arriving with an expired or tampered link is shown a full form, types a new password twice, and only then gets a raw untranslated better-auth error with no way to request a fresh link.
Note on severity: the brief's table makes any SEC failure a Blocker. I have reserved Blocker for SEC-02 (unambiguous, fully code-verifiable, gates the form) and rated SEC-01/SEC-04 High, because SEC-01 fails only on its timing clause (the copy clause passes) and SEC-04 is a missing notification rather than a broken gate. Flagging so the counts can be normalised if the programme prefers the strict reading.
Findings
1. Reset token is never verified before the form is rendered — Blocker · SEC-02
Where: apps/web/src/pages/ResetPasswordPage.vue:18-21, :48-51What: resetToken is a computed that only checks typeof token === "string". Nothing calls the server to validate it on mount. Validity is discovered at authClient.resetPassword() (:67), i.e. after the user has typed and confirmed a new password. A visitor with no ?token= at all gets the identical full form and is told auth.resetPassword.tokenMissing (en.yml:451) only on submit. Why it matters: Reset links land in email, are often opened on a second device, and routinely expire (1 hour, apps/api/src/auth/auth.config.ts:186). The most common real-world arrival at this page — a stale link — costs the user two password entries before it fails, and the failure is terminal (see finding 2). The pattern's functional test list names "Validate the token on the reset page" explicitly. Fix: Verify on mount and render three real states: validating (skeleton), invalid/expired (message + link to auth.forgot-password), valid (form). better-auth 1.4 has no dedicated verify endpoint, so either add a thin GET /auth/reset-password/verify?token= on the NestJS side, or at minimum reject malformed/absent tokens before mount (the pattern's own "Validate token format client-side" optimisation) so the no-token case never renders a form.
2. Every failure on the reset page is a dead end — High · MSG-04
Where: apps/web/src/pages/ResetPasswordPage.vue:89-167 (whole template) What: The page contains no RouterLink at all. Not to sign-in, not to forgot-password. tokenMissing, error and the raw server errors all render inside a Message with nothing actionable beside it. Contrast ForgotPasswordPage.vue:99-104, which does carry a "Back to sign in" link. Why it matters: The user who most needs a route out — expired link, wrong link, link opened twice — is left on a form that cannot succeed, with only the browser back button (which returns them to their mail client, not the app). The pattern lists both No "Back to Login" Path and Expired Token Without Guidance as named anti-patterns. Fix: Add a persistent "Back to sign in" link below the submit button (mirroring the forgot page), and make the expired/invalid message contain an inline RouterLink to auth.forgot-password worded "Request a new reset link".
3. Forgot-password never inspects the API result — failures render as success — High · MSG-06 (proposed)
Where: apps/web/src/pages/ForgotPasswordPage.vue:26-31What: await authClient.requestPasswordReset({...}) discards its return value and line 31 sets the success message unconditionally. The better-auth Vue client (better-auth@^1.4.18) resolves to { data, error } and does not throw on non-2xx, so the catch at :32 only fires on network/DNS failure. Every server-side failure therefore displays "If an account exists for this email, a reset link has been sent." SignUpPage.vue:46-47 in the same app gets this right (if (!result?.data || result?.error)), so this is an oversight, not house style. Why it matters: The rate limit is real and tight — 5 requests per 15 minutes on /request-password-reset (auth.config.ts:172-175). The 6th attempt by a user who did not receive the first five emails returns 429 and is shown as success. So does an SMTP outage, and so does getSmtpConfig() throwing "SMTP configuration is missing" (auth.config.ts:28-30). The user waits indefinitely for an email that was never sent, with no signal that anything went wrong. Locked out, silently. Fix: Destructure the result and branch on error, as SignUpPage does. Map 429 to a specific "Too many requests — try again in 15 minutes" string; everything else to auth.forgotPassword.error. Keep the enumeration-safe copy for the success path only. (No existing baseline rule covers "the client ignores a {data, error} result and reports success unconditionally" — proposed as MSG-06 below.)
4. Response timing distinguishes registered from unregistered addresses — High · SEC-01
Where: apps/api/src/auth/auth.config.ts:80-95 (sendResetPasswordEmail), wired at :185What: The copy clause of SEC-01 passes — en.yml:438 / nb.yml:438 say "If an account exists…" and the same message shows either way. The timing clause does not. better-auth awaits sendResetPassword before responding, and that callback performs a synchronous SMTP handshake and transport.sendMail(). An unknown address short-circuits before the callback and returns in milliseconds; a known address returns only after the mail server round-trip. The gap is large (tens to hundreds of ms) and trivially measurable by an attacker scripting the endpoint. Why it matters: It reconstructs the account-enumeration oracle the careful copy was written to prevent. The pattern is explicit: "Ensure response times are identical to prevent timing attacks" and "Return the same response and timing regardless of whether the email exists." Fix: Do not await the mail send on the request path. Enqueue it (BullMQ is already in the stack, packages/queues) and return immediately, or wrap the whole handler in a fixed-floor delay. The pattern's own performance section recommends exactly this: "Show confirmation immediately; send email in background." Caveat: I have measured no timings — the magnitude of the gap is inferred from the awaited SMTP call, not observed. The structural cause is certain; the size is not.
5. A completed password reset sends no confirmation email — High · SEC-04
Where: apps/api/src/auth/auth.config.ts:182-188What: emailAndPassword configures sendResetPassword (the request email) but there is no hook on reset completion. Grepping the API surfaces exactly one password-related mail template. The user is never told out of band that their password changed — even though the same commit sets revokeSessionsOnPasswordReset: true, so every one of their other sessions is silently terminated at the same moment. Why it matters: This is the single control that lets a victim notice an account takeover. Without it, an attacker who completes a reset leaves no trace the user sees; the legitimate user just finds themselves logged out everywhere with no explanation. The pattern lists "Notify user — Send an email confirming the password was changed" under Session Invalidation. Fix: Add an after hook on the reset-password path (or a databaseHooks.account.update hook) that sends a "Your password was changed" mail naming the time and offering a contact route if it wasn't them. Reuse the existing nodemailer transport.
6. Raw better-auth error strings reach the user, in English, in both locales — Medium · MSG-02
Where: apps/web/src/pages/ResetPasswordPage.vue:73; ForgotPasswordPage.vue:33What: errorMessage.value = result.error.message ?? t("auth.resetPassword.error") surfaces the provider string verbatim — better-auth emits things like "invalid token", "token is expired", "password too short". On the forgot page, getErrorMessage(error, fallback) (packages/domain-helpers/src/error.ts:12-31) returns error.message before ever reaching the translated fallback, so the fallback is close to unreachable whenever the error has any message at all. Why it matters: A Norwegian user (nb is a first-class locale here) gets lowercase English SDK jargon in the middle of a fully translated page. It also leaks server vocabulary and reads as a crash. CONTENT-01 parity is otherwise clean for this feature — every key exists in both en.yml and nb.yml — so this is the only copy that escapes i18n. Fix: Map better-auth's documented error codes (INVALID_TOKEN, TOKEN_EXPIRED, PASSWORD_TOO_SHORT) to auth.resetPassword.* keys and fall back to the generic translated sentence for anything unrecognised. Never render error.message directly.
7. Password fields have no reveal toggle, though the app already has one — Medium · FORM-03
Where: apps/web/src/pages/ResetPasswordPage.vue:109-117, :129-133What: Both fields are bare <InputText type="password">. No toggle, no aria-pressed, no accessible name. ProfilePage.vue:350-411 — the same app, the same task — uses PrimeVue <Password toggle-mask> three times. Why it matters: The user is composing a new password against a 5-clause policy (finding 9) entirely blind, and typing it twice blind. This is the highest-value place in the whole app for a reveal control, and it is the one place that lacks it. The per-project note in BASELINE.md names PrimeVue Password as the intended answer here. Fix: Swap both InputText for <Password toggle-mask fluid>, matching ProfilePage. Keep autocomplete="new-password" (correctly set today — FORM-02 passes).
8. Confirm-password is validated only on submit, with no affirmative match feedback — Medium · FORM-07
Where: apps/web/src/pages/ResetPasswordPage.vue:53-56What: The mismatch check runs inside handleSubmit. There is no watcher, no @blur handler, and nothing that confirms a successful match. ProfilePage.vue:138, :411-417 again does it correctly, showing a live mismatch hint as the user types. Why it matters: With both fields masked (finding 7), a typo is undetectable until submit, and the submit wipes nothing but tells the user only that two invisible strings differ — they must clear both and start over. Password Mismatch Not Caught is a named anti-pattern in the corpus, and it recommends real-time validation plus a checkmark when the fields agree. Fix: Add a computed passwordsMatch; render a green check + "Passwords match" when both fields are non-empty and equal, and the mismatch hint otherwise. Wire both to aria-describedby on the confirm field.
9. Composition rules instead of a strength meter, and a policy that contradicts the rest of the app — Medium · SEC-05
Where: apps/web/src/pages/ResetPasswordPage.vue:24-42; en.yml:453What: getPasswordPolicyError enforces ≥10 characters and uppercase and lowercase and digit and symbol, surfacing one failure at a time. Meanwhile ProfilePage.vue:143/:150 requires only 8 characters and shows a 4-bar strength meter, and profile.password.newPlaceholder (en.yml:403) says "At least 8 characters". The backend enforces neither — better-auth's default minPasswordLength (8) is unchanged in auth.config.ts:182-188. Why it matters: Three consequences. (a) The length clause of SEC-05 passes, but NIST 800-63B and the pattern both prefer length + a meter over composition rules, which push users toward Password1!. (b) One-error-at-a-time means up to five submit cycles to discover the whole policy. (c) The user who satisfies a 10-character policy at reset can immediately weaken it to 8 characters from Profile — so the strict rule is theatre, and the two screens tell the same user different things about the same account. Fix: Pick one policy and put it in one place. Recommended: length-based minimum (≥8, ideally ≥12 encouraged) plus the strength meter that already exists on ProfilePage, enforced server-side via better-auth's minPasswordLength. Extract the meter into @aadalen/ui so both screens use it.
10. Submit button disables itself during submission — Medium · FORM-06
Where: apps/web/src/pages/ForgotPasswordPage.vue:87-93; ResetPasswordPage.vue:156-162What: Both use <Button :loading="isSubmitting">. PrimeVue 4's Button binds :disabled="disabled || loading", so activating the button disables the element the user just activated. Why it matters: A disabled element cannot hold focus, so focus drops to <body>. A keyboard or screen-reader user loses their place mid-flow and, when the result message renders, has to traverse the page from the top to find it. handleSubmit already guards nothing against double-submit — the disable is the only guard, and it is the wrong one. Fix: Keep the spinner, drop the disable: use :loading="isSubmitting" with :disabled="false" plus aria-busy, and add an early if (isSubmitting.value) return; in the handler. Basis: node_modules is not installed in this working copy, so I could not read PrimeVue's source. This rests on PrimeVue 4's documented loading semantics, not on inspection of the shipped component.
11. Neither page has an <h1> — Medium · A11Y-04
Where: apps/web/src/pages/ForgotPasswordPage.vue:42-45; ResetPasswordPage.vue:91-94; layout at apps/web/src/layouts/DefaultLayout.vue:15-60What: The page title goes through PrimeVue <Card>'s #title slot, which renders a styled div, not a heading. DefaultLayout supplies <header> and <main> landmarks but no heading either. Result: both pages have zero headings of any level. Why it matters: Heading navigation (the primary screen-reader wayfinding mechanism) finds nothing on the page, and the accessible page name is left to the document title — which is the same generic string on every route (finding 14). A blind user landing from an email link has no announced confirmation of which page they reached. Fix: Put a real <h1> inside the Card title slot on both pages (and on SignIn/SignUp, which share the shape). The pattern's reference HTML opens with <h1>Reset your password</h1>.
12. Token-bearing URL is crawlable and has no referrer policy — Medium · NAV-04
Where: apps/web/public/robots.txt:2; apps/web/nginx.conf (no add_header for security headers); apps/web/index.html (no <meta name="robots">) What: robots.txt is User-agent: * / Disallow: — an explicit allow-everything. nginx.conf sets cache headers only: no X-Robots-Tag, no Referrer-Policy, no Content-Security-Policy. The reset token travels in ?token= on a route that is otherwise entirely public. Why it matters: NAV-04 requires both noindex and a no-referrer policy enforced at the response header, precisely because a runtime meta tag in an SPA arrives too late. A reset URL pasted into any indexed surface becomes crawlable, and any future outbound link or third-party asset on the page would carry the token in Referer. Modern browser defaults (strict-origin-when-cross-origin) blunt the referrer half today, which is why this is Medium and not High — but it is a default, not a decision. Fix: In nginx.conf, add location ~ ^/(reset-password|verify-email) { add_header X-Robots-Tag "noindex, nofollow"; } and set Referrer-Policy: no-referrer globally. Add Disallow: /reset-password to robots.txt.
13. Success state is not a step, and says nothing about what happens next — Medium · CONTENT-03
Where: apps/web/src/pages/ForgotPasswordPage.vue:71-77; en.yml:438What: On success a green Message appears inside the still-populated form. The heading still reads "Forgot your password?", the email field still holds the address, and the submit button still invites another send. The copy — "If an account exists for this email, a reset link has been sent." — omits the spam-folder note, the 1-hour expiry (auth.config.ts:186), and any resend affordance. Why it matters: The corpus names "email in spam" as a top drawback of this pattern and prescribes a distinct "Check your email" confirmation step carrying spam_note and resend. As built, the user cannot tell whether the send succeeded, cannot tell how long they have, and if they resend they will be silently rate-limited (finding 3). Fix: Replace the form with a confirmation state on success: heading "Check your email", the address echoed, "Don't see it? Check your spam folder.", "The link expires in 1 hour", and a "Resend" control with a visible cooldown. Also satisfies NAV-05.
14. Internal (Entra-only) users can complete a reset they can never use — Medium · MSG-03
Where: apps/api/src/auth/auth.config.ts:219-230 (before-hook) vs :140-161What: assertEmailPasswordSignInAllowed blocks email/password sign-in for users holding an internal role, but the hook guards ctx.path === "/sign-in/email" only. /request-password-reset and /reset-password are unguarded. An admin or logistics user can therefore request a link, receive it, set a new password (which also revokes their sessions), be redirected to sign-in, and be told "Use Microsoft sign-in" — a raw APIError message (:159), untranslated, rendered via SignInPage.vue:61. Why it matters: A complete, apparently successful multi-step flow that ends in a wall, after logging the user out of everywhere. They have done real work for nothing, and the rejection arrives at the last possible moment. The forgot-password page never hints that credentials are not how they sign in. Fix: Extend the before-hook to /request-password-reset. For internal users, still return the enumeration-safe success response (do not leak role membership via a distinguishable error), but send them a "Your account signs in with Microsoft" email instead of a reset link. Also add a "Sign in with Microsoft" link to the forgot-password page, mirroring SignInPage.
15. No field is focused on mount — Low · FORM-11
Where: apps/web/src/pages/ForgotPasswordPage.vue:60-68; ResetPasswordPage.vue:109-117What: Neither page has autofocus or an onMounted focus call. Both are single-purpose forms whose entire content is one or two inputs. Why it matters: Every user pays an extra click or Tab, and the reset page is overwhelmingly reached by clicking a link in an email client — the highest-friction possible arrival. Keyboard users must first traverse the header's logo, language switcher and two auth buttons (DefaultLayout.vue:20-46) before reaching the field. Fix: Focus the email field on the forgot page and the new-password field on the reset page, on mount (once the token is confirmed valid, per finding 1).
16. The password policy hint sits after both fields and is not associated with them — Low · FORM-04
Where: apps/web/src/pages/ResetPasswordPage.vue:136-138What: policy.summary renders in a <small> positioned below the confirm field, with no id and no aria-describedby from either input. FORM-04's "before the user types" is technically met (it is on screen at mount) but the reading order puts the rules after the fields they govern, and screen-reader users focusing the password input hear nothing. Why it matters: A sighted user typing into the first field has the constraints below their point of attention; a screen-reader user has to hunt for them. Combined with one-error-at-a-time validation (finding 9), the policy is effectively discovered by trial. Fix: Move the hint between the new-password label and the confirm field, give it id="reset-password-hint", and add aria-describedby="reset-password-hint" to the new-password input. The pattern's reference HTML does exactly this.
17. No route sets a document title — Low · NAV-03
Where: apps/web/src/router/index.ts:46-59 (no meta.title); apps/web/index.html:21What: There is no title management anywhere in apps/web/src — no document.title assignment, no useHead, no unhead/@vueuse/head dependency. Every route in the app shows "Ådalen App". Why it matters: Tab titles, browser history and bookmarks are indistinguishable, and with no <h1> either (finding 11) the reset page has no accessible name at all. This is app-wide, not feature-specific — recording it here because both audited routes fail it, but it should be fixed once at the router level. Fix: Add meta.title to each route record and a single router.afterEach that sets document.title. Two i18n keys needed for this feature.
Unverified
- A11Y-01 (contrast). Cannot be settled from code. The pages use ÅDALEN semantic tokens (
text-text-2,text-accent,text-xs) correctly per the repo's own rules, but the computed values oftext-text-2onbg-paperand oftext-accenton the back-to-sign-in link need a contrast tool. The<small class="text-xs text-text-2">policy hint (ResetPasswordPage.vue:136) is the most likely failure: muted ramp at 12px. - A11Y-06 (short viewport / mobile keyboard). Needs a rendered page. Worth checking: the reset page is a single
max-w-mdcard with two fields, a hint, up to twoMessageblocks and a button — with a mobile keyboard open at ~360×700 the submit button and any error message may both be below the fold. This app is explicitly mobile-first (apps/web/CLAUDE.md§12) and Capacitor-wrapped, so it matters more here than elsewhere. - A11Y-02 (target size). The only small target is the "Back to sign in" text link (
ForgotPasswordPage.vue:99-104),text-smwith no padding — likely under 24px tall, but that needs a rendered box. - A11Y-03 (focus indicators). PrimeVue 4 ships focus rings; whether the ÅDALEN theme override in
packages/ui/src/styles/theme.csspreserves them was not checked. - MSG-01 (
role="alert"/role="status"). Both pages use PrimeVue<Message>, which I believe rendersrole="alert" aria-live="assertive".node_modulesis not installed in this working copy, so I could not confirm. If correct, errors pass, but the success message is announced assertively where MSG-01 wantsrole="status"/ polite — a minor deviation, not filed as a finding pending confirmation. - SEC-01 timing magnitude. The structural cause is confirmed in code (finding 4); the size of the observable gap has not been measured against a running instance.
Baseline additions
- MSG-06 — When a client method returns a result object rather than throwing (
{ data, error }), the handler must branch onerrorbefore showing success. Atry/catcharound a non-throwing call is not error handling. Justification: this is exactly finding 3, it is invisible to typecheck and lint, it silently converts a locked-out user into a confident one, and it is a whole-class defect for any better-auth, Supabase or@hey-apiclient — all three of which appear across these four projects. Cheap to check by reading code, library-agnostic. - SEC-06 (candidate, weaker) — A credential-recovery flow is not offered to accounts that cannot authenticate with credentials; if it must be offered for enumeration reasons, the response must not be a working reset. Justification: finding 14. Proposed as a candidate rather than a firm rule because it needs a second sighting before promotion — it may be specific to apps mixing SSO and local passwords, though playout, members and tt-time-tracker all plausibly do.
Cross-project note
- playout — this is the direct analogue that seeded BASELINE.md, so SEC-02, MSG-04 and CONTENT-03 should be compared finding-for-finding. The interesting divergence is SEC-03: customer-portal passes it declaratively (
revokeSessionsOnPasswordReset: true) where playout neededverifyIdToken(token, true). SEC-01's timing clause is likely shared — Firebase'ssendPasswordResetEmailis awaited on the request path in the same way. SEC-04 (no post-reset confirmation email) is worth checking there too. - tt-time-tracker — same stack shape (PrimeVue 4, NestJS). FORM-03/SEC-05 (bare
InputText type="password"where PrimeVuePasswordexists) and FORM-06 (:loadingdisabling the submit) are near-certain to recur; the FORM-06 defect is a property of PrimeVue's Button, so it will appear on every PrimeVue form in both repos and may deserve promotion to a project-level finding rather than per-feature. - members — NAV-03 and A11Y-04 are the likely shared ones; the
@bcc-codecomponent library may already supply a heading-bearing card, so check upstream first. - All four — MSG-06 (proposed) and NAV-04 (noindex/referrer headers on token-bearing URLs) are infrastructure-level and worth a single sweep across all four repos rather than four separate audits.