Skip to content

[UX] tt-time-tracker — Accept invite ​

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

Summary ​

The invite flow is functionally sound and gets the two hardest things right: the token is verified server-side before the password form renders (SEC-02 pass, via GET /api/auth/invite-info), and the success state offers an explicit link instead of a timed redirect (NAV-01 pass). Everything else is the classic "PrimeVue component dropped next to a bare <label>" set: no field is programmatically labelled, no field carries autocomplete, and no error is announced — so the whole form is close to unusable with a screen reader or a password manager. The single most important defect is NAV-04: the page URL carries a live 7-day password-setting token and the SPA is served by an nginx config that sets neither X-Robots-Tag nor Referrer-Policy, and no meta fallback exists either.

Findings ​

1. Invite URL carries a 7-day token with no noindex and no referrer policy — High · NAV-04 ​

Where: services/client/nginx.conf:11-13 (the location / block that serves every SPA route), services/client/index.html:4-17 (no robots / referrer meta), services/client/src/views/AcceptInvite.vue:1-181 (no useHead/meta at runtime either) What: /accept-invite?token=… is a public route (router/routes.ts:39 — no requiresAuth) whose query string is a better-auth reset-password token that sets the account's first password. auth.module.ts:34 extends that token's life to 60 * 60 * 24 * 7 — seven days. Nothing anywhere in the delivery chain marks the page noindex or restricts referrers: the nginx server block adds headers only for static assets (Cache-Control), never for /index.html, and the document head has no <meta name="robots"> or <meta name="referrer">. The API sets Helmet headers (services/api/src/main.ts:28-38), but Helmet is on the API process only — it never touches the static client responses. Why it matters: A live account-takeover token can be indexed if the URL ever appears in a crawlable place (a forwarded email in a web archive, a shared support ticket, a link-preview bot), and it is sent to third parties as soon as the page makes a cross-origin request or the user follows an outbound link. index.html:7-9 already loads Google Fonts cross-origin from this document. Baseline NAV-04 is explicit that a runtime meta tag alone is insufficient; here there is not even that. Fix: In services/client/nginx.conf, inside location / (or the server block, with always), add add_header X-Robots-Tag "noindex, nofollow, noarchive" always; and add_header Referrer-Policy "no-referrer" always;. Add the matching <meta name="robots" content="noindex"> / <meta name="referrer" content="no-referrer"> to index.html as defence in depth. Same treatment is required for /reset-password and /verify-email, which carry the same token.

2. No field on the form has a programmatically associated label — High · FORM-01 ​

Where: services/client/src/views/AcceptInvite.vue:58, 66, 74, 93What: All four labels are bare <label class="text-sm font-medium text-gray-700">Nom</label> with no for, and the PrimeVue InputText / Password components below them are given no id, inputId, aria-label or aria-labelledby. PrimeVue does not generate the association itself — Password in particular requires inputId to reach the inner <input>. Nothing wraps the control inside the <label> either, so the implicit association does not apply. Why it matters: A screen-reader user hears four unnamed edit fields and cannot tell "Mot de passe" from "Confirmer le mot de passe" — on a form that irreversibly sets their account password. Clicking the visible label also fails to focus the input for everyone else. WCAG 1.3.1 / 3.3.2. Fix: Give each control an id and point the label at it: <label for="invite-password">…</label> + <Password input-id="invite-password" …> (and <InputText id="invite-name"> for the read-only pair). The same edit is needed verbatim in ResetPassword.vue:43, 63.

3. No autocomplete anywhere, and the email is disabled rather than readonly — High · FORM-02 ​

Where: services/client/src/views/AcceptInvite.vue:59-63 (name), 66-71 (email), 75-86 (password), 94-100 (confirm) What: No field sets autocomplete. The password fields need autocomplete="new-password"; the email field needs autocomplete="username" (or email) so a password manager can pair the credential with an identity. The email and name fields are additionally disabled, and disabled inputs are skipped by most password managers' form parsing as well as by form serialization. Why it matters: This is the one moment in the product's lifecycle where the user invents a password. Without new-password, browsers and managers will not offer to generate one, and without a readable username field the credential is saved (if at all) with no identity attached — so on the next visit the manager has nothing to autofill and the user falls into the password-reset flow. The authentication/signup reference implementation sets autocomplete="name", "email" and "new-password" on exactly these three fields. Fix: Add autocomplete="new-password" to both Password fields, autocomplete="username" to the email field, and swap disabled for readonly on name and email so the values remain machine-readable while staying uneditable.

4. No error or state change is announced to assistive technology — High · MSG-01 ​

