Skip to content

[UX] tt-time-tracker — Password reset ​

Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Dr-Wade/tt-time-tracker · Branch: develop @ bb3238c · Files reviewed: 13 Patterns: authentication/password-reset

Summary ​

The flow is structurally complete — request form, generic confirmation, token page, success state, "back to sign in" on every step — and it visibly learned from the enumeration problem: the client forces the generic confirmation even when the request throws (ForgotPassword.vue:93-96), and the reset handler explicitly checks better-auth's { error } return rather than trusting a resolved promise (ResetPassword.vue:116-120). It is the best of the three password-reset implementations audited so far on copy and on the happy path.

The failures are all at the edges. The reset page renders the password form without ever verifying the token, so an expired link costs the user two typed passwords before it tells them anything (SEC-02). The "e-mail reset is not available on this instance" branch is shown to every user for the duration of the config request, because smtpConfigured defaults to false while loading — users are told to contact their administrator about a feature that works. And enumeration is defeated in copy but not in timing: sendResetPassword awaits a live SMTP round-trip inside the request, so a known address is measurably slower than an unknown one (SEC-01).

Findings ​

1. The reset form renders without verifying the token — Blocker · SEC-02 ​

Where: services/client/src/views/ResetPassword.vue:14, :98, :116What: The guard is v-if="!token" — presence only. token is route.query.token cast to a string (:98); nothing validates it. Any request to /reset-password?token=x renders the full two-field password form. Validity is discovered only at resetPassword() on submit (:116), after the user has typed and confirmed a new password. The server compounds this: auth.module.ts:46 builds the email link as ${appUrl}/reset-password?token=${token} directly, bypassing better-auth's own /api/auth/reset-password/:token redirect endpoint, which is the hop that would normally validate the token and redirect with an error=INVALID_TOKEN marker before the SPA ever paints. Why it matters: The most common way to arrive at this page is late — the link has expired or was already used. Those users pick a password, type it twice, press submit, and only then are told the link is dead and they must start over. The typed password is discarded. This is exactly the "Expired Token Without Guidance" anti-pattern in authentication/password-reset, and the baseline's SEC-02 exists to prevent it. Fix: Add a token-verification call on mount (a GET /api/auth/verify-reset-token style endpoint, or route the email link through better-auth's redirect endpoint and read the error query param it appends). Render one of three states — verifying / invalid / form — and only mount the password fields in the third. The invalid state already exists at :14-24; it just needs to be reachable for a real expired token, not only a missing one.

2. Every user is told password reset is unavailable while the config loads — High · CONTENT-04 ​

Where: services/client/src/views/ForgotPassword.vue:14-19, via services/client/src/composables/useAppConfig.ts:18What: smtpConfigured is computed(() => data.value?.smtpConfigured ?? false). The composable is a useQuery with no isLoading handling, so during the in-flight /api/config request — i.e. on every cold load of this page — the value is false and the template takes the !smtpConfigured branch. The user sees « La réinitialisation de mot de passe par e-mail n'est pas disponible sur cette instance. Contactez votre administrateur » and then the form appears. Why it matters: The waiting state is rendered as a definitive failure state, and a false one. A user on a slow connection reads a sentence telling them to go bother an administrator about a feature that is working fine, and some fraction of them will do exactly that and abandon the flow. Note this fires on the page whose entire purpose is recovering account access, i.e. for users who are already stuck. Fix: Distinguish the three states in useAppConfig — expose isLoading and render a spinner or the form skeleton while pending, the unavailable message only when the query has resolved with smtpConfigured === false. Alternatively default to true and hide the branch until proven otherwise, so the failure mode is a form that errors rather than a false denial.

3. Enumeration is prevented in copy but not in timing — High · SEC-01 ​

Where: services/api/src/auth/auth.module.ts:41-52 with services/api/src/mail/mail.service.ts:35-41What: sendResetPassword is awaited inside the request, and it awaits mail.sendMail, which awaits nodemailer.transporter.sendMail — a live SMTP connection and delivery handshake. For an address with no account, better-auth never invokes the hook and the response returns immediately. The observable difference between "account exists" and "account does not exist" is therefore the full SMTP round-trip, typically hundreds of milliseconds to seconds. Why it matters: SEC-01 requires same copy and same timing. The copy half is done well (ForgotPassword.vue:22-25, plus the deliberate catch at :93-96 that shows success even on failure) — which makes the timing leak the only thing standing between this endpoint and a working account-enumeration oracle for the whole tenant. The authentication/password-reset pattern calls this out twice, under "No Rate Limiting on Reset Requests" and under "Consistent Timing". Fix: Take mail delivery off the request path. The repo already runs a BullMQ worker — enqueue the send and return immediately, which is also what the pattern's Performance section recommends ("Show confirmation immediately; send email in background"). Failing that, wrap the handler so every response is padded to a fixed floor duration regardless of which branch ran.

4. Every state change in the flow is silent to a screen reader — High · MSG-01 ​

Where: services/client/src/views/ForgotPassword.vue:21-25 and :43-46; services/client/src/views/ResetPassword.vue:26-36, :57-60, :71-74, :76-79What: No role="alert", role="status" or aria-live anywhere in either view. Three separate transitions are affected:

  • Submitting the request replaces the entire <form> with a <p> (ForgotPassword.vue:21). The button that had focus is unmounted, focus falls to <body>, and nothing is announced.
  • Successful reset replaces the form with the success <p> (ResetPassword.vue:26), identically.
  • All five validation messages are plain <small class="text-red-500"> elements with no live role and no aria-describedby linking them to their input. Why it matters: A screen-reader user presses « Envoyer le lien », hears nothing, and has no cursor position from which to explore — the page appears to have done nothing. The same on a failed submit: the error is painted but never announced. WCAG 4.1.3 (Status Messages). The reference HTML in authentication/password-reset puts role="alert" on every field-error span for precisely this reason. Fix: role="alert" on the error <small> elements plus aria-describedby from each input; wrap the confirmation and success blocks in a container with role="status"; and move focus to the confirmation heading when the form is replaced.

5. No post-reset security follow-through — Medium · SEC-03, SEC-04 ​

Where: services/api/src/auth/auth.module.ts:36-40What: The onResetPassword hook does one thing: flip emailVerified to true. It does not revoke the user's other sessions and it does not send a "your password was changed" notification. Contrast services/api/src/modules/user/user.service.ts:251-258, where the admin-driven reset path explicitly runs prisma.unscoped.session.deleteMany({ where: { userId } }) with a comment explaining exactly why ("an attacker-set password coexists with the user's existing session") and logs an activity record. The self-service path inherits none of that. Session revocation may be supplied by better-auth's own resetPassword implementation — I could not confirm this, as node_modules is not installed in this checkout, so the guarantee currently rests on undocumented library default rather than on anything in this repo. Why it matters: SEC-04 has no fallback at all: a user whose account was taken over and whose password was reset by the attacker gets no signal. The reset flow is the single highest-value takeover path in the product, and it is the one account change that produces no notification and no audit entry. SEC-03's guarantee, meanwhile, is one library upgrade away from silently disappearing. Fix: In onResetPassword, mirror the admin path: session.deleteMany for the user (idempotent even if better-auth already did it, and it makes the guarantee explicit and testable), send a confirmation email via MailService naming the time and offering a "this wasn't me" route, and write an activity.log entry as the admin path does.

6. The new-password fields carry no autocomplete — Medium · FORM-02 ​

Where: services/client/src/views/ResetPassword.vue:45-56 and :64-70What: Neither Password component sets autocomplete. There is also no hidden username/email field on the form, so a password manager has no identity to associate a saved credential with. This is an inconsistency within the repo, not just against the baseline: services/client/src/components/Login/LoginChooseMethod.vue:22 correctly sets autocomplete="current-password", and ForgotPassword.vue:39 correctly sets autocomplete="email". Why it matters: The one moment a password manager most needs to act — the user is choosing a new password — is the one place the app doesn't tell it to. Managers won't reliably offer generation, and won't reliably offer to update the stored entry afterwards, so the user ends up locked out again with a saved password that no longer works. The pattern's reference HTML sets autocomplete="new-password" on both the password and the confirmation field. Fix: autocomplete="new-password" on both fields, and add a <input type="hidden" autocomplete="username"> populated from the token's account email (or a readonly disabled email display) so managers can match the entry.

7. Labels are not programmatically associated with their inputs — Medium · FORM-01 ​

Where: services/client/src/views/ResetPassword.vue:44, :63; services/client/src/views/ForgotPassword.vue:36What: All three labels are bare <label class="text-sm font-medium text-gray-700"> with no for, and the PrimeVue InputText/Password components are given no id. The label is neither wrapping the control nor linked to it. Why it matters: A screen reader announces the field as an unlabelled edit box, and clicking the label does not focus the field. On ResetPassword.vue this is worse than usual because the two fields are visually near-identical password boxes — « Nouveau mot de passe » and « Confirmer le mot de passe » are the only thing distinguishing them, and that distinction is exactly what fails to reach assistive tech. Fix: Give each control an id (inputId for PrimeVue Password) and point the label's for at it. Worth fixing as a repo-wide sweep — the same shape appears in the login form.

8. Password rules and match feedback only appear after a failed submit — Medium · FORM-04, FORM-07 ​

Where: services/client/src/views/ResetPassword.vue:45-75, :108-110What: Two related gaps:

  • The 8-character minimum is passed to PrimeVue as :min-length="8" (:51), which only feeds the strength meter; it is never stated in visible text before the user types. « Minimum 8 caractères » is produced solely by the submit-time check at :109.
  • The confirmation field is compared to the source only inside handleSubmit (:110). There is no on-input or on-blur comparison and no affirmative "passwords match" signal — the second Password even has :feedback="false", so it gives no feedback of any kind until submit. Why it matters: The user composes a password, retypes it, presses the button, and only then learns either that it's too short or that the two entries differ — with both fields masked, so they cannot see where they diverged and generally have to clear and redo both. authentication/password-reset lists "Password Mismatch Not Caught" as a named anti-pattern and prescribes real-time comparison on blur of the confirm field. Fix: Render a static hint under the first field ("Au moins 8 caractères") before any input, and add a watcher on the confirm field that shows a green check/« Les mots de passe correspondent » once both are non-empty and equal, and the mismatch error on blur.

9. Submit buttons are disabled while submitting — Medium · FORM-06 ​

Where: services/client/src/views/ForgotPassword.vue:48-54, services/client/src/views/ResetPassword.vue:80-86What: Both submits are <Button :loading="loading">. PrimeVue's Button renders the underlying element as disabled whenever loading is true, so the element the user just activated becomes disabled mid-interaction. (This is PrimeVue-internal behaviour; node_modules is not installed in this checkout, so it is asserted from the library's documented loading semantics rather than read from source — worth confirming against primevue@4.5.4 before filing.) Why it matters: Disabling a focused element drops focus to <body>. Combined with finding #4 — no live region on the resulting state change — a keyboard or screen-reader user who submits either form loses their place completely and receives no announcement of what happened. There is also no in-repo guard against double submission other than the disable itself, so the accessibility cost isn't buying anything the handler couldn't do. Fix: Keep the button enabled, set aria-busy="true" while in flight, guard handleSubmit with if (loading.value) return;, and swap the label to « Envoi… » / « Enregistrement… » so the state is conveyed by text rather than by removing the control.

