Appearance
[UX] tt-time-tracker — Users
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Dr-Wade/tt-time-tracker · Branch:
develop@bb3238c· Files reviewed: 24 Patterns:authentication/user-profile
Summary
The Users list and detail screens are among the better-built surfaces in this repo: the table has a real error state with retry, the empty states are context-aware with an action, invite failures are surfaced honestly rather than reported as success, and the admin password reset revokes the target's sessions server-side. The serious problem is at the other end of the lifecycle: "Archiver" is the only offboarding control the product has, and it does not remove the user's access to anything — the authorization path never reads archived, so an archived user keeps their sessions, their role and full API access while disappearing from the admin's list. Secondary themes: unsaved edits are silently overwritten by background refetches, the page's action buttons lose their accessible name on mobile, and a member who follows a link to /admin/users is bounced to their timesheet with no explanation at all.
Findings
1. Archiving a user does not revoke their access — Blocker · SEC-ACCESS-REVOCATION (proposed; SEC-03-adjacent)
Where: services/client/src/views/Admin/Users/UserDetails.vue:61-69 (« Archiver ») → services/api/src/modules/user/user.service.ts:264-269 and services/api/src/casl/organization.guard.ts:98-117What: Archiving sets memberProfile.archived = true and nothing else. The member row (which carries the role) is untouched, live sessions are not revoked, and no authorization path consults archived: OrganizationGuard.resolveRoleAndUser looks up member only (organization.guard.ts:107-117), and OrganizationService.getUserRole (organization.service.ts:248-259) likewise returns member.role regardless of archive state. The API's DELETE /users/:id is itself just archived: true (user.service.ts:267), so there is no stronger action behind it. Grep confirms archived appears in the API only in list filters, the duplicate-user check and the archive write — never in an auth or session path. Why it matters: An admin offboarding a departing employee archives them, sees them vanish from « Utilisateurs », and reasonably concludes access is gone. It is not: that person can still sign in (local credentials and session cookies both still work), still record hours, still read organization data — and is now invisible in the list where an admin would look for them. There is no other control anywhere in the product that removes a user's access. Severity call: Blocker rather than High because the capability an admin needs — remove this person's access — is unreachable in the entire product, and the control that appears to provide it silently does not. If the team's intent is that « Archiver » is only list hygiene, this downgrades to High and becomes finding #7 (copy) plus a missing feature. Fix: Have the archive write revoke the user's sessions (prisma.unscoped.session.deleteMany({ where: { userId } }), as resetPassword already does at user.service.ts:249-252) and make resolveRoleAndUser / getUserRole return null for an archived profile so the guard rejects. Then say so in the confirm dialog (see #7).
2. Unsaved edits are silently overwritten by a background refetch — High · FORM-DISCARD-GUARD (proposed; collides with the pending FORM-12(d))
Where: services/client/src/views/Admin/Users/UserDetails.vue:418-427, services/client/src/composables/useChangeTracker.ts:22-27, services/client/src/collections/queryClient.ts:7-16What: The edit form binds to a copy produced by useChangeTracker, whose watcher is { immediate: true, deep: true } on the query getter and unconditionally does copy.value = formatFunction(val) on every emission. The source is a TanStack query with staleTime: 30_000 and no refetchOnWindowFocus: false (the default is true), so any window refocus after 30s re-resolves userControllerFindOne with a new object identity, fires the watcher, and replaces whatever the admin has typed into « Nom » with the server value. Nothing warns, nothing highlights, the field just reverts. The same helper backs the other admin detail screens, so the defect is not confined to Users. Why it matters: Typed work is lost with no signal — the admin's next act is to press « Enregistrer », which saves the old value while showing "Les informations ont été sauvegardées". The reverse also holds: handleUpdate (line 442) never invalidates the findOne query, so after a successful save original stays stale, changed stays true, and the Save button remains enabled — the screen cannot distinguish saved from unsaved either way. Fix: In useChangeTracker, skip the overwrite while changed.value is true (or expose an explicit reset()), and set refetchOnWindowFocus: false for detail queries backing a form. Invalidate the findOne query after a successful handleUpdate so changed clears. Additionally add an onBeforeRouteLeave guard: the « Retour » button (UserDetails.vue:5-11) and the archive action (:506) both router.push away from a dirty form with no prompt.
3. Mobile: every action button on both screens loses its accessible name — High · A11Y-05
Where: services/client/src/components/Layout/LayoutMain.vue:117-127 (.mobile-header-buttons :deep(.p-button-label) { display: none }), consumed by Users.vue:17-35 and UserDetails.vue:41-78What: The mobile header renders the #buttons slot and then hides PrimeVue's .p-button-label with display: none, turning every labelled button into an icon-only button. display: none removes the text from the accessibility tree, and none of these buttons carries an aria-label: « Ajouter » (AddButton.vue), « Renvoyer l'invitation », « Réactiver », « Archiver » and « Enregistrer » (UserDetails.vue:42-77) all announce as an unnamed button on mobile. The one button that does have an aria-label is the overflow menu (Users.vue:23), which shows the team knows the pattern. Why it matters: On a phone a screen-reader user cannot tell Save from Archive on the user-detail page — the two most consequential controls on the screen — and cannot find "add user" on the list. Fix: Give each Button an aria-label matching its label, or replace the CSS label-hiding with PrimeVue's own icon-only rendering, which keeps aria-label intact. Fix belongs in LayoutMain.vue plus the call sites; it affects every page in the app, so it is a project-level candidate.
4. No <h1>, and on the detail page an <h1> nested inside an <h2> — Medium · A11Y-04
Where: services/client/src/components/Layout/LayoutMain.vue:6 and :75 (both render <slot name="title"/> inside an <h2>); UserDetails.vue:22-24What: LayoutMain wraps the page title in <h2>, so the Users list page has no <h1> at all and its first heading is level 2. UserDetails puts its own <h1> in that slot, producing <h2><h1>Nom</h1></h2> — invalid nesting and an h2 → h1 → h3 order down the page. The heading element also contains the « Retour » button and the avatar, so the heading's accessible name absorbs "Retour". The authentication/user-profile pattern's accessibility checklist calls for "proper heading hierarchy (h1 for name)" explicitly. Why it matters: Heading navigation (a primary screen-reader mechanism on a data-dense admin page) gives a wrong and, on the detail page, structurally invalid outline. Fix: Make LayoutMain's title element an <h1> (configurable via prop if a view needs otherwise), move the back button and avatar out of the heading, and drop the inner <h1> in UserDetails.
5. A member who opens an /admin/* link is bounced with no explanation — Medium · MSG-04 (also MSG-03)
Where: services/client/src/router/index.ts:41-48What: if (!authStore.canAccessAdmin) return { name: "home" } and, for superadmin routes, return { name: "admin-dashboard" }. Neither branch sets a message, a toast, or a destination that explains anything; there is no forbidden/unauthorized view in the app. tt-time-tracker is the only one of the four programme projects without one (playout, customer-portal and members all have a dedicated surface). Why it matters: A user sent a link to a colleague's record (/admin/users/<id>) lands on their own timesheet with the URL rewritten and no indication of why. The dead end offers no route out because the user does not know they hit one — they conclude the link is broken and ask someone. It is also indistinguishable from the app losing their navigation. Fix: Add a forbidden route rendering "Vous n'avez pas accès à cette page" with the requested path, who to ask, and a link back to the user's own home; redirect there instead of to home/admin-dashboard. A toast on the redirect is the minimum viable version. Note: the same guard busy-waits on authStore.role with no timeout (router/index.ts:34-40). In practice the role fetch always resolves (auth.store.ts:63-65 falls back to "member" on error), so this is a hang only if the request never settles — recorded, not filed.
6. Nothing on either screen shows or sets a user's role — Medium · ROLE-VISIBILITY (proposed)
Where: Users.vue:178-186 (columns), UserDetails.vue:1-340 (no role UI); grant/revoke lives in services/client/src/components/OrganizationSettings.vue:134-190What: The list's columns are Nom / Connexion / Identifiant / Véhicule, and the detail page shows auth provider and archive state — role appears nowhere. The only role control in the product is the "Administrateurs" card in organization settings, a separate screen that lists admins by their member record rather than by member profile, and which can only toggle admin. The responsible role, which the router honours for /admin access (auth.store.ts:19-20) and which CASL implements (casl-ability.factory.ts:84), cannot be granted anywhere in the UI. Why it matters: An admin looking at a user's page cannot answer "does this person have admin rights?" — the question the page most obviously exists to answer — and cannot revoke them from there. Because the list does not mark admins either, archiving or password-resetting an owner/admin looks identical to doing it to a member. Fix: Add a Role column to the list and a role field on the detail page, sourced from the member record; make grant/revoke happen there (reusing POST/DELETE /organizations/:id/admins) with a confirm that names the capabilities gained or lost, per MSG-05. Expose responsible or remove it.
7. The archive confirmation does not say what archiving does — Medium · MSG-05
Where: UserDetails.vue:514 → services/client/src/composables/useArchiveConfirm.ts:22-31What: The dialog is header « Confirmer l'archivage », body « Archiver cet utilisateur ? », buttons « Archiver » (danger-styled) / « Annuler ». It states nothing about consequences: whether the user's recorded hours and invoices survive (they do), whether the user can still sign in (they can — see #1), whether it is reversible (it is, via the archive view), or whether anyone is notified (no one is). Why it matters: The one place the product could set the admin's expectation about what archiving means is blank, which is precisely why finding #1 is dangerous rather than merely surprising. A danger-red button with a bare question reads as "delete". Fix: Body copy along the lines of « <Nom> n'apparaîtra plus dans la liste des utilisateurs et ne pourra plus se connecter. Ses heures et ses factures sont conservées. Vous pourrez le réactiver depuis les archives. » — and make that statement true (#1).
8. Admin password reset never says the user will be signed out everywhere — Medium · MSG-05 (also CONTENT-03)
Where: UserDetails.vue:199-268; server behaviour at services/api/src/modules/user/user.service.ts:230-262What: The panel says only « Définissez un nouveau mot de passe pour cet utilisateur. » Confirming it replaces the credential and deletes every session the target user has (user.service.ts:249-252). Neither the panel, the button, nor the success toast (« Le mot de passe a été réinitialisé ») mentions that the user will be logged out on every device, that their old password stops working immediately, or that the admin is now responsible for telling them the new one — the app cannot, since local accounts are created with a placeholder local-…@local.invalid address (user.service.ts:82). Why it matters: An admin doing this to help someone who forgot their password will knock that person out of an in-progress session mid-shift, and neither party gets told why. There is no undo. Fix: State the consequences above the Confirmer button and repeat the "communicate the new password to the user" instruction in the success toast. SEC-04 (out-of-band notification) is not filed separately here: local accounts have no real email address by construction, so there is no channel to notify on. If email-provider users ever become resettable this way, SEC-04 applies immediately.
9. The name field can be emptied on edit although it is required on create — Medium · FORM-05
Where: UserDetails.vue:168-172 vs ModalAddUser.vue:145-149; server side services/api/src/modules/user/dto/update-user.dto.ts:5-9What: Creating a user runs a Zod schema requiring « Nom requis ». Editing has no validation at all: InputText v-model="user.name" with nothing checking it, and UpdateUserDto.name is @IsOptional() @IsString() with no MinLength, so an empty string persists. The detail page then renders the fallback « Sans nom » (UserDetails.vue:23) and the list shows an avatar with « ? » (UserAvatar.vue:24). Why it matters: A stray select-all-delete saves a nameless user who is then hard to find in the list, in search (which matches on name), and in every entry and invoice attributed to them. No message explains that anything went wrong, because nothing thinks it did. Fix: Apply the same required-name rule on the edit path, block the save with an inline message rather than accepting it, and add @MinLength(1) to UpdateUserDto.name.
10. English backend messages reach French users — Medium · MSG-02
Where: services/client/src/utils/index.ts:52-80 (extractErrorMessage), used at UserDetails.vue:449, 478, 495, 508, 522 and ModalAddUser.vue:195What: extractErrorMessage prefers a curated French string when the error carries a known code, then falls back to the backend's message. The Users endpoints throw untranslated English: "Username 'x' is already taken in this organization" (user.service.ts:76), "Cannot send invite to a local user" (user.service.ts:203), "Password can only be reset for local users" (user.service.ts:238), plus class-validator constraint text joined verbatim (utils/index.ts:73-77). Why it matters: In a French-only product, the most common recoverable error in this feature — duplicate username on create — is shown in English, inside a toast titled « Erreur ». It also fails MSG-03: none of these strings says what to do next. Fix: Give these endpoints stable error codes and add French entries to ERROR_CODE_MESSAGES; use the raw backend string only when no code matched, and prefer the French fallback over an English message. Helper-level — likely a project-level finding once another feature confirms it.
11. The user-search field has no label — Low · FORM-01
Where: services/client/src/components/Forms/Fields/FieldSearch.vue:1-10, used at Users.vue:11-15What: InputText with placeholder="Rechercher un utilisateur..." and no <label>, aria-label or aria-labelledby; the adjacent InputIcon is a decorative <i>. The placeholder is the only name, and it disappears on typing. Why it matters: The field is announced as an unlabelled edit box, and once text is entered there is no way for a screen-reader user to recall what it filters. Fix: Add an aria-label prop on FieldSearch (defaulting to the placeholder) or a visually-hidden <label for>.
12. The Confirmer button for a password reset is disabled until valid — Low · FORM-05
Where: UserDetails.vue:259-266What: :disabled="!newPassword || newPassword.length < 8". The length rule is at least stated up-front in the field footer (:240-245, satisfying FORM-04), and PrimeVue's Password supplies the reveal toggle and strength meter (FORM-03, SEC-05 — pass). But the button gives the user nothing to act on while blocked, and the guard behind it (handleResetPassword at :463-465, which sets resetPasswordError) is consequently unreachable dead code. That error <small> (:247-250) also lacks role="alert" (MSG-01). Fix: Keep the button enabled, run the length check in the handler, and give the error element role="alert".
13. The archive/active view is not reflected in the URL — Low · pending NAV-06(e)
Where: Users.vue:121-131, 140What: « Voir les archives » flips a local view ref. The URL never changes, so the archive view cannot be linked, bookmarked, or returned to after visiting a user's detail page and pressing « Retour » — which always lands back on the active list. Fix: Back view with a query parameter (?vue=archives).
Covered elsewhere — not re-filed
- Table rows are click-only
<tr @click>(components/Table.vue:92-97): notabindex, norole, not a link, so a keyboard user cannot open a user's record from the list and no row can be opened in a new tab. This is the tt-time-tracker instance of the cross-projectA11Y-03row already recorded in PROJECT-LEVEL.md. The sortable<th>s, by contrast, are correctly keyboard- operable (Table.vue:29-33). - NAV-03 — fails project-wide, see PROJECT-LEVEL.md (
index.htmltitle « Tim » on every route, including/admin/users/<id>). - CONTENT-01 — not-applicable, see project-level i18n finding.
- MSG-06 (failed load shown as empty state) — this feature is on the good side of the split PROJECT-LEVEL.md records:
TablerendersListErrorStatewith a working « Réessayer » (Users.vue:44-50,Table.vue:60-76). The detail page's four queries have no error branch at all, though — a failed entries or invoices fetch renders « Aucune entrée pour cet utilisateur » (UserDetails.vue:299-303,:333-337), which is the same defect one level down. Folded here rather than filed, since it is the recorded project theme.
Unverified
- A11Y-01 (contrast). Not determinable from code. Several elements are candidates for a rendered check:
text-primary-700/70andtext-primary-500/70onbg-primary-50in the stat cards (UserDetails.vue:94, 101, 104),text-surface-400for empty-state descriptions and pagination text (ListEmptyState.vue:16,Table.vue:138), and the 10px uppercase labels (UserDetails.vue:94, 111, 128, 144). - A11Y-06 (short viewport / mobile keyboard). Needs a rendered page. The mobile header is
sticky top-0with a second search row (LayoutMain.vue:5-42), which is the usual place this fails. - A11Y-02 (target size). The pagination arrows are
w-8 h-8= 32px (pass), and the back button issize-9(pass); PrimeVuesize="small"text buttons could not be measured withoutnode_modulesinstalled. - PrimeVue internals (standing caveat): whether
Passwordforwardsautocomplete, and whatMenu/ConfirmDialogdo for focus management androle, could not be read from source. Related to that: the reset field atUserDetails.vue:227-246sets noautocomplete="new-password", so a password manager has no signal that this is a third party's new credential and may offer to save it against the admin's own account (FORM-02). Stated as a likely defect rather than asserted, because PrimeVue's own default is unread. - UserAvatar renders visible initials as text (
UserAvatar.vue:9) with noaria-hidden, so a screen reader announces "JD" immediately before the name it abbreviates. Low-value, and whether it is actually announced depends on the rendered accessibility tree.
Baseline additions
Descriptive IDs, per the brief — the orchestrator should renumber.
SEC-ACCESS-REVOCATION— Removing or suspending a user must actually end their access: the authorization path checks the suspended state and live sessions are revoked server-side. A control that hides a user from an admin list without revoking access is a defect, not a naming choice. (Sibling toSEC-03, which covers the password-change case.)FORM-DISCARD-GUARD— In-progress edits are never discarded without the user's knowledge: background refetches must not overwrite a dirty form, and navigating away from one prompts first. (Overlaps the pendingFORM-12(d)proposal; the refetch half is the new part.)ROLE-VISIBILITY— Wherever an admin can act on a user, that user's role or permission level is visible, and any role the authorization layer honours is grantable somewhere in the UI.
Cross-project note
SEC-ACCESS-REVOCATION— worth checking in all three others immediately. members, customer-portal and playout each have some form of archive/disable on member records; the question in each is whether the auth path reads that flag and whether sessions are revoked. This is the kind of defect that is invisible until someone asks.FORM-DISCARD-GUARD— the refetch-clobbers-edits shape needs TanStack Query withrefetchOnWindowFocusplus a copy-based edit form. customer-portal and playout both use detail forms over cached data; likely present.A11Y-05(label hidden by CSS) — specific to this repo'sLayoutMain, but "icon-only on mobile withoutaria-label" is generic; worth one grep in each of the other three for.p-button-label { display: none }or equivalents.MSG-04(no forbidden surface) — the inverse of the 404 gap recorded in PROJECT-LEVEL.md: playout, customer-portal and members all have a forbidden/unauthorized view and tt-time-tracker does not. Three-of-four makes the canonical behaviour clear; this is the one to align.MSG-02— already project-level in customer-portal and members; theextractErrorMessagefallback ordering here is the same shape, so tt is a third confirmation of the theme.