Skip to content

[UX] tt-time-tracker — Onboarding wizard ​

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

Summary ​

The wizard is well structured on the happy path — five steps, a skip, a per-step save, a French-language summary screen — but it is the one screen in the app the router will not let an admin leave, and its failure paths are all wrong. The final "Configuration terminée" screen offers three buttons that do literally nothing (the guard bounces every one of them straight back), a failed invite makes the invite step permanently unpassable, the summary reports invitations as sent even when the wizard itself has just said email is not configured, and a failed "Terminer" leaves the client believing onboarding completed while the server still says it did not. Nothing here needs a rendered page to see; it is all visible in useOnboarding.ts and the router guard.

Findings ​

1. The three buttons on the final step are inert — the router bounces every one of them — High · MSG-04 ​

Where: services/client/src/components/Onboarding/OnboardingStepDone.vue:41-61, guard at services/client/src/router/index.ts:56What: The done step offers « Gérer les projets », « Gérer les utilisateurs » and « Paramètres », each a $router.push({ name: 'projects' | 'users' | 'settings' }). But onboarding is only marked complete when the user presses « Terminer » (useOnboarding.ts:121), and until then the guard at router/index.ts:56 redirects any route that is not onboarding / choose-organization back to onboarding. The redirect target is the route the user is already on, so vue-router resolves it as a duplicated navigation, the view never remounts, and nothing at all happens on screen. Why it matters: On the screen that says the workspace is ready, three of the four visible controls are dead. The user taps, gets no navigation, no error and no toast, and has to guess that the unrelated « Terminer » button is the one that unlocks the app. Fix: Have these buttons complete onboarding first and then navigate — e.g. await completeOnboarding(); router.push({ name: 'projects' }) — reusing the same error handling as next(). Alternatively drop them and let « Terminer » land on the dashboard, which is what actually happens today. (Severity call: not a Blocker because « Terminer » still works and the flow can be completed; High because three controls silently do nothing.)

2. A failed invite makes the invite step permanently unpassable — step submits are not idempotent — High · FORM-09 (proposed FLOW-IDEMPOTENT-STEP) ​