10. The expired-token error is a dead end — Medium · MSG-04 ​

Where: services/client/src/views/ResetPassword.vue:76-79, :118, :123What: When resetPassword returns an error, the message « Lien invalide ou expiré. Veuillez faire une nouvelle demande. » is written to errors.general and rendered as a bare <small> above the submit button. No link. The identical message in the missing-token branch at :15-23 is accompanied by a « Réessayer » RouterLink to forgot-password. So the branch that almost never fires has the escape route, and the branch that fires constantly — a genuinely expired link — does not. This view also has no "back to sign in" link at all, unlike ForgotPassword.vue:58-63. Why it matters: The copy tells the user to make a new request and then gives them nothing to click. They are stranded on a page with a dead form, a dead token in the URL, and no navigation. authentication/password-reset lists both "Expired Token Without Guidance" and "No Back to Login Path" as named anti-patterns, and this view manages both at once. Fix: Render the general-error case as a full state swap reusing the :14-24 block — same message, same « Réessayer » link — rather than as an inline <small>. Add the « Retour à la connexion » link to this view unconditionally, as ForgotPassword.vue has it.

11. The token-bearing URL is crawlable and has no referrer policy — Medium · NAV-04 ​

Where: services/client/public/robots.txt, services/client/index.html:1-19What: robots.txt is User-agent: * / Disallow: — an empty Disallow, which explicitly permits crawling everything. index.html sets no <meta name="robots" content="noindex"> and no <meta name="referrer" content="no-referrer">, and I found no Referrer-Policy or X-Robots-Tag header anywhere in services/api/src (no helmet, no header middleware). /reset-password?token=… is thus a fully indexable URL carrying a single-use credential — one that stays valid for seven days (see Baseline additions). The page also loads a cross-origin stylesheet from fonts.googleapis.com (index.html:8); modern browsers' default strict-origin-when-cross-origin truncates that Referer to the origin, so the token does not currently leak to Google, but nothing in the app is enforcing it. Why it matters: NAV-04 requires this to be enforced by response header, not assumed. Any user who pastes the link into a chat app with link previewing, or any crawler that reaches it, handles a live credential. Fix: Disallow: /reset-password, /accept-invite, /verify-email in robots.txt; add X-Robots-Tag: noindex and Referrer-Policy: no-referrer as response headers for those paths from the API/proxy layer.

