Appearance
[UX] tt-time-tracker — Email verification
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Dr-Wade/tt-time-tracker · Branch:
develop@bb3238c· Files reviewed: 12 Patterns:authentication/signup
Summary
VerifyEmail.vue is a small, careful component — it verifies the token before showing any success state and explicitly handles better-auth's result-shaped (non-throwing) errors, so SEC-02 passes and the "false success" trap is already closed. The problem is one level up: no code path in the application ever sends a verification email, so the route is unreachable except by hand-crafting a URL. Everything downstream of that — no resend control, an email that promises a validity the config does not implement, an error state whose only exit is the login page — compounds into a flow that cannot be completed or recovered if it ever is switched on.
On the customer-portal trap (MSG-04): tt-time-tracker does not reproduce it. verify-email is a public route (App.vue:145), router/index.ts has no emailVerified gate at all, and nothing pins an unverified user anywhere. There is no "stuck on the page with no sign-out" shape here. The MSG-04 failure in this project is a different one — see finding 2.
Findings
1. Nothing ever sends a verification email — the feature is unreachable — Blocker · FEATURE-REACHABLE (proposed)
Where: services/api/src/auth/auth.module.ts:54-66; services/client/src/views/VerifyEmail.vue:1What: The emailVerification block defines sendVerificationEmail, but nothing triggers it:
sendOnSignUp: false(auth.module.ts:55) — so sign-up does not send it.requireEmailVerificationis never set, so sign-in does not send it either (grep acrossservicesandpackages:emailVerificationappears only atauth.module.ts:54and:57).authClient.sendVerificationEmailis never called from the client.lib/auth.tsdoes not even re-export it, and there is no resend UI anywhere.- The only account-creation path is the admin invite (
user.service.ts:175-193), which issues a reset-password token;onResetPassword(auth.module.ts:36-40) then setsemailVerified: truedirectly. Google sign-in carries verification from the provider.
So the route, the view, the mail template (mail-templates.ts:35-50) and its unit tests all exist for a flow that no user can enter. Why it matters: Either email verification is intended and is silently non-functional in production, or it is deliberately dormant and the repo is shipping a publicly reachable dead route plus a mail template that will drift out of maintenance. Nobody reading the code can tell which, and neither state is safe to leave. Fix: Decide explicitly. If verification is wanted: set sendOnSignUp: true (or call sendVerificationEmail from the invite path) and add requireEmailVerification with a "check your email" holding screen. If it is not wanted: delete the route, the view and verifyEmailHtml. Severity note — this is a close call. Blocker under "the feature does not work as intended": the entire chain is wired except its trigger. If the team confirms verification is deliberately off, downgrade to Medium and treat it as dead-code removal.
2. An invalid or expired link is unrecoverable — no resend exists anywhere — High · MSG-04
Where: services/client/src/views/VerifyEmail.vue:29-39What: Both failure branches (missing token, :57-61; rejected token, :65-70) render one red line plus a single RouterLink to login. There is no "renvoyer le lien" control on this page, and — per finding 1 — no such control exists anywhere else in the product either. Signing in does not re-trigger verification, because requireEmailVerification is unset. Why it matters: MSG-04 requires that an expired token link to the form that reissues it, not just "back". Here "back" is the only exit and it leads nowhere useful: the user lands on a sign-in form that will not send them a new link. The authentication/signup pattern lists "allow users to resend the verification email with rate limiting" under its Email Verification security requirements. Fix: Add a resend control to the failure state — an email input (the token is gone, so the address cannot be recovered from it) plus a rate-limited call to authClient.sendVerificationEmail, with a neutral confirmation that does not reveal whether the address exists (SEC-01).
3. The email promises 24 hours of validity that the config does not implement, and neither surface covers the failure path — Medium · CONTENT-03, MSG-03
Where: services/api/src/mail/mail-templates.ts:46; services/client/src/views/VerifyEmail.vue:29-39What: Two separate copy defects on the same flow. (a) The verification email states « Ce lien est valable 24 heures. » (mail-templates.ts:46). emailVerification.expiresIn is never set (auth.module.ts:54-66), so the token lives for better-auth's default — which the same file goes out of its way to override for the reset flow (resetPasswordTokenExpiresIn: 60 * 60 * 24 * 7, auth.module.ts:35), showing the authors knew the default was not what they wanted there. The exact default cannot be read here (node_modules absent — see Unverified), but the "24 heures" figure corresponds to no value anywhere in the codebase. The same literal appears in inviteEmailHtml:12, where the code provably sets 7 days — so the string is a copy-paste constant, not a statement of fact. (b) Neither the email nor the page mentions the spam folder, what to do when the link has expired, or that a new link can be requested. The error copy « Lien invalide ou expiré. » collapses two different situations with two different remedies into one sentence and offers no next action. Why it matters: A user who opens the link outside the window is told it is "invalid or expired" after being told it was good for 24 hours, with no way to act. CONTENT-03 requires the confirmation state to cover the failure path. Fix: Set emailVerification.expiresIn explicitly and generate the duration into the email copy from that constant rather than hardcoding it. Split the error copy by cause and give each a next step ("Ce lien a expiré — demandez-en un nouveau" vs "Ce lien n'est pas valide"). Add spam-folder guidance to whatever "check your email" screen finding 1 produces.
4. Success auto-redirects on a 2-second timer that is never cleared — Medium · NAV-01, NAV-02
Where: services/client/src/views/VerifyEmail.vue:73What: setTimeout(() => router.push({ name: "login" }), 2000). Three problems in one line:
- NAV-01 / WCAG 2.2.1 (Timing Adjustable): a timed automatic redirect with no control to extend, pause or trigger it. Two seconds is not enough to read « E-mail vérifié avec succès ! » on a slow render, and there is no way back to the confirmation once it fires.
- NAV-02: the handle is discarded, so the timer is not cleared on unmount. A user who navigates away inside the window (browser back, a bookmark) is yanked to
/loginfrom wherever they went. - Wrong destination.
autoSignInAfterVerification: true(auth.module.ts:56) means the server has already established a session, yet the view sends the user to the sign-in screen. It only appears to work because of an unrelated bounce atApp.vue:151(if (user && name === "login") goToReturnUrl()), which depends on the better-auth session store having refreshed — the view never refreshes it, andlib/session.tsdoes not either. If it has not refreshed, the user is asked to sign in again despite being signed in.
The copy « Redirection en cours… » (:24-26) also never names the destination (CONTENT-04). Fix: Drop the timer. Refresh the session, then render an explicit « Continuer vers l'application » button pointing at home, and name the destination in the copy.
5. Every state change is silent to assistive technology — Medium · MSG-01
Where: services/client/src/views/VerifyEmail.vue:10-39What: The page is a pure state machine — spinner → success or error — and none of the three branches carries a live region. The error is a bare <p class="text-sm text-red-500"> (:30) with no role="alert"; the success text (:21-23) has no role="status" / aria-live="polite"; the pending text (:12-14) announces nothing either. Because the whole block is swapped by v-if, a screen reader that has already finished reading the page gets no notification at all. Related: success is signalled by a bare ✓ glyph in a <p> (:18-20) and failure by red text alone — no icon with an accessible name, no textual "Erreur" label, so the state is conveyed by colour and a decorative character. Why it matters: WCAG 4.1.3. A non-sighted user lands on this page, hears "Vérification en cours", and then hears nothing — never learning whether their address was verified. Fix: Wrap the swapped block in a single container with aria-live="polite" and aria-atomic="true", give the error branch role="alert", and prefix the states with a visible word (« Erreur : », « Vérifié ») rather than relying on colour and the checkmark.
6. A single-use token sits in the URL with no noindex and no referrer policy — Medium · NAV-04
Where: services/client/index.html:1-19; services/client/src/views/VerifyEmail.vue:56What: The verification link is ${appUrl}/verify-email?token=… (auth.module.ts:59). index.html sets no robots meta and no meta name="referrer", and a grep for noindex|referrer|robots across services, deploy and the Docker configs returns nothing — so there is no response-header enforcement either. The view also never strips the consumed token from the address bar (no router.replace), leaving it in browser history, in the back/forward stack and visible over the user's shoulder after it has been used. Why it matters: NAV-04 requires token-bearing URLs to carry noindex and a no-referrer policy enforced by header. Severity note: Medium rather than High. The token is single-use and consumed on mount, and the URL must first leak somewhere crawlable for indexing to matter — this is a hardening gap that needs a second condition, not something an outsider can act on directly. Fix: Serve X-Robots-Tag: noindex, nofollow and Referrer-Policy: no-referrer for /verify-email from the web server, and call router.replace({ query: {} }) once the token has been consumed.
7. better-auth's English error text reaches the user; the French fallback is dead code — Medium · MSG-02
Where: services/client/src/views/VerifyEmail.vue:67, :75; services/client/src/utils/index.ts:44-50, :83-86What: The view calls extractErrorMessage(error, "Lien invalide ou expiré."). extractErrorMessage maps a code to French copy only for the five app-level codes in ERROR_CODE_MESSAGES (ENTRY_OVERLAP, UNAUTHORIZED, FORBIDDEN, NOT_FOUND, VALIDATION). better-auth's codes (INVALID_TOKEN, TOKEN_EXPIRED, …) are not among them, so control falls through to fromValue(e.message) at :83, which returns the SDK's English message. The French fallback passed at the call site is therefore only ever used when the error object carries no message at all. Why it matters: In a French-only UI (no i18n layer by design), the sole failure state of this page renders English SDK prose such as "invalid token" — exactly the MSG-02 shape already recorded project-wide for customer-portal, but here caused by an unmapped code table rather than a deliberate preference. Fix: Add the better-auth email-verification codes to ERROR_CODE_MESSAGES, or have the auth views pass extractErrorCode through a local map and use the French fallback whenever the code is unrecognised. The same call pattern appears on Login.vue:89/:101 and the other auth views, so fix it in the helper.
8. No heading and no landmark in any state — Medium · A11Y-04
Where: services/client/src/views/VerifyEmail.vue:1-43What: The page contains no <h1> — no heading element of any level. All three states are built from <p> and <div>, and the route renders outside LayoutApp (routes.ts:40), so no ancestor supplies one either; App.vue provides only a <div id="app">, no <main>. Why it matters: A screen-reader user cannot orient by heading or landmark navigation, and the page has no accessible name beyond the static document title (NAV-03, project-level). Fix: Add an <h1> per state (« Vérification de votre e-mail » / « E-mail vérifié » / « Vérification impossible ») and wrap the card in <main>.
Not applicable / passing
- SEC-02 — passes. The token is verified before any success UI is shown, and
VerifyEmail.vue:63-64documents the better-auth{ error }result shape explicitly so a rejected token cannot render a false confirmation. This is the best-handled part of the feature. - CONTENT-01 — not-applicable — see project-level i18n finding.
- NAV-03 — fails project-wide, see PROJECT-LEVEL.md (
index.html:17, « Tim »). - FORM-01…FORM-11 — not applicable. The view renders no form or input.
- SEC-01 — not applicable to this page (it accepts a token, not an address). It becomes applicable the moment finding 2's resend form is added.
- MSG-05, NAV-05, CONTENT-02 — not applicable.
Unverified
- A11Y-01 (contrast).
text-surface-500on white for the two secondary lines (:12,:24) andtext-red-500for the error (:30) are the likeliest failures at 14px, but this needs computed colour values against the runtime Tailwind/PrimeVue theme — andapplyTheme(App.vue:32-41) recolours from org-supplied theme data, so it varies per tenant. - A11Y-06 (short viewport).
pt-24plus a fixed-height logo on a centred card needs a rendered viewport to judge. - better-auth's default
emailVerification.expiresIn.node_modulesis not installed, so the actual token lifetime backing finding 3(a) could not be read from source. What is verifiable: the value is never set in this repo, and the identical "24 heures" string ininviteEmailHtmlis provably wrong (7 days). ProgressSpinnersemantics. Whether PrimeVue 4's spinner emitsrole="progressbar"with an accessible name could not be confirmed from source.- Rate limiting on
/verify-email. better-auth ships defaults; none are configured inauth.module.ts, and the effective limit could not be read.
Baseline additions
Descriptive IDs with definitions — the orchestrator should renumber.
FEATURE-REACHABLE— every shipped route must be reachable from at least one in-product path. A view whose only entry point is a URL no code ever produces is either a broken feature or dead code, and must be resolved as one or the other. (Cited by finding 1. Related to, but distinct from, the/devobservation in the tt queue file.)COPY-MATCHES-CONFIG— a duration, limit or quantity stated in user-facing copy must be derived from the constant that implements it, never hardcoded alongside it. (Cited by finding 3(a); the "24 heures" literal is wrong in two of the three mail templates.)TOKEN-STRIP— a consumed single-use token is removed from the address bar once used. (Already proposed elsewhere as NAV-06(a); noting the collision rather than claiming a new ID.)
Not filed as a finding, flagged as a project-level candidate:services/client/index.html:6 sets maximum-scale=1.0,user-scalable=0, disabling pinch-zoom app-wide (WCAG 1.4.4). This matches the already-proposed A11Y-07(b) in PROJECT-LEVEL.md and belongs in one project-level finding, not in 25 feature drafts.
Cross-project note
- Finding 4 (timed redirect, NAV-01) — the customer-portal email-verification screen is the obvious sibling; playout's password-reset audit seeded NAV-01 from exactly this shape. Check members' post-action screens too.
- Finding 2 (MSG-04, no resend) — customer-portal fails MSG-04 on the same feature by a different mechanism (guard pins the user, no sign-out). Both projects reach "unrecoverable email-verification state" from opposite directions; worth one aligned recommendation.
- Finding 7 (MSG-02) — customer-portal already has this at the helper level (
getErrorMessage) and members at the shared-client level (widgets/src/api.ts). That is three of four projects leaking provider/SDK strings from a shared helper. Strong candidate for the cross-project alignment table. - Finding 6 (NAV-04) — every project in the programme has at least one token-in-URL route (
reset-password,accept-invite,verify-email). None has been found so far to setnoindexor a referrer policy by header. Likely 4/4. - Finding 1 — probably unique to tt-time-tracker; it stems from this repo's admin-invite-only account model.