Skip to content

[UX] customer-portal — Profile ​

Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Adalen-Truck/customer-portal · Branch: develop @ 693a76c · Files reviewed: 4 Patterns: authentication/account-settings

Summary ​

A well-built, single-page settings screen (identity, password, roles/links, language) with real error/loading states, a working strength meter, full en/nb i18n parity, and a password change that correctly requires the current password. The most important issue is that changing the password does not end other sessions by default — the revoke-others control is opt-in and unchecked — so the one action a user takes after suspecting compromise leaves the attacker's session alive. The rest are form-mechanics and announcement gaps.

Files: apps/web/src/pages/ProfilePage.vue, apps/web/src/domains/profile/profile.mutations.ts, apps/web/src/domains/profile/profile.queries.ts, packages/ui/.../PageLayout.vue (h1 source).

Findings ​

1. Password change leaves other sessions signed in by default — High · SEC-03 ​

Where: apps/web/src/pages/ProfilePage.vue:124,162,420-438; apps/web/src/domains/profile/profile.mutations.ts:52-66What: revokeOtherSessions defaults to false and is only sent as true if the user notices and ticks the "Sign out of all other devices" checkbox. authClient.changePassword is called with that flag verbatim, so on the default path a password change does not invalidate other sessions. Why it matters: SEC-03 expects a password change to invalidate other sessions. Changing a password is the standard reaction to "someone got into my account"; leaving every other session live by default defeats that. The account-settings pattern's "Session Management" section treats ending other sessions as core, not optional. Fix: Default the checkbox to true (let the user opt out of keeping other devices), or always revoke and drop the choice. Confirm server-side that better-auth actually deletes the other sessions. Placed High not Blocker: exploitation needs a pre-existing hostile session, i.e. a hardening gap per the brief's normalisation.

2. Password submit is disabled while invalid, with no message for the empty-current-password case — Medium · FORM-05 ​

Where: apps/web/src/pages/ProfilePage.vue:441-446 (:disabled="!passwordValid || ..."), :142-147What: The submit button is disabled whenever passwordValid is false. passwordValid requires currentPassword.length > 0, but there is no inline message for a missing current password — only newPassword mismatch/too-short messages render. A user who fills new + confirm correctly but leaves "Current password" blank sees a dead, unexplained button. Why it matters: FORM-05 — a disabled submit with no message gives the user nothing to act on; they cannot tell what is blocking the save. Fix: Keep the submit enabled and surface a "Enter your current password" message on attempt, or add an inline required-field hint under the current- password field.

3. Save/submit buttons are disabled during their own pending state — Medium · FORM-06 ​

Where: apps/web/src/pages/ProfilePage.vue:307,314-317 (name), :442-446 (password) What: Both the name-save button (:disabled="!nameDirty || updateMe.isPending.value") and the password submit (:disabled="... || changePassword.isPending.value") disable themselves while the mutation is in flight — the password one is a type="submit" the user just activated. Why it matters: FORM-06 — disabling a just-activated control drops focus to <body>, so keyboard and screen-reader users lose their place mid-submit. The :loading spinner already communicates progress without needing disabled. Fix: Drive the busy state with aria-busy / :loading and guard against double-submit inside the handler, rather than toggling disabled.

4. Password fields carry no autocomplete attributes — Medium · FORM-02 ​

Where: apps/web/src/pages/ProfilePage.vue:350-356,366-373,404-411What: The three PrimeVue Password fields set no autocomplete. A change- password form should mark current as autocomplete="current-password" and the new/confirm fields as autocomplete="new-password". Why it matters: FORM-02 — password managers cannot reliably offer to generate a strong new password or update the stored credential, pushing users toward weaker, reused passwords. Fix: Pass autocomplete="current-password" / "new-password" through to the Password inputs. (Whether PrimeVue forwards the attr to the inner <input> should be confirmed once node_modules is installed — see Unverified.)

5. Two <h1>s render in the error state — Medium · A11Y-04 ​