12. Nothing the user already typed is carried into the flow — Low · FORM-09, FORM-11 ​

Where: services/client/src/components/Login/LoginChooseMethod.vue:41-47, services/client/src/views/ForgotPassword.vue:37-42What: The « Mot de passe oublié ? » link navigates with no params, so the email the user just typed into the login form is dropped and must be retyped on the next screen. Neither view focuses its first field on mount — no autofocus, no onMounted focus call — so the user must also click or tab into it first. Why it matters: The user arrives here having already failed to sign in; retyping the address they typed thirty seconds ago is pure friction at the worst moment. Small individually, but this flow is short enough that it's a meaningful share of the total interaction cost. Fix: Pass the login email as a query param (or shared store) and prefill; add a focus-on-mount to the email field in ForgotPassword.vue and to the first password field in ResetPassword.vue.

13. The confirmation state doesn't say what to do when the email doesn't arrive — Low · CONTENT-03 ​

Where: services/client/src/views/ForgotPassword.vue:22-25What: The confirmation is one sentence: « Si un compte est associé à cette adresse e-mail, vous recevrez un lien de réinitialisation dans quelques instants. » It does not mention the spam folder, does not echo the address that was used, does not say how long the link stays valid, and offers no resend. Why it matters: Deliverability is the single most common failure of this flow, and the failure is invisible — the user waits, nothing arrives, and the page has told them nothing about what to try next. Their only recourse is to guess that resubmitting the form does something. The pattern's own content schema includes spam_note and resend for exactly this state. Fix: Add « Vous ne le voyez pas ? Vérifiez votre dossier spam. », echo the submitted address, and add a resend control (rate-limited, and reusing the same generic copy so it doesn't become an enumeration side channel).

