Appearance
[UX] tt-time-tracker — Sign in
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Dr-Wade/tt-time-tracker · Branch:
develop@bb3238c· Files reviewed: 15 Patterns:authentication/login,forms/form-validationPreflight: HEAD matches assignment. Working tree carries one uncommitted file,STACK.md— documentation only, no feature code, so the audit proceeded.
Summary
The sign-in surface is three separate forms behind one route: e-mail + password (LoginChooseMethod), an organization lookup (LoginChooseOrganization) leading to a scoped username + password form (LoginLocal), and a Google button. The markup is clean PrimeVue 4 and correctly uses Password with toggle-mask, so FORM-03 and the reveal-toggle anti-pattern are already satisfied — the Flowbite migration note in BASELINE.md holds. Two defects are serious: the e-mail login path swallows every failure silently (a try/catch wrapped around a better-auth call that resolves with an error rather than throwing), and both password forms tell the caller whether the account exists, which is the login pattern's first named anti-pattern and a SEC-01 failure.
CONTENT-01: not-applicable — see project-level i18n finding.
Findings
1. Failed e-mail sign-in shows the user nothing at all — Blocker · MSG-03
Where: services/client/src/components/Login/LoginChooseMethod.vue:81-90 (also services/client/src/views/Login.vue:97-103 for the Google button) What: handleLogin awaits signIn.email(...) inside a try/catch. The better-auth client (services/client/src/lib/auth.ts:4-10) is created with no throwOnError / throw option, so its methods resolve with { data, error } instead of rejecting. The catch block therefore never runs, and the returned error is discarded — nothing reads it. The repo's own four other better-auth call sites destructure the result rather than catching, which is the direct evidence: LoginLocal.vue:96-102, ResetPassword.vue:116, AcceptInvite.vue:169, VerifyEmail.vue:65. LoginChooseMethod is the only one that assumes a throw. Same shape in Login.vue:99 for signIn.social. Why it matters: E-mail is the default tab (Login.vue:60). A user who mistypes their password presses "Se connecter", watches the spinner run, watches it stop, and stays on an unchanged form with no message, no field error and no toast. There is nothing to distinguish a wrong password from a dead network. loading is also cleared in finally before the session refetch resolves, so even a successful login flashes the button back to idle for a beat before App.vue:151 navigates. Fix: Destructure the result the way LoginLocal does — const { error } = await signIn.email(...) — and branch on error.code. Do the same at Login.vue:99. Then delete the substring sniffing described in finding 2. A repo-wide lint rule or a thin wrapper that throws on error would stop this recurring; four of six call sites got it right by convention alone.
2. Login errors reveal whether the account exists — Blocker · SEC-01
Where: services/client/src/components/Login/LoginLocal.vue:99-100 and services/client/src/components/Login/LoginChooseMethod.vue:85-86What: Both handlers split the failure into two distinct messages: "Mot de passe incorrect" when the error mentions a password, and "L'utilisateur n'existe pas" / "Cet utilisateur n'existe pas" when it mentions a user. LoginLocal reaches the user (it reads error correctly); LoginChooseMethod's copy is currently unreachable because of finding 1, but the intent is in the code and will ship the moment finding 1 is fixed. The mechanism is also fragile: it string-matches the English SDK message (msg.includes("password"), error.code?.includes("PASSWORD")) to pick French copy, so an SDK wording change silently reclassifies the error. Why it matters: An attacker can enumerate valid usernames for a named organization one request at a time — LoginChooseOrganization already confirms which organization slugs exist (LoginChooseOrganization.vue:53-57), so the pair gives a clean org → user oracle. The authentication/login pattern names this its first anti-pattern: "Error messages like 'No account with that email' or 'Wrong password' let attackers enumerate valid accounts."Fix: Collapse both branches to one message on the form, not on a field: "Identifiants incorrects. Vérifiez votre e-mail et votre mot de passe." Keep the response time uniform for known and unknown identifiers. Reserve distinct copy for states that are not enumeration-sensitive (rate limited, account locked, server error).
3. Session/role gates in the router can hang forever behind an unlabelled spinner — High · MSG-04, CONTENT-04
Where: services/client/src/router/index.ts:15-21 (session) and :33-40 (role); the resulting UI is services/client/src/App.vue:144-152 + :11-14What: beforeEach blocks on a Promise that only resolves when a watch on isPending (then on authStore.role) fires. There is no timeout, no rejection path, and no abort on a new navigation. If the session request never settles — offline, a hung /api/auth request, a 5xx that leaves isPending true — the promise never resolves, the guard never returns, and the navigation never completes. App.vue's loading ref starts true and is only set false inside the same !pending watcher (App.vue:146-149), so the user is left on a full-screen Spinner (App.vue:11-14) that has no text, no role="status", no accessible name (components/Spinner.vue renders three unlabelled divs) and no way out other than a manual page reload. Why it matters: This is the app's cold-start path — every deep link and every refresh goes through it. The failure mode is indistinguishable from a slow connection, so users wait rather than retry, and screen-reader users get no announcement that anything is loading at all. MSG-04: the dead end offers no route out. CONTENT-04: the waiting state names nothing. Fix: Race both waits against a timeout (10–15s). On timeout, resolve and let the guard fall through to login, or route to an error state that says what failed and offers "Réessayer". Independently, give Spinner a role="status" + aria-label (or an accompanying visible line such as « Vérification de votre session… ») so the wait is named. The second guard (role) has the same shape and needs the same treatment.
4. The sign-in page has no heading — Medium · A11Y-04
Where: services/client/src/views/Login.vue:1-45What: The card contains a logo <img alt="logo"> (:4-8), a SelectButton, the form, a divider and the Google button. There is no <h1> — in fact no heading element of any level anywhere in the view or its three children. Why it matters: The reference implementation in the authentication/login pattern opens with <h1>Sign in</h1> for a reason: heading navigation is how screen-reader users orient on a page, and this page identifies itself only through an image whose alt text is the untranslated word "logo". Combined with finding 8 (every route's title is "Tim"), a non-sighted user arriving here has no programmatic signal of where they are. Fix: Add <h1>Se connecter</h1> as the first element of the card, with a short supporting line. Give the logo a real alt (the organization name when organizationLogin?.name is known, alt="" otherwise since it is decorative next to a real heading).
5. No label is associated with any input — Medium · FORM-01
Where: LoginChooseMethod.vue:5 and :18; LoginLocal.vue:15 and :27; LoginChooseOrganization.vue:5What: Every field uses a bare <label class="text-sm font-medium ..."> with no for, and no InputText / Password in any of the three forms sets an id or inputId. The association is purely visual. The error <small> elements are likewise not linked to their field by aria-describedby. Why it matters: A screen reader announces these inputs as unlabelled edit fields, and clicking the label text does not focus the input. The pattern's accessibility checklist requires both <label for>/id pairing and errors linked via aria-describedby; PrimeVue supports both via inputId on Password and aria-describedby passthrough. Fix: Give each field an id (login-email, login-password, login-username, login-organization), point the label's for at it, and add :aria-describedby="errors.x ? 'login-x-error' : undefined" with a matching id on the <small>.
6. Validation errors appear silently for screen-reader users — Medium · MSG-01
Where: LoginChooseMethod.vue:12-15 and :27-30; LoginLocal.vue:21-24 and :36-39; LoginChooseOrganization.vue:10-13What: Field errors are plain <small class="text-red-500"> toggled by v-if with no role="alert" and no live region. The only announced channel is the Pebble toast host (components/PebbleToastHost.vue:4-9, correctly role="region" + aria-live="polite"), which is used solely for the unmapped fallback branch — the common cases (bad password, unknown user, invalid e-mail, unknown organization) all render inline and are therefore never announced. Why it matters: WCAG 4.1.3. A user who submits an empty or wrong form gets a DOM insertion with no announcement; nothing tells them the submission failed. Colour (text-red-500 plus PrimeVue's :invalid border) is also the sole visual signal — the validation pattern's guidance is explicit about not relying on colour alone. Fix: Add role="alert" to each error <small> (and pair it with the aria-describedby from finding 5). Consider an icon or a short prefix so the error state is not colour-only.
7. Credential fields carry no name or id, so password managers have little to bind to — Medium · FORM-02
Where: LoginChooseMethod.vue:6-11 and :19-26; LoginLocal.vue:16-20 and :28-35What: autocomplete is set correctly in intent (email, username, current-password) but no input has a name or an id, and the forms never navigate on submit — @submit.prevent plus an XHR. Note also that on LoginLocal the value actually sent is scopedUsername(orgId, username) (:95), not what the user typed, so a manager that does save will save the unscoped string. Why it matters: Chrome and Safari heuristics fall back to name/id when autocomplete is absent or unrecognised, and the "save password?" prompt is driven by a form the browser can identify. Missing autofill is the pattern's "Missing Autocomplete Attributes" anti-pattern; the intent is right here but the supporting attributes are not. Fix: Add name and id/inputId to all four credential inputs. Verify the autocomplete value actually lands on the inner <input> (see Unverified) and use :input-props if PrimeVue's Password puts stray attributes on its wrapper.
8. Every route reports the same document title — Medium · NAV-03
Where: services/client/index.html:19 (<title>Tim</title>); services/client/src/router/routes.ts:35 (the login record) — no route in the file declares a meta.title, and nothing anywhere in services/client/src writes document.title (verified by grep). Why it matters: Browser tabs, history entries, bookmarks and the screen reader's page-change announcement all read "Tim" for the login page, the time sheet and every admin screen alike. On a login page this also removes the last remaining textual identification (see finding 4). Fix: Add meta.title per route and an afterEach that sets document.title = [meta.title, "Tim"].filter(Boolean).join(" · "). Cheap, and it fixes all 25 features in the queue at once.
9. Submit buttons disable themselves while submitting — Medium · FORM-06
Where: LoginChooseMethod.vue:33-39; LoginLocal.vue:42-48; LoginChooseOrganization.vue:16-22What: All three use PrimeVue <Button :loading="loading" type="submit">. PrimeVue 4's Button renders :disabled="disabled || loading", so loading also disables the control. No aria-busy is set and the label does not change. Why it matters: Disabling the element that currently holds focus drops focus to <body>, so a keyboard user loses their place mid-submit and a screen-reader user hears nothing about the state change. The baseline states this explicitly. Caveat: node_modules is not installed in this checkout, so the PrimeVue Button internals could not be read from disk — the disabled || loading binding is from PrimeVue 4's published source (primevue: ^4.5.4, services/client/package.json:40), not from a local file. Worth a 30-second confirmation before filing. Fix: Keep the button enabled, guard re-entry in the handler (if (loading.value) return), and express the state with aria-busy="true" plus a label swap to « Connexion… ».
10. Neither form focuses its first field on mount — Low · FORM-11
Where: LoginChooseMethod.vue:6-11 (e-mail), LoginLocal.vue:16-20 (username), LoginChooseOrganization.vue:6-9 (organization) What: No autofocus, no onMounted focus call in any of the three. Why it matters: Sign-in is a single-purpose page; every user must click or tab into the first field before typing. The cost compounds on the two-step local path, where the user lands on a fresh empty field twice. Fix: Focus the first input on mount in each component, including after the next transition from organization chooser to LoginLocal.
11. "Mot de passe oublié ?" is likely under the 24px target minimum — Low · A11Y-02
Where: LoginChooseMethod.vue:40-47What: The recovery link is text-sm (14px / 20px line-height in Tailwind) with no padding, sitting alone in a centred div — so its hit box is roughly 20px tall. WCAG 2.5.8's inline exception is weak here because the link is not inside a sentence; it is a standalone control. Why it matters: The single most-used escape hatch on a login screen is the hardest one to hit on a phone. Rendered height is not measured (see Unverified), but the computed line-height leaves no headroom. Fix: Add inline-block py-2 (or min-h-[24px] px-2 py-1) to the link.
Unverified
- A11Y-01 (contrast) — not measurable from code. Worth checking on a rendered page:
text-red-500error text on white (Login.vue:9),text-surface-400for the "Retour" control and the administrator hint (LoginLocal.vue:4,:49), and thetext-primary-500recovery link, whose colour is theme-driven (applyTheme,App.vue:37-41) and therefore varies per organization — a tenant-configurable primary colour can fail contrast on white without anyone noticing. - A11Y-06 (short viewport / mobile keyboard) — needs a rendered viewport. The page uses a fixed
pt-24top offset plus ah-32spacer div (Login.vue:42); with a keyboard open on a small phone the submit button's visibility is untested here. - PrimeVue
Passwordattribute forwarding — whetherautocomplete,:invalidand friends land on the inner<input>or on the wrapper could not be confirmed:node_modulesis absent from this checkout and no PrimeVue copy exists anywhere on disk. If they land on the wrapper, finding 7 is more severe than Medium (password managers would see nocurrent-passwordhint at all). Same caveat applies to the Button internals in finding 9. - Rate limiting / lockout feedback — the
authentication/loginpattern calls out "No Rate Limiting Feedback" as an anti-pattern. Whether the NestJS side throttles login and whether any code reaches the client was not traced; it sits in the API service, outside this feature's client files.
Baseline additions
- MSG-06 — Failures are always surfaced. Every failed operation produces a visible message. Where the SDK returns errors as values rather than throwing (better-auth,
@hey-apiclients, Supabase), the result must be inspected; atry/catchwrapped around a non-throwing call is a silent-failure defect, not a style choice. Motivated by finding 1 — the bug is invisible in review because the code looks like correct error handling. - NAV-06 — Async gates are bounded. Any route guard or bootstrap gate that waits on asynchronous state must have a timeout and a visible failure path with a retry. An unbounded wait renders as an indefinite spinner the user cannot escape. Motivated by finding 3; the inventory notes the same shape in members'
while (!session.initialized)loop, which is what makes it rule-worthy rather than a one-off. - CONTENT-05 — Mode switches are labelled by situation, not implementation. A control that switches between authentication or entry modes names the user's circumstance, not the system's internal term. Motivated by
Login.vue:56-59: the tabs read « E-mail » and « Local ». "Local" is the developer's word for an organization-scoped username account; the user who has one has no way to know that is them. Something like « Compte de mon entreprise » would.
Cross-project note
- SEC-01 (enumeration copy) — check members and customer-portal login and password-reset copy for the same known-user / unknown-user split. playout's password-reset audit already established the rule, so it is the likely-clean one.
- MSG-06 / silent failure — applies anywhere a result-returning SDK is used. members and customer-portal should be grepped for
try {around calls whose siblings destructure{ error }. Within this repo the same pattern needs checking inForgotPassword.vue:88(forgetPasswordis untyped and awaited bare) and inLogin.vue:99— both are in queue rows #5 and #4 respectively. - NAV-06 / busy-wait — the inventory already names members (
while (!session.initialized)). Two projects makes this an alignment issue. - NAV-03 (per-route titles) — likely all four; cheap to confirm with one grep for
document.titleper repo, and worth doing once rather than per feature. - FORM-06 (disable on submit) — customer-portal is also PrimeVue and will share the
:loading→disabledbehaviour wherever it uses<Button loading>.