Where: services/client/src/composables/useOnboarding.ts:91-102 and 126-140What: saveInvites() loops over every filled row and awaits users.add() one at a time. If row 3 fails (mistyped address, duplicate, server error), rows 1–2 are already created, the error is toasted, and currentStepIndex is not advanced — so the user stays on the step with all rows still on screen. Pressing « Suivant » again re-submits from row 1, and the API rejects an already-present member with A user with email '…' already exists in this organization (services/api/src/modules/user/user.service.ts:113). The retry therefore fails harder than the original attempt, and every subsequent retry fails on row 1. The same re-submit happens on any « Retour » from the done step followed by « Suivant ». Why it matters: The only remaining exit is « Passer », which abandons the rest of onboarding entirely (see #7). The error names an address the user successfully invited seconds ago, so it reads as a bug in their own input. Fix: Make the step submit idempotent: drop rows that succeeded from inviteRows as each users.add() resolves, and report per-row outcomes ("2 invités, 1 échec — corrigez l'adresse") instead of aborting the whole batch on the first rejection. The same applies to saveProject() — see #5.

3. The summary claims invitations were sent when the API said they were not — High · proposed MSG-06 (see PROJECT-LEVEL.md) ​

Where: services/client/src/composables/useOnboarding.ts:100, services/client/src/components/Onboarding/OnboardingStepDone.vue:33-37What: invitesSent is incremented for every row whose users.add() resolves. The API deliberately reports partial success — createWithAuth returns inviteSent / inviteFailed and persists inviteFailed on the profile precisely "so the admin UI can show 'created, invite failed — resend' instead of assuming the invite went out" (services/api/src/modules/user/user.service.ts:148-167). The client collection discards that response body (services/client/src/composables/collections/useArchivableCollection.ts:47-50 awaits isPersisted and returns nothing), so the wizard cannot see it. Worse, when SMTP is unconfigured sendInvite does not throw at all — it logs "invite token created but email not sent" (user.service.ts:188-191) — so the done step announces « N invitations envoyées » on the same run in which the invite step displayed « Les notifications par e-mail ne sont pas activées » (OnboardingStepInvite.vue:55-58). Why it matters: The admin finishes onboarding believing their team has been emailed. They have not been, and nothing on this screen points at the resend affordance that does exist in the Users list (views/Admin/Users.vue:68). Fix: Surface the create response through the collection wrapper and count only inviteSent === true; when smtpConfigured is false, say « N comptes créés — aucun e-mail envoyé » and link to the Users list where the invite link can be copied or resent.

4. completeOnboarding() flips the local flag before the server write and never rolls it back — High · proposed MSG-06 ​

Where: services/client/src/stores/organization.store.ts:85-93What: data.value.onboardingCompleted = true is assigned beforeorganizationControllerUpdate is awaited, and the catch lives in the caller (useOnboarding.ts:135, :153), which only toasts. On a failed PATCH the client now believes onboarding is complete while the server still says it is not. Why it matters: The two guards read that flag in opposite directions. After a failed « Terminer » the user sees « Une erreur est survenue », but the next navigation now escapes the wizard (router/index.ts:61 redirects onboarding → admin-dashboard), and on the next page load fetchOrganization re-reads false from the server and drops them back at step 1 of the wizard with every entered invite row gone. The wizard pattern's own checklist calls this out: asynchronous state must reconcile correctly after a failure. Fix: Await the PATCH first and assign the flag only on success (or restore the previous value in a catch and re-throw), mirroring the ordering already used by update() on line 95.

5. The project name is wiped after save, so a step back offers to create it twice — Medium · FORM-09 ​

Where: services/client/src/composables/useOnboarding.ts:78-89What: saveProject() clears projectName after a successful create. Pressing « Retour » therefore returns to an empty « Nom du projet » field with no indication that a project was created at all. If the user re-enters the same name and presses « Suivant », saveProject() runs again and a second project with the same name is created — there is no idempotency guard equivalent to the duplicate-member check that protects invites. Why it matters: FORM-09 — a value the user supplied is not carried forward, and the flow silently rewards retyping it with a duplicate record they then have to archive from the admin area. Fix: Keep projectName populated and track the created project id; on re-submit, rename the existing project rather than creating another. Show the created project on the step ("Projet « X » créé") instead of an empty field.

6. Wizard progress is not a real state — reload or browser Back restarts it from step 1 — Medium · NAV-05 ​

Where: services/client/src/composables/useOnboarding.ts:30, services/client/src/router/routes.ts:42What: currentStepIndex, projectName and inviteRows are plain component refs behind a single /onboarding route. There is no step in the URL and no persistence. A refresh, a PWA cold start, or the browser Back button (which leaves the route and is immediately bounced back by the guard) returns the user to step 1 with every typed invite row lost — while the project and members already created on the server remain, invisibly. Why it matters: This is a mobile PWA; a backgrounded tab being reclaimed is routine. The advanced/wizard pattern lists a save-and-resume control as part of the anatomy and requires that "state survives refresh, navigation, or retry in the way users would expect". Fix: Put the step in the route (/onboarding/:step) or at minimum persist step index and form state to sessionStorage, and resume at the furthest step reached.

7. « Passer » irreversibly ends onboarding, is labelled as if it skipped one step, and discards the current step's input — Medium · MSG-05 ​

Where: services/client/src/views/OnboardingWizard.vue:6-14, services/client/src/composables/useOnboarding.ts:148-158What: The header button reads « Passer » and sits next to a five-dot step indicator, so it reads as "skip this step". It actually calls completeOnboarding() and navigates to the dashboard, and once the flag is set router/index.ts:61 permanently redirects /onboarding away — the wizard can never be re-entered. Anything typed on the current step (organization name, theme, feature toggles, invite rows) is never sent, since skip() does not call organization.update(). Why it matters: One tap on an ambiguous secondary control ends a setup flow for good and silently drops what is on screen. MSG-05: an irreversible action must confirm and say what will be lost. Fix: Relabel to « Configurer plus tard » and confirm ("Vous pourrez tout régler depuis Paramètres, mais cet assistant ne réapparaîtra pas"), or save the current step before completing.

8. Invite rows have no labels and no client-side validation, so a typo returns an English validator string — Medium · FORM-01, FORM-04, MSG-02 ​

Where: services/client/src/components/Onboarding/OnboardingStepInvite.vue:19-31What: Both inputs are labelled by placeholder alone ("Nom", "Email") with no <label for>, no id and no aria-label. There is no format check before submit — saveInvites() only tests .trim() truthiness — and the client User schema declares email: z.string().optional() with no .email() (packages/schemas/src/user.schema.ts:8). The server does validate (@IsEmail() in services/api/src/modules/user/dto/create-user.dto.ts:26), and extractErrorMessage falls through to the raw constraints text (services/client/src/utils/index.ts:63-81) because a class-validator error carries no mapped code. Why it matters: A screen-reader user hears an unlabelled text box; a typo produces "email must be an email" in English in an otherwise French UI, after the earlier rows have already been created (see #2), with no indication which row is at fault. Fix: Real <label for>/id pairs (the features step already does this correctly — OnboardingStepFeatures.vue:19-31), type="email" plus a client-side format check that marks the offending row invalid before submit, and a French message.

9. Step changes are not announced, and the progress indicator has no accessible or mobile representation — Medium · MSG-01 ​

Where: services/client/src/components/Onboarding/OnboardingProgress.vue:1-31, services/client/src/views/OnboardingWizard.vue:27-41What: The indicator is a row of unlabelled <div>s; state is carried by size and colour only, with no aria-current, no list semantics and no "Étape 2 sur 5" text. The labels that do exist are hidden sm:block (line 20), so on a phone — the primary form factor here — there is no textual progress information at all. The step swap itself happens inside a <Transition> with no role="status" / aria-live region and no focus move to the new step heading, so a screen-reader user hears nothing when « Suivant » succeeds. Why it matters: WCAG 4.1.3. A keyboard/screen-reader user cannot tell whether pressing « Suivant » did anything, or how much of the flow remains. Fix: Render the indicator as <ol>/<li> with aria-current="step" (the pattern's reference implementation does exactly this), add a visible « Étape {n} sur {total} » string that survives the sm breakpoint, and move focus to the new step's <h2> after each transition.

10. Remove-row button is icon-only, unnamed, and below the minimum target size — Medium · A11Y-05, A11Y-02 ​

Where: services/client/src/components/Onboarding/OnboardingStepInvite.vue:33-39What: <button class="mt-1.5 …"><MdiClose class="text-lg" /></button> — no aria-label, no type="button", no text, and no padding, so the hit area is the 18px glyph. Why it matters: A screen reader announces "button" with no name; on a touch screen the control is under the 24×24 CSS px floor of WCAG 2.5.8, next to the email field it deletes. Fix: aria-label="Supprimer la ligne {n}" (or the invitee's name) plus p-2 so the target reaches 24px.

11. No <h1> on the wizard; headings start at <h2> — Low · A11Y-04 ​

Where: services/client/src/views/OnboardingWizard.vue:5What: The page title « Configuration » is a <span>; each step's own title is an <h2> (e.g. OnboardingStepProfile.vue:4). Landmarks are otherwise correct — <header>, <main>, <footer> are all present. Fix: Make line 5 an <h1>, or promote the step title to <h1> and keep the shell label as a <span>.

12. « Retour » is not guarded while a save is in flight — Low · NAV-05 ​

Where: services/client/src/views/OnboardingWizard.vue:47-54, services/client/src/composables/useOnboarding.ts:126-146What: « Suivant » is bound to :loading="saving"; « Retour » is not. Pressing it mid-save decrements currentStepIndex, and the in-flight next() then increments whatever index it finds, landing the user back on the step they just left — after its save has already run. Fix: :disabled="saving" on « Retour », or capture the index at the start of next() and only advance from it.

13. /onboarding does not declare requiresAdmin — Low · proposed SEC-ROUTE-ROLE-PARITY ​

Where: services/client/src/router/routes.ts:42What: The route carries only requiresAuth. Nothing redirects a plain member who types /onboarding while the organization is un-onboarded, so they get the full organization-configuration wizard. The API enforces the real permission, so saves fail — but the theme picker (OnboardingStepProfile.vue:66-78) applies immediately app-wide via the watcher in App.vue:36, and the org name field edits the shared store object in place. Why it matters: A non-admin sees, and can locally mutate, org-wide settings before hitting a raw server error. Low rather than High because the server does enforce authorization — this is UI exposure, not a bypass. Fix: Add meta: { requiresAdmin: true } to the route, or gate the guard's redirect and the view on authStore.isAdmin.

14. Every step template dereferences organization.data! — Low ​

Where: services/client/src/components/Onboarding/OnboardingStepProfile.vue:18,33,53,71, OnboardingStepFeatures.vue:29What: Non-null assertions on a ref that is genuinely nullable — fetchOrganization swallows failures and leaves data null when there is no cached copy (organization.store.ts:49-53), and the guard on router/index.ts:61 only redirects away from /onboarding when data is truthy. A direct navigation with unresolved org data renders a TypeError instead of the wizard. Fix: v-if="organization.data" around the step body with a loading state, which the pattern's checklist expects alongside the default state.

Project-level, not re-filed here ​

  • NAV-03 — no route sets a document title; fails project-wide, see PROJECT-LEVEL.md.
  • CONTENT-01 — not-applicable — see project-level i18n finding.
  • MSG-04 more broadly: the guard traps a not-yet-onboarded admin on this screen, and the screen has no logout, no organization switcher and no support link, so a persistently failing completeOnboarding (offline PWA, 5xx) leaves the admin with no exit at all. This is the same rule another agent proposed as MSG-06(c) ("a router-enforced gate must expose the escape hatch that clears it") in PROJECT-LEVEL.md — recorded here rather than filed separately, since finding #4's optimistic flag is the only thing that currently lets anyone out.

Unverified ​

  • A11Y-01 (contrast) — the dim states (text-surface-400 on white for future step labels and hint text, text-[10px] step labels, text-blue-700 on bg-blue-50) look like the usual failure candidates but need a contrast tool on a rendered page.
  • A11Y-06 (short viewport / mobile keyboard) — the wizard is a min-h-screen flex column with a fixed header, a progress strip, a scrolling <main> and a pb-safe footer; whether the footer buttons stay reachable with a soft keyboard open needs a device.
  • FORM-06 — « Suivant » and « Passer » use PrimeVue's :loading. Whether that also sets disabled (and so drops focus to <body> on activation) cannot be confirmed: node_modules is not installed in this checkout.
  • Whether clearing the logo (OnboardingStepProfile.vue:54) actually persists the deletion depends on how the generated SDK serialises an undefined body field; not readable from source here.
  • Whether crypto.randomUUID() client-side ids in useArchivableCollection.insert survive the server's own id assignment — not exercised by any onboarding test.

Baseline additions ​

  • FLOW-IDEMPOTENT-STEP — Re-submitting a step of a multi-step flow after a partial failure must not re-attempt work that already succeeded, and must never make the step less passable than the first attempt. (Cited by #2, #5. This is the same defect shape the customer-portal onboarding audit found; it is distinct from FORM-09, which is about not retyping values.)
  • SEC-ROUTE-ROLE-PARITY — A route that renders privileged administration UI declares the same role requirement its API enforces; server-side enforcement is not a substitute for not rendering the screen. (Cited by #13.)
  • Progress-indicator rule (suggest folding into MSG-01 rather than a new ID): a step indicator exposes step position as text, not only as shape/colour, and that text survives every breakpoint. (Cited by #9.)

Cross-project note ​

  • #2 / #3 (non-idempotent step submit, success reported for work the API flagged as failed) — customer-portal has the identical shape in its own onboarding, per this batch's earlier audit. Worth aligning both on one rule.
  • #4 (optimistic local flag written before the server confirms, never rolled back) matches the "success feedback fires before the write resolves" family already recorded for playout in PROJECT-LEVEL.md.
  • #9 (visual-only progress/step indicator, unannounced step change) is likely in any project with a multi-step flow — check customer-portal sign-up and members onboarding.
  • #10 (icon-only control with no accessible name, sub-24px target) is the A11Y-03/A11Y-05 family already confirmed in all four projects.