14. Neither page sets a document title or sits in a landmark — Low · NAV-03, A11Y-04 ​

Where: services/client/src/router/index.ts:1-11, services/client/index.html:17, services/client/src/App.vue:1-24What: The router has no afterEach title hook and no route carries a title meta; every page in the SPA is « Tim », set statically in index.html:17. The view roots are plain <div>s and App.vue provides no <main>, so there is no main landmark on either page (these two routes render outside LayoutApp, so they get nothing from the app shell either). Why it matters: Tab and history entries are indistinguishable, and a screen reader gets no page-change announcement on route navigation plus no landmark to skip to. The <h1> is correct on both pages (ForgotPassword.vue:10, ResetPassword.vue:10), which makes the missing landmark the only structural gap. Fix: Add a meta.title per route and an afterEach that sets document.title; wrap the view content in <main>.

15. Raw SDK error strings can reach the user in English — Low · MSG-02 ​

Where: services/client/src/utils/index.ts:83-86, used at services/client/src/views/ResetPassword.vue:118What: extractErrorMessage maps known codes to curated French copy, but its final chain is fromValue(e.message) ?? fromValue(e.error) ?? e.statusMessage ?? fallback. The French fallback is reached only when all of those are absent. better-auth error objects always carry a message — an English string like "invalid token" or "password too short" — so the fallback passed at :118 and :123 is effectively dead code for the errors it was written to handle. Why it matters: A French-language UI intermittently shows English library-internal strings at its most stressful moment. The intent is clearly there (both call sites pass a curated French fallback); the helper's precedence order defeats it. Fix: Map better-auth's documented error codes into ERROR_CODE_MESSAGES (INVALID_TOKEN, PASSWORD_TOO_SHORT, …), or have the auth call sites use a strict variant of the helper that returns the fallback unless a known code matched, rather than passing through whatever message the SDK supplies.

Passing ​