Where: apps/web/src/pages/ProfilePage.vue:205 inside <PageLayout>, which itself renders <h1>{{ title }} (packages/ui/.../PageLayout.vue, <h1 :class=...>{{ title }}) What: PageLayout always renders the page title as an <h1>. The load-error branch renders a second <h1> ("Couldn't load your profile") as a child of that same PageLayout, so the error view has two <h1>s. (In-repo packages/ui, so asserted from source per PROJECT-LEVEL.) Why it matters: A11Y-04 — exactly one <h1> per page; two breaks the document outline for screen-reader users navigating by heading. Fix: Make the error message an <h2> (or <p>), since PageLayout already owns the page's <h1>.

6. Inline validation and the strength meter change silently — Medium · MSG-01 ​

Where: apps/web/src/pages/ProfilePage.vue:389-394 (too-short), :412-417 (mismatch), :374-388 (strength bars) What: The mismatch and too-short messages appear/disappear as plain <p>s with no role="alert"/aria-live. The strength meter is four colour bars with a static aria-label="Password strength" on the container; the level itself is conveyed by colour + bar count and is not announced. Why it matters: MSG-01 — a screen-reader user gets no notification that their input was rejected or how strong the new password is (WCAG 4.1.3). Fix: Wrap the validation messages in an aria-live="polite" region; expose the strength level as text (e.g. aria-live "Weak/Fair/Strong") rather than colour alone.

7. Generic password error hides the most common cause — Medium · MSG-03 ​

Where: apps/web/src/pages/ProfilePage.vue:169-171; profile.mutations.ts:60-62What: The mutation throws better-auth's result.error.message, but the component catch discards it and always toasts profile.password.error ("Could not change password"). The overwhelmingly common failure — the current password is wrong — is not distinguished, and the toast says nothing about what to do next. Why it matters: MSG-03 — the user is told what went wrong generically but not how to fix it; a wrong-password typo reads identically to a server outage. Fix: Map the known better-auth "invalid password" code to a specific message ("Your current password is incorrect") while keeping a generic fallback for everything else. (Does not conflict with the project-level MSG-02 note — this page does not use getErrorMessage.)

8. Confirm-password shows only mismatch, never a positive match — Low · FORM-07 ​

Where: apps/web/src/pages/ProfilePage.vue:138-140,412-417What: A passwordsMatch computed exists but is never used for affirmative feedback; only the mismatch error renders. The user gets no confirmation that the two entries agree. Why it matters: FORM-07 — confirmation fields should show affirmative match feedback, not only mismatch errors. Fix: Render a check/"Passwords match" indicator when passwordsMatch is true.

9. Removing the profile photo has no confirmation — Low · MSG-05 ​

Where: apps/web/src/pages/ProfilePage.vue:89-96,268-275What: The "Remove photo" text button calls removeAvatar() immediately; the existing image is deleted with no confirm step. Why it matters: MSG-05 — a single stray click destroys the uploaded photo. Low because it is easily re-uploaded, but it is still an unconfirmed destructive action. Fix: Confirm before removing, or offer an undo toast.

Unverified ​

  • A11Y-01 (contrast) — needs a contrast tool. Watch the muted text-text-3 eyebrow labels at text-[10px] and the text-xs hints/links (e.g. :229, :302, :268-275).
  • A11Y-06 (responsive / short viewport) — needs a rendered viewport; the two-column lg:grid-cols-2 layout and sticky mobile title were not run.
  • FORM-02 / FORM-03 forwarding — whether PrimeVue Password forwards autocomplete and gives its toggle-mask reveal button an accessible name is a library internal; node_modules not installed.
  • MSG-01 toast role — all success/error feedback is PrimeVue useToast; whether the Toast container carries role="status"/aria-live is unverifiable from source here.
  • SEC-04 (out-of-band notification) — whether the backend emails the user when the password changes cannot be seen from the frontend; worth confirming server-side.
  • A11Y-02 — the text-xs "Remove photo" button may fall under the 24×24px target minimum; needs measurement in a rendered page.

Baseline additions ​

None. SEC-03 already covers finding 1 as written; consider tightening its wording to "…and defaults to revoking, rather than offering it as an opt-in the user must discover." — but that is a clarification, not a new ID.

Cross-project note ​

Finding 1 (SEC-03 opt-in/default-off session revocation) is likely shared by any other project offering a self-service password change — playout's Profile.vue credential linking and tt-time-tracker/members account screens should be checked for the same default. NAV-03 already confirmed across all four projects.