Where: services/client/src/views/AcceptInvite.vue:87-90, 101-104, 106-109 (field and general errors), 22-35 (success panel), 45-50 (loading panel), 10-20 (invalid-token panel) What: Every error is a plain <small class="text-red-500"> with no role="alert" and no aria-describedby linking it to its field. The three top-level states (verifying → form → success/invalid) are swapped by v-if with no role="status" or aria-live="polite" region. Why it matters: A screen-reader user presses "Activer mon compte", the submission fails ("Les mots de passe ne correspondent pas", or an expired token), and nothing is spoken — the page appears to have simply ignored them. The same silence covers the success transition, so there is no confirmation that the account was activated. WCAG 4.1.3. Fix: Give each <small> an id, role="alert", and reference it from the field via aria-describedby; wrap the state-swapping panel in a container with role="status" aria-live="polite". The signup pattern's reference markup uses <span class="field-error" role="alert" hidden> per field.

5. Raw better-auth error strings reach the user — Medium · MSG-02 ​

Where: services/client/src/views/AcceptInvite.vue:171, 176; fallthrough in services/client/src/utils/index.ts:52-77What: extractErrorMessage(error, "Lien invalide ou expiré.") only uses the French fallback when nothing extractable is present. ERROR_CODE_MESSAGES (utils/index.ts:44-50) covers five app-level codes — ENTRY_OVERLAP, UNAUTHORIZED, FORBIDDEN, NOT_FOUND, VALIDATION — none of which better-auth emits. So better-auth's own codes (INVALID_TOKEN, PASSWORD_TOO_SHORT, …) fall through to fromValue() and their raw English message is rendered verbatim in an otherwise all-French UI. Why it matters: A French-speaking user setting up their account is shown an untranslated SDK string such as "invalid token" with no idea what to do about it. Fix: Extend ERROR_CODE_MESSAGES with the better-auth codes this flow can produce and let anything unmapped use the French fallback rather than the raw message.

6. Submit button is disabled while submitting, dropping focus — Medium · FORM-06 ​

Where: services/client/src/views/AcceptInvite.vue:110-116 (<Button :loading="submitting" type="submit">), handler at 159-180What: PrimeVue 4's Button renders the native disabled attribute whenever loading is true (its internal disabled computed is props.disabled || props.loading), so activating the button disables the element the user just activated. handleSubmit also has no re-entrancy guard of its own — nothing returns early when submitting.value is already true. Why it matters: Disabling a focused button moves focus to <body>; a keyboard or screen-reader user loses their place mid-submission and, combined with finding 4, gets no announcement of where they landed or what happened. Fix: Keep the button enabled, express the busy state with aria-busy="true" plus a label change ("Activation en cours…"), and guard the handler with if (submitting.value) return;. Verification note: node_modules is not installed in this checkout, so the PrimeVue loading → disabled behaviour is asserted from the documented PrimeVue 4 Button API, not read from the installed 4.5.x source. The missing handler guard is verified directly.

7. Confirmation field gives no affirmative match feedback — Medium · FORM-07 ​

Where: services/client/src/views/AcceptInvite.vue:92-105, validation at 163What: The mismatch check runs only inside handleSubmit. While typing there is no live comparison and, crucially, no positive signal when the two values do match. Why it matters: With toggle-mask off by default the user is typing a password they cannot see, twice, and only finds out on submit that they mistyped — then has to retype both fields because the error clears nothing but also confirms nothing. Fix: Compare on every keystroke once both fields are non-empty, showing a green "Les mots de passe correspondent" as well as the mismatch error.

8. The 8-character rule appears only after it is broken — Medium · FORM-04 ​

Where: services/client/src/views/AcceptInvite.vue:75-90 (no hint text), 162 ("Minimum 8 caractères" emitted on violation) What: The only constraint copy in the form is the error. The PrimeVue strength meter shows Faible/Moyen/Fort but never states the hard minimum, and prompt-label="Entrer un mot de passe" is not a constraint. Why it matters: The user chooses a password, submits, and is bounced for a rule they were never shown — the most avoidable class of form error. Fix: Render a persistent hint under the field ("Au moins 8 caractères"), referenced by aria-describedby, exactly as the signup pattern's reference markup does with <div id="password-hint">At least 8 characters</div>.

9. Activated users are sent to a blank login form to retype what they just set — Medium · FORM-09 ​