Recorded because the assignment named these as high-value checks and because two of them are the first clean results across three implementations:

  • SEC-01 (copy half) — passes, and deliberately: ForgotPassword.vue:93-96 catches the error and shows the success state anyway, with a comment naming enumeration as the reason. Only the timing clause fails (finding #3).
  • SEC-05 — passes. Client-side check at ResetPassword.vue:109 enforces 8, and better-auth's minPasswordLength default is 8; the PrimeVue Password at :45-56 supplies a real strength meter with French labels rather than composition rules, which is what the rule prefers. One caveat: the server minimum is an unstated library default — worth setting minPasswordLength: 8 explicitly in auth.module.ts:30-53 so an upgrade can't move it silently.
  • FORM-03 — passes. toggle-mask on all three password fields; PrimeVue supplies the reveal control, so this was not hand-rolled. (Toggle keyboard operability and accessible name are Unverified below.)
  • FORM-05 — passes. Neither submit is disabled on invalid input; validation runs on submit and explains the block.
  • FORM-08 — passes. Both forms put their only secondary link after the submit button.
  • FORM-10 — passes. No paste handlers anywhere in the flow.
  • NAV-01 / NAV-02 — pass. No timed redirects, no timers, no subscriptions; every transition is user-initiated.
  • MSG-05 — not applicable; nothing destructive here.
  • CONTENT-01 — not applicable; see project-level i18n finding.
  • CONTENT-02 — passes. « Mot de passe oublié ? » on the login form (LoginChooseMethod.vue:45) → « Mot de passe oublié » as the destination <h1>.

Unverified ​

  • A11Y-01 (contrast). Needs computed colour values. The candidates are text-surface-500 on white for the instruction paragraph (ForgotPassword.vue:28) and text-red-500 for every error message — small text at 14px, where 4.5:1 is required.
  • A11Y-06 (short viewport / mobile keyboard). Both views use pt-24 fixed top padding with no vertical scroll container; on a ~700px viewport with the keyboard open, ResetPassword.vue's two password fields plus the PrimeVue strength-meter overlay panel may push the submit button out of reach. Needs a rendered check.
  • A11Y-02 (target size) on the secondary links. text-sm gives a ~20px line box, below the 24px minimum — but the links are block and may inherit enough from surrounding spacing. Needs a rendered measurement, not a class reading.
  • PrimeVue Password toggle-mask internals (FORM-03, A11Y-03, A11Y-05). Whether the reveal control is a real <button>, is keyboard-operable, and carries an accessible name and aria-pressed depends on primevue@4.5.4's implementation. node_modules is not installed in this checkout, so I could not read it. This determines FORM-03's quality for all four password fields in the repo and is worth settling once.
  • Whether better-auth's resetPassword revokes sessions (finding #5, SEC-03 half). Same cause — could not read the library.

Baseline additions ​

SEC-06 — Single-use credential tokens expire within one hour. Invite tokens, which are legitimately long-lived, must be a distinct token type rather than a relaxed reset token.

Rationale: auth.module.ts:32-35 sets resetPasswordTokenExpiresIn: 60 * 60 * 24 * 7 — seven days — with a comment explaining that invite links double as password-reset tokens and recipients may open them days later. The reasoning is sound for invites and wrong for resets: it silently gives every ordinary "I forgot my password" link a seven-day lifetime. authentication/password-reset recommends 15–60 minutes, and the baseline currently has no rule on token lifetime, so this passes review unremarked. Combined with finding #11 (indexable, no referrer policy) it is the most consequential thing in this audit that no existing rule catches. The fix is to separate the two token types rather than to shorten the shared one, which is why this is a rule rather than a finding.

MSG-06 — When a form is replaced by a confirmation or success state, focus moves to the new content. Currently the baseline covers announcement (MSG-01) and covers not disabling the submit button (FORM-06), but nothing covers the case where the submitted form is unmounted wholesale — which drops focus to <body> even when the button was never disabled. Both views here do it (ForgotPassword.vue:21, ResetPassword.vue:26); the playout implementation did too. It is a distinct defect from MSG-01 and needs its own ID, since satisfying MSG-01 with an aria-live region does not fix the lost focus position.

Cross-project note ​

  • SEC-02 (finding #1) — check customer-portal and members directly. Presence-only token checks are the default outcome of any SPA that reads a token from route.query, which all four projects do. Likely 3-for-3.
  • CONTENT-04 (finding #2) — the "feature-flag defaults to false while the query is in flight" shape is specific to useAppConfig's ?? false, but the same idiom will appear anywhere a TanStack Query result gates a UI branch. Worth grepping ?? false against query data in customer-portal, which uses the same stack.
  • SEC-01 timing (finding #3) — depends on whether each project sends its reset mail inline or via a queue. playout uses Firebase Auth, which handles this server-side and is likely clean; members and customer-portal are worth checking, as both control their own mail path.
  • FORM-01, FORM-02, FORM-06, FORM-07, MSG-01 — these five were all findings in the playout password-reset audit that seeded the baseline, and all five recur here in a completely different stack. That is now two-for-two on a library-independent defect set, which is the strongest available argument that the baseline is measuring the right things. Expect them in customer-portal and members too; FORM-02 in particular is worth a single cross-repo sweep rather than four separate fixes.
  • NAV-03 / A11Y-04 (finding #14) — project-wide in tt-time-tracker, not feature-specific. Should be raised once against the project, like CONTENT-01.