Appearance
[UX] customer-portal — Sign in
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Adalen-Truck/customer-portal · Branch:
develop@693a76c· Files reviewed: 17 Patterns:authentication/login,forms/password
Summary
The form itself is largely correct: real <label for> pairs, autocomplete="email" and autocomplete="current-password", paste unblocked, submit never gated on validity, and PrimeVue Message for errors. The two things that matter are (a) a server-side pre-credential check that answers differently for employee accounts than for everyone else, which is an account-classification oracle, and (b) the page rendering raw English better-auth strings straight into the error banner — in an app whose default locale is Norwegian. Secondary to those, the page hand-rolls a password input with InputText type="password" while ProfilePage.vue in the same repo already uses PrimeVue <Password toggle-mask>, so there is no reveal toggle here.
Findings
1. Sign-in tells an attacker which addresses belong to Ådalen employees — High · SEC-01
Where: apps/api/src/auth/auth.config.ts:140-161 (assertEmailPasswordSignInAllowed), wired at :219-230; surfaced at apps/web/src/pages/SignInPage.vue:31What: A better-auth hooks.before middleware on /sign-in/email looks the address up in the DB before any credential verification and, if the user holds an internal role, throws APIError("FORBIDDEN", { message: "Use Microsoft sign-in" }). Any unauthenticated visitor can therefore submit an address with a junk password and read back a three-way answer: Use Microsoft sign-in (address exists and is staff), Invalid email or password (everything else), or success. Why it matters: The general account-existence leak SEC-01 targets is avoided — "unknown address" and "known customer" both return the same string — but role is leaked for the highest-value account set in the system. An attacker can enumerate a candidate list of employee addresses and aim phishing at the Entra ID flow instead of the portal. Timing is not an additional signal here, since the DB lookup runs on every attempt. Fix: Do not branch on identity before authentication. Either (a) verify credentials first and only then reject internal users with the Microsoft hint, or (b) return the same generic invalidCredentials string for the internal case and put the "employees sign in with Microsoft" guidance as static copy on the page, where it costs nothing to state publicly.
2. Raw, untranslated better-auth strings are rendered as the error message — Medium · MSG-02, CONTENT-01
Where: apps/web/src/pages/SignInPage.vue:31, :41, :61, :64; helper at packages/domain-helpers/src/error.ts:12-31What: errorMessage.value = result?.error?.message ?? t("auth.signIn.invalidCredentials") prefers the server string and only falls back to i18n when the server sends nothing. getErrorMessage() behaves the same way in the catch branch — it returns error.message verbatim for any Error instance. The auth.signIn.* keys have full en/nb parity (i18n/messages/en.yml:415-431, nb.yml:415-431), but they are mostly bypassed at runtime. Why it matters: The app defaults to nb (apps/web/src/i18n/index.ts:29), so a Norwegian user who mistypes a password sees the English Invalid email or password; an employee sees Use Microsoft sign-in; a rate-limited user sees better-auth's English 429 text; someone offline sees the browser's raw Failed to fetch / Load failed. None of these tell a Norwegian user what to do next (MSG-03), and Failed to fetch is exactly the SDK leakage MSG-02 exists to prevent. The Microsoft button has the same shape: if ENTRA_SSO_CLIENT_ID/ENTRA_SSO_CLIENT_SECRET are unset the provider map is empty (auth.config.ts:115-132) while the button still renders unconditionally (SignInPage.vue:160-168), so clicking it yields a raw provider error rather than the button simply not being offered. Fix: Map better-auth error codes (INVALID_EMAIL_OR_PASSWORD, FORBIDDEN, TOO_MANY_REQUESTS, network failure) to auth.signIn.* keys and add nb/en copy for the rate-limited and offline cases; use the server string only for logging. Reverse the fallback so i18n wins. Gate the Microsoft button on a microsoftEnabled flag from /api/v1/client-config.
3. Submit button is disabled while the request is in flight — Medium · FORM-06
Where: apps/web/src/pages/SignInPage.vue:145-151 (and the Microsoft button at :160-168) What: <Button :loading="isSubmitting" type="submit">. PrimeVue's Button resolves its rendered disabled as disabled || loading, so the button the user just activated becomes disabled for the duration of the network round-trip. Caveat: node_modules is not installed in this checkout, so this is PrimeVue 4.5.4's documented loading behaviour rather than something I read from the installed source. The fix below is correct either way.Why it matters: Disabling the focused element moves focus to <body>. A keyboard or screen-reader user loses their place mid-sign-in, and when the error Message appears they are no longer anywhere near it — they have to tab back in from the top of the document to retry. Fix: Drop :loading as the only busy signal. Keep the button enabled, set :aria-busy="isSubmitting", swap the label to a auth.signIn.submitting ("Logger inn…" / "Signing in…") key, and guard re-entry with if (isSubmitting.value) return; at the top of handleSubmit.
4. Password field has no reveal toggle, despite the repo already having one — Medium · FORM-03
Where: apps/web/src/pages/SignInPage.vue:110-117What: The field is <InputText type="password">. ProfilePage.vue:350, :366 and :404 use PrimeVue <Password ... toggle-mask> for the same job, so the component is available, already themed, and already the house pattern. Why it matters: This is a mobile-first app (apps/web/CLAUDE.md: 320 px portrait, Capacitor wrapper). Typing a password blind on a phone keyboard is the single largest source of avoidable failed logins, and the UX Patterns login entry names "No Password Visibility Toggle" as a first-class anti-pattern. A user who cannot see what they typed retries, and after enough retries hits the rate limiter with no explanation (see finding 2). Fix: Replace with <Password id="signin-password" v-model="password" autocomplete="current-password" :feedback="false" fluid toggle-mask />. Keep :feedback="false" — a strength meter belongs on sign-up/reset, not sign-in.
5. The page has no <h1> and no heading at all — Medium · A11Y-04, CONTENT-02
Where: apps/web/src/pages/SignInPage.vue:77-80; shell at apps/web/src/layouts/DefaultLayout.vue:14-55What: The page title goes through PrimeVue Card's #title slot, which renders a <div class="p-card-title">, not a heading element. DefaultLayout.vue contributes <header> and <main> landmarks but no heading either. Nothing in either file emits h1–h6. Why it matters: Screen-reader users navigate unfamiliar pages by heading list (H key). On this page that list is empty, so there is no way to confirm what page they landed on or jump to its main content — they have to read linearly from the logo. Separately, the control that leads here says "Logg inn"/"Sign in" while the destination's visual title is "Velkommen tilbake"/"Welcome back", so even sighted users get no echo of the action they took (CONTENT-02); the corpus reference markup puts <h1>Sign in</h1> first and "Welcome back…" in the paragraph below it. Fix: Render the title as a real <h1> (e.g. <template #title><h1 class="text-2xl sm:text-3xl">{{ t('auth.signIn.submit') }}</h1></template>), and move "Velkommen tilbake" into the existing subtitle paragraph at :82-84.
6. A stale or crafted redirect query lands the user on a blank page after signing in — Medium · MSG-04
Where: apps/web/src/pages/SignInPage.vue:34-39; source of the param at apps/web/src/router/index.ts:642-647What: redirectTarget is taken from route.query.redirect and handed to router.push() unvalidated and unawaited-for-failure. router.push resolves with a NavigationFailure rather than throwing, so nothing is reported. The router has no catch-all record, so a redirect value matching no route resolves to nothing and renders an empty <router-view>. Why it matters: The user is authenticated — the session cookie is set — but sees a blank screen with no navigation, no error, and no way back except editing the URL. A bookmarked /sign-in?redirect=/locations-contacts/… from before a route rename, or a hand-edited link, is enough to trigger it. Signing in again just replays the same dead end. Fix: Resolve before pushing — const r = router.resolve(redirectTarget); await router.push(r.matched.length ? r : { name: "dashboard" }) — and reject anything that is not a single-leading-slash app path (rejects //host and absolute URLs). Apply the same guard to withBase(route.query.redirect) at :58; app/base.ts:20 strips only one leading slash, so ?redirect=//example.com produces the protocol-relative callbackURL //example.com. (See Unverified — I could not confirm whether better-auth 1.4.18 rejects that server-side.) Note the missing 404 route itself is inventory observation #1 and belongs to feature #37, not here.
7. The email field is not focused on mount — Low · FORM-11
Where: apps/web/src/pages/SignInPage.vue:95-102What: No autofocus and no onMounted focus call. Grep shows the repo uses autofocus elsewhere (SkillsPage.vue:449, CustomerAssetsPage.vue:399, admin/StandardsPage.vue:224), so this is an omission rather than a convention. Why it matters: Sign-in is a single-purpose page; every visitor's first action is typing an email. Without focus, a keyboard user tabs past the logo, language switcher and two header buttons first, and on mobile the keyboard does not open. Fix: Add autofocus to the #signin-email InputText.
8. The header logo link has no accessible name on mobile — Low · A11Y-05
Where: apps/web/src/layouts/DefaultLayout.vue:18-28What: The RouterLink to home.index wraps <img src="/aadalen-logo.svg"> with no alt attribute plus a <span class="mobile:hidden">Portal</span>. Below 760 px the span is display:none, leaving the link's accessible name computed from the image alone — and a missing alt makes screen readers fall back to announcing the file path. Why it matters: On the phone form factor this app targets first, the only route back to the landing page from sign-in is announced as "aadalen-logo.svg, link". Affects every page using DefaultLayout (landing, sign-in, sign-up, forgot/reset password), not just this one. Fix: Add alt="" to the <img> and an :aria-label="t('navigation.home')" on the RouterLink, so the name survives the mobile:hidden span.
9. Every route shares one document title — Low · NAV-03
Where: apps/web/index.html:21; no title handling anywhere in apps/web/src (no document.title, no useHead, no meta.title on any route record in router/index.ts) What: The tab title is the static Ådalen App on all 59 page components, sign-in included. Why it matters: Screen readers announce the document title on navigation, so arriving at sign-in from a guard redirect is announced identically to the page the user was bounced off. Browser history, bookmarks and tab-switching are also unusable when every entry reads the same. Fix: Add meta: { titleKey: "auth.signIn.submit" } to the route records and a single router.afterEach that sets document.title from the key plus the app name. App-wide fix; sign-in is just the first place it surfaces.
10. The "Forgot password?" link sits between the password field and Submit — Low · FORM-08
Where: apps/web/src/pages/SignInPage.vue:136-143 (link) vs :145-151 (submit) What: DOM order is email → password → error/success messages → forgot-password link → submit button, so the link is in the tab path between the last field and the action. Why it matters: A keyboard user filling the form and pressing Tab-then-Enter lands on "Forgot password?" and navigates away, losing the typed email. (Enter inside the field still submits, so the trap is Tab-driven, not universal — hence Low.) Fix: Move the forgot-password link below the submit button, after the or continue with divider or immediately under <Button>. Note for the orchestrator: the UX Patterns login reference markup places forgot-password above the submit button, so FORM-08 and the corpus disagree here. Worth settling once in BASELINE.md rather than per feature.
Unverified
A11Y-01(contrast). Not checkable from code. The muted classes on this page aretext-text-2onbg-paper(:82,:154,:172) andtext-accentonbg-paperfor both links (:138,:175); the divider rule isbg-border. All resolve throughpackages/ui/src/styles/theme.csstokens and need a contrast tool on a rendered page.A11Y-06(short viewport / mobile keyboard). Needs a rendered viewport. The card ismax-w-mdwithgap-6inside amax-w-5xl px-4 py-6<main>; whether the submit button stays reachable at ~700 px height with the keyboard open is untested.A11Y-02(24×24 targets). The "Forgot password?" (:137-142) and "Create an account" (:174-179) links aretext-sm font-semiboldanchors with no padding and no min-height class. That is very likely under 24 px tall and neither is inline in a sentence (so the WCAG 2.5.8 inline exception does not obviously apply), but the rendered box has to be measured.- Protocol-relative
callbackURL.app/base.ts:20strips exactly one leading slash, so?redirect=//example.comyieldscallbackURL: "//example.com"atSignInPage.vue:58. Whether better-auth 1.4.18 rejects that againsttrustedOrigins(auth.config.ts:168, populated fromBETTER_AUTH_TRUSTED_ORIGINS) could not be checked —node_modulesis not installed in this checkout. Harden client-side regardless (finding 6). - PrimeVue
MessageARIA. PrimeVue 4Messageis documented to renderrole="alert", which would satisfyMSG-01for the error banner at:120-126. Not confirmed against the installed package for the same reason. The success banner at:128-134is only ever set duringsetup()from?reset=success, so it is present at first paint and a live region would not announce it either way — worth arole="status"and a focus move if that message is ever set post-mount.
Baseline additions
SEC-06— Credential endpoints are rate-limited per account and per IP, and the limit is communicated to the user when hit.auth.config.ts:169-181enables better-auth rate limiting but only definescustomRulesfor/request-password-resetand/reset-password;/sign-in/emailfalls through to the global default, and noauth.signIn.*key exists for a 429. The corpus names "No Rate Limiting Feedback" as a login anti-pattern and recommends surfacing "Too many attempts, wait N seconds" after 3–5 failures. Likely true of all four projects; nothing in the current baseline covers brute-force throttling.A11Y-07—<html lang>reflects the active locale and updates when the user switches it (WCAG 3.1.1).apps/web/index.html:2is hard-codedlang="en"whilei18n/index.ts:29defaults tonb, and nothing writesdocument.documentElement.lang—LanguageSwitcher.vue:44-47sets onlyi18n.localeand localStorage. A screen reader therefore reads Norwegian copy with an English voice on every page of the app. Applies to any multi-locale project (customer-portal, playout); worth stating as a rule so it is checked once per project rather than argued per feature.
Cross-project note
MSG-02(raw SDK strings reaching users) — theresult?.error?.message ?? t(...)shape is copy-pasted intoSignUpPage.vue:47in this repo, and the same helper (@aadalen/domain-helpers) backs every catch block. Since playout, members and tt-time-tracker all wrap a third-party auth SDK too, this is the strongest candidate for a four-project alignment issue.FORM-03(no reveal toggle) — tt-time-tracker shares the "prefer PrimeVuePassword" per-project note, so checkPasswordFieldusage there; playout hasPasswordFieldavailable and members should be checked against@bcc-code/component-library-vuebefore filing.FORM-06(loading ⇒ disabled submit) — a library default, not a local mistake: PrimeVue does it in customer-portal and tt-time-tracker, and playout's FormKit submit has the equivalentdisabledbehaviour. Expect it in all four.NAV-03(single document title) — confirmed absent app-wide here. Cheap to grep in the other three (document.title,useHead,meta.title).A11Y-07(<html lang>) — check playout, which also ships multiple locales.