Where: services/client/src/views/AcceptInvite.vue:22-35 (success panel links to { name: 'login' } with no state) What: resetPassword does not establish a session, and the success panel links to /login carrying nothing — not even the email, which the component already holds in email.value from the invite-info lookup. Why it matters: The user has just proved possession of a mailed token and chosen a password; they are then asked to re-enter both identity and credential on a cold form. On mobile, with a password manager that (per finding 3) never captured the credential, this is where invited users drop out. Fix: At minimum pass the email through (:to="{ name: 'login', query: { email } }") and prefill it in Login.vue; better, sign the user in directly after a successful activation and land them on home.

Where: services/client/src/views/AcceptInvite.vue:10-20What: The invalid-token panel says "Demandez à votre administrateur de vous renvoyer une invitation." and stops. There is no link — not to /login, not to /forgot-password, not a mailto or any way to identify who the administrator is. The same is true of the errors.general path (171), which leaves a red line of text under a form whose token is now known to be dead. Why it matters: A user whose invite expired (or who already used it, since consuming the token makes every later visit render this panel) has literally nothing to click. Baseline MSG-04 requires an expired token to link to the mechanism that reissues it. Fix: Add a RouterLink to login ("Vous avez déjà un compte ? Se connecter") and to forgot-password ("Recevoir un nouveau lien"), which works for an already-activated user since the invite and reset tokens are the same mechanism.

11. The invite email promises 24 hours; the token lives 7 days — Medium · CONTENT-05 (proposed) ​

Where: services/api/src/mail/mail-templates.ts:12 ("Ce lien est valable 24 heures.") vs services/api/src/auth/auth.module.ts:34 (resetPasswordTokenExpiresIn: 60 * 60 * 24 * 7) What: The stated validity period is wrong by a factor of seven. The same mismatch is in resetPasswordHtml (mail-templates.ts:29), which shares the token setting. The 7-day value is deliberate — the code comment above it explains that invitations get a longer window than the 1-hour default — so the email copy is simply stale. Why it matters: A recipient who opens the mail on day two believes the link is dead and either gives up or generates avoidable support load; conversely the copy understates how long a live account-takeover token remains valid, which is the wrong direction for a security notice. Fix: Derive the sentence from the configured value, or at minimum correct it to "7 jours" in inviteEmailHtml and to the real reset lifetime in resetPasswordHtml.

12. No field is focused on mount — Low · FORM-11 ​

Where: services/client/src/views/AcceptInvite.vue:52-117What: Single-purpose form, no autofocus and no programmatic focus after the invite-info fetch resolves. Note the first two controls are non-editable, so focus belongs on the password field, not the first field in DOM order. Why it matters: Every user starts with a mouse move or a run of Tab presses through a logo and two dead inputs. Fix: Focus the password input once loading flips to false (a template ref plus nextTick in the onMounted finally block).

13. Every route shares one document title — Low · NAV-03 ​

Where: services/client/index.html:17 (<title>Tim</title>); services/client/src/router/routes.ts (no meta.title on any of the 30 records); services/client/src/router/index.ts (no afterEach title hook) What: The tab reads "Tim" on the invite page as it does everywhere else. Why it matters: A user with the invite open alongside other tabs, or scanning browser history for the mail link they clicked, has nothing to distinguish it. This is a project-wide gap, reported here because the invite page is one of the few a user reaches from outside the app. Fix: Add meta: { title: "Créer votre compte" } to the route and a router.afterEach that writes document.title.

14. No landmark wraps the page content — Low · A11Y-04 ​

Where: services/client/src/views/AcceptInvite.vue:2 (root <div>); services/client/src/App.vue:2-25 (shell is <div id="app">, and public routes render outside LayoutApp) What: There is no <main>. Heading structure itself is correct — exactly one <h1> in each of the three states, no skipped levels. Why it matters: Screen-reader users cannot jump to the main region; on a page with no navigation at all, the "skip to content" affordance is the only structure available. Fix: Make the view's root element <main> (public routes render standalone, so there is no nesting conflict).

15. The verifying spinner is unlabelled and unnamed — Low · CONTENT-04, A11Y-05 ​

Where: services/client/src/views/AcceptInvite.vue:45-50What: <i class="pi pi-spin pi-spinner" /> is the entire loading state — an icon-only element with no accessible name, no adjacent text, and no aria-live region, inside a v-if that is otherwise empty. Why it matters: A screen-reader user hits a page that announces nothing at all while the token is checked; a sighted user gets a spinner that does not say what is being waited on ("Vérification de votre invitation…"). Fix: Add visible text next to the spinner, mark the icon aria-hidden="true", and put the pair in the role="status" region from finding 4.

16. Three different verbs for one action — Low · CONTENT-02 ​

Where: services/api/src/mail/mail-templates.ts:10 ("Définir mon mot de passe") → services/client/src/views/AcceptInvite.vue:39 (heading "Créer votre compte") → :115 (button "Activer mon compte") What: The email CTA, the destination heading and the submit button each name the task differently. Why it matters: Baseline CONTENT-02 asks the destination heading to echo the control that led there; a user who clicked "Définir mon mot de passe" lands on "Créer votre compte" and has a moment of doubt about whether the link went where they expected — on the one page in the product where a wrong-looking destination should make them stop. Fix: Pick one verb. "Définir votre mot de passe" as both email CTA and page heading reads most accurately, since the account already exists (the admin created it) — the user is not creating anything.

Passing rules worth recording ​

  • SEC-02 — pass. onMounted (AcceptInvite.vue:140-157) calls GET /api/auth/invite-info, which checks the verification row and its expiresAt server-side (services/api/src/auth/auth.controller.ts:22-31) before the password form is shown, and the endpoint is rate-limited to 10/min. Note the sibling ResetPassword.vue does not do this — it renders its form on the mere presence of ?token= — so this is worth protecting during any refactor that unifies the two views.
  • NAV-01 — pass. Success offers a RouterLink, not a setTimeout redirect.
  • NAV-02 — pass. No timers, intervals or subscriptions.
  • FORM-03 — pass. Both Password fields set toggle-mask.
  • FORM-05 — pass. The submit button is never disabled on invalid input.
  • FORM-08 / FORM-10 — pass. Nothing sits between the last field and submit; no paste handlers anywhere.
  • SEC-05 — pass. 8 characters enforced client-side (:162) and by better-auth's default minPasswordLength, with a strength meter rather than composition rules.
  • A11Y-02 — pass (marginal). The "Se connecter" link is a 20px-tall block, under the 24px minimum, but it is the only target in its panel so the WCAG 2.5.8 spacing exception applies.
  • CONTENT-01 — not-applicable — see project-level i18n finding.
  • CONTENT-03 — pass. The success panel states what happens next and links to it.
  • SEC-01, SEC-03, SEC-04, MSG-05, NAV-05 — not applicable to a token-gated first-password form with no other sessions to invalidate.

Unverified ​

  • A11Y-01 (contrast). text-red-500 error text on white, text-surface-500 helper text, and the text-primary-500 link all need a contrast tool against the resolved theme tokens (services/client/src/utils/themeColors.ts applies a per-organisation palette at runtime, so the values are not static).
  • A11Y-06 (short viewport / mobile keyboard). The layout uses a fixed pt-24 top offset with no min-h guard; whether the submit button stays reachable at ~700px height with a keyboard open needs a rendered viewport.
  • :min-length="8" on Password (AcceptInvite.vue:81, ResetPassword.vue:47). PrimeVue 4's Password has no documented minLength prop, so this is either a fallthrough attribute landing on the wrapper span (doing nothing) or a real minlength on the inner input. node_modules is not installed in this checkout, so it could not be resolved. If it is inert, the fix in finding 8 should add a real minlength via input-props.
  • PrimeVue Button loading → disabled — see the verification note on finding 6.

Baseline additions ​

  • CONTENT-05 — Stated limits match enforced limits. Copy that names a duration, quota, size or count ("valid for 24 hours", "up to 10 files", "5 attempts remaining") must be derived from, or verified against, the value actually enforced in code. Stale limit copy in security-relevant email is the common case. Evidence: finding 11 — invite email says 24 hours, token lives 7 days. Candidate for promotion once a second project shows it.
  • Consider tightening NAV-04 to name the delivery layer explicitly: for SPAs the header must come from the static file server (nginx/CDN), since an API-side Helmet configuration — present here — does not cover the HTML document. This repo shows exactly that trap.

Cross-project note ​

  • NAV-04 is the one to check everywhere: playout's password-reset audit found it, tt-time-tracker fails it at the nginx layer, and customer-portal and members both have token-in-URL reset/invite routes. The tt-time-tracker variant is instructive — the team did configure Helmet, but on the API only.
  • FORM-01 + FORM-02 together ("bare <label> next to a library input, no autocomplete") are almost certainly systemic here: ResetPassword.vue:43, 63 is character-for-character the same construction, and customer-portal uses the same PrimeVue Password component, where inputId is equally required.
  • MSG-01 (no role="alert" on validation errors) is a hand-rolled-form symptom; playout escapes it via FormKit, so expect it in customer-portal and members wherever forms are assembled by hand.
  • FORM-09/auto-sign-in after activation is worth checking in members and customer-portal invite flows — the "set a password, now log in again" round trip is a common consequence of using a reset-password primitive as an invite.