Skip to content

[UX] tt-time-tracker — Organization chooser ​

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: forms/selection-input

Summary ​

The chooser view itself is small and mostly sound — real <button> rows, a correct <h1>, a visible focus ring — but the organization selection mechanism around it has two serious gaps. A remembered organization id is read straight out of localStorage and never re-validated against the user's current memberships, and the one piece of code written to validate it is unreachable, so a user whose membership is revoked keeps seeing that company's name, logo and theme from cache while every request 403s. Separately, a user who belongs to zero organizations lands on a one-sentence empty state with no sign-out and no way forward — the same dead end the playout tenant-chooser audit found.

Checked and clear: this screen does not contribute to the account/organization enumeration oracle. GET /organizations is membership-scoped server-side (services/api/src/modules/organization/organization.service.ts:126-143 filters on members: { some: { userId } }). The oracle flagged by the sign-in audit is GET /organizations/:id/public, which is @AllowAnonymous() (organization.controller.ts:52-58) and is consumed by components/Login/LoginChooseOrganization.vue:53 — a Sign-in (#4) concern, not this one.

Findings ​

1. A remembered organization is never re-validated against current memberships — High · SEC-04 (proposed TENANT-SCOPE-VALIDATED, see additions) ​

Where: services/client/src/stores/organization.store.ts:11,20,42-54 and services/client/src/App.vue:96,104-108What: The store seeds its selection at construction time — const idFromChooser = ref<string | null>(localStorage.getItem("lastOrganizationId")) (line 11) — so organization.id is already truthy the first time ensureOrganizationSelected runs. That function's first guard is if (organization.id) return; (App.vue:96), which means its own remembered-id validation two lines later — if (rememberedId && list.some((t: any) => String(t.id) === rememberedId)) (App.vue:104-108) — is unreachable in exactly the case it was written for. Nothing else in the client compares organization.id against authStore.organizations. When membership has been revoked, GET /organizations/:id returns 403 (OrganizationGuard + @CheckAbility("read", "Organization"), organization.controller.ts:41-50), and fetchOrganization swallows it with a bare catch { } that deliberately "leaves cached data in place" (organization.store.ts:49-53). The cached copy was already loaded from tt-organization-<id> a few lines earlier (line 58-61). Why it matters: An ex-employee (or a user moved from Org A to Org B) opens the app and sees Org A's name in the header, Org A's logo and Org A's theme applied, because all of that comes from the local cache — while every scoped request behind it fails. They are never routed to the chooser, so the correct organization is not offered; they have to notice the dropdown and switch manually. On the worker-facing screens, which have no error surface at all (see PROJECT-LEVEL.md, MSG-06), the failures render as empty days rather than as errors, so the most likely reading is "my hours have been deleted". Related residue: sign-out clears only lastOrganizationId (organization.store.ts:135-140) and leaves every tt-organization-<id> blob — name, email, counts, feature flags — in localStorage indefinitely, including on shared devices. Severity call: High rather than Blocker. A recovery path does exist (LayoutOrganizationDropdown), and no new cross-tenant data is fetched — what is displayed is a cached copy of an organization the user did legitimately have access to. It is not Medium because the user is shown a company they have been removed from, with no signal that anything is wrong. Fix: Once authStore.organizations resolves, drop idFromChooser when it is not in the list (exempting superadmins, who legitimately hold ids outside their memberships — see App.vue:112-115) and route to choose-organization. Delete tt-organization-* entries for organizations no longer in the list, and clear all of them on sign-out. Stop swallowing 403/404 in fetchOrganization — offline is worth a silent retry, "you are not a member of this organization" is not.

2. A user with no organizations hits a dead end with no sign-out — High · MSG-04 ​

Where: services/client/src/views/OrganizationChooser.vue:22-27; services/client/src/App.vue:94-126,144-152; services/client/src/router/routes.ts:41What: With an empty membership list the screen renders one sentence — « Aucun accès à une entreprise. » — and nothing else. The route sits outside the LayoutApp shell (routes.ts:41), so none of the app chrome that carries sign-out (LayoutProfileDropdown, ProfileCenter) is rendered. Navigating to /login manually does not help: App.vue:151 does if (user && name === "login") goToReturnUrl();, which bounces back, and ensureOrganizationSelected (App.vue:122-123) pushes straight back to the chooser. Why it matters: The user is authenticated, stuck, and cannot sign out to try a different account or hand the device to a colleague. They are also given no idea what to do — no "ask your administrator to invite you", no indication of which account is signed in, so someone who logged in with the wrong address cannot even see that that is what happened. This is the same shape as the playout tenant-chooser dead end. Severity call: High rather than Blocker because the state is reachable only for a user with no memberships, not for a normal user in the normal path — but for that user the app is fully unusable with no escape hatch. Fix: Add a « Se déconnecter » action to this view (it should be present in both the empty and the populated state), show the signed-in email address, and replace the bare sentence with next-step copy naming who can grant access.

3. A failed organization-list fetch is rendered as "you have no organizations" — Medium · MSG-06 (project-level family) ​

Where: services/client/src/stores/auth.store.ts:46-55; views/OrganizationChooser.vue:22-27What: The catch block sets organizations.value = [] and shows a toast. [] is indistinguishable from a genuine zero-membership result, so a network blip or a 500 renders the permanent empty state of finding #2 — a false statement about the user's access — with no retry control. The toast text comes from extractErrorMessage (services/client/src/utils/index.ts:52-81), which returns a curated French string only when the error carries a known code and otherwise falls through to the raw message, so a fetch failure surfaces to a French-speaking user as English SDK prose (MSG-02). The toast is transient; the false empty state is not. Why it matters: A user sees "no access to any company" for a transient failure, concludes their account was disabled, and contacts support. Severity call: Medium, not High — a page reload re-runs the fetch, so it is recoverable, unlike #2. Fix: Give the store a tri-state (null loading / [] empty / error), render a distinct error block with a « Réessayer » button, and keep the raw SDK string out of the toast. The extractErrorMessage fallthrough is helper-level and affects every screen — worth promoting to a project-level finding rather than fixing here.

4. The in-app organization switcher is a click-only <div> — Medium · A11Y-03 ​

Where: services/client/src/components/Layout/LayoutOrganizationDropdown.vue:3-10What: The trigger is <div class="flex items-center gap-2 cursor-pointer ..." @click="toggleMenu"> with no tabindex, no role="button", no aria-haspopup/aria-expanded, no keyboard handler and no accessible name beyond the organization name text. The same code shape appears in views/Profile.vue:151. Why it matters: This is the only way to change organization once one is selected — the chooser route is never linked from anywhere in the app (the sole router.push({ name: "choose-organization" }) is the automatic one at App.vue:123). A keyboard-only or screen-reader user therefore cannot switch organizations at all, which given finding #1 is also the only manual recovery from a stale selection. Severity call: Medium not High only because a keyboard user can reach the same outcome by signing out and back in; it is a genuine A11Y-03 barrier on the primary control. Note this file is shared app chrome and may overlap another queue row. Fix: Use a <button type="button" aria-haspopup="menu" :aria-expanded="open">, or PrimeVue's own menu trigger binding. Same fix in Profile.vue.

5. One click triggers two competing navigations — Medium · NAV-05 ​

Where: services/client/src/views/OrganizationChooser.vue:68-77 and 79-87What: selectOrganization sets organization.idFromChooser, then reads and removes returnUrl from sessionStorage and assigns location.href (line 86). Changing idFromChooser also fires watch(() => organization.id, ...) at line 68, which is still on the choose-organization route so it passes its own guard, reads sessionStorage.getItem("returnUrl") — now null, because the click handler just removed it — and therefore takes the router.push({ name: "home" }) branch (line 75). The two blocks are otherwise identical copies of the same logic. Why it matters: Every selection click fires both a full-page navigation to the deep link and an SPA push to home. At minimum this leaves an extra history entry, so Back after choosing an organization can return the user to the chooser; whether they land on their deep link or on home depends on whether the router push resolves before the browser unloads the document, which cannot be determined from the source — see Unverified. The full-page location.href also throws away the warm SPA/query cache for a navigation that router.replace would handle. Fix: Keep one owner. selectOrganization should only set idFromChooser; let the watch do all the navigation, so the click path and the single-organization auto-select path (line 63-66) share one code path. Prefer router.replace over location.href unless the reload is deliberate.

6. The chooser never shows which organization is currently active, and rows carry no disambiguator — Medium · A11Y-03 + CONTENT-02 (proposed SELECTION-CURRENT-INDICATED) ​

Where: services/client/src/views/OrganizationChooser.vue:28-45, esp. line 41 What: Every row renders identically. There is no aria-current, no aria-selected, and no visual marker for the organization already in organization.id — even though the other selection surface for the same decision, LayoutOrganizationDropdown.vue:32, does mark it (icon: t.id === organization.data?.id ? "pi pi-check" : undefined). The label is {{ t.name ?? t.id }} inside a truncate span with no title and no second line, and the row draws a generic pi pi-building glyph even though the API returns each organization's logo (organization.service.ts:130-142). Why it matters: This screen is the point where a multi-organization user commits to whose data they will see. Two organizations with a shared name prefix — common for group companies, which is exactly who has multiple memberships — are indistinguishable once the name truncates, and a user who came here to switch cannot see what they are switching away from. Picking wrong means working in another company's records. Fix: Mark the active row with aria-current="true" plus a non-colour indicator; add :title on the name; render t.logo when present and a secondary line (slug or role) when it is not.

7. Loading and its resolution are silent to assistive technology — Medium · MSG-01 / CONTENT-04 ​

Where: services/client/src/views/OrganizationChooser.vue:14-19; services/client/src/components/Spinner.vue:1-16What: Spinner.vue renders two presentational <div>s with no role="status", no aria-label and no text. The chooser shows it with no accompanying copy, then swaps it for the list (or the empty state) inside a plain v-else with no live region. Why it matters: A screen-reader user arriving on this route hears the heading and then nothing — no indication that anything is loading, no announcement when the organization list appears, and no announcement of the « Aucun accès » outcome. They have to re-explore the page manually to discover the state changed (WCAG 4.1.3). forms/selection-input makes the same point: state changes in a selection control must be announced. Fix: Wrap the loading branch in role="status" aria-live="polite" with screen-reader text naming what is being waited on (« Chargement de vos entreprises… »), and put the result branch in a polite live region so the count or the empty message is announced. A role="status" prop on Spinner.vue would fix this everywhere at once.

8. No landmark wrapping the page content — Low · A11Y-04 ​

Where: services/client/src/views/OrganizationChooser.vue:2-3; services/client/src/router/routes.ts:41What: The route is declared outside the LayoutApp shell, so components/Layout/LayoutMain.vue:2's <main> does not wrap it, and the view's own root is a <div>. views/OnboardingWizard.vue:26 — the other top-level non-shell route — does provide its own <main>, so this is an inconsistency inside the project as well as a rule failure. The <h1> itself is correct: exactly one, at line 10-12, and no skipped levels. Fix: Change the inner wrapper to <main>.

Unverified ​

  • A11Y-01 (contrast) — cannot be settled from source. Candidates worth measuring: text-gray-600 on white for the empty state (line 24), text-surface-400 on the chevron (line 42), and the spinner's --p-primary-100 track (Spinner.vue:70), all of which are low-contrast by class name.
  • A11Y-06 (short viewport / responsive) — needs a rendered page. Note pt-24 plus a fixed <div class="h-32" /> spacer (line 48) below the card on a route with no scroll container of its own; worth checking at ~700px height with a long organization list.
  • Finding #5 outcome ordering — that both handlers run is determinable from the source; which navigation wins the race between location.href and a Vue watcher flush is not. Needs a browser to confirm whether the deep link or home is the final destination.
  • PrimeVue Menu internals (finding #4) — node_modules is not installed, so I could not confirm what ARIA the popup itself emits. The claim above is about the trigger <div>, which is in repo source and is unambiguous.

Baseline additions ​

The orchestrator will renumber these; IDs are descriptive on purpose.

  • TENANT-SCOPE-VALIDATED — a persisted tenant/organization selection is re-validated against the user's current memberships at every session start, and a cached copy of a tenant the user no longer belongs to is discarded rather than rendered. (Findings #1.)
  • CACHE-CLEARED-ON-SIGNOUT — sign-out clears every tenant-scoped cache entry the app wrote, not only the pointer to the active tenant. (Finding #1, residue.)
  • SELECTION-CURRENT-INDICATED — a chooser that can be revisited indicates which option is currently active, programmatically and not by colour alone. (Finding #6.) This overlaps the existing A11Y-07(d)/(e) collision noted in PROJECT-LEVEL.md and should probably be folded into whichever of those survives.
  • SINGLE-NAV-OWNER — one user action results in exactly one navigation; duplicated handlers must not both navigate. (Finding #5.) Related to, but narrower than, the NAV-06 collision cluster.

Not applicable / already covered ​

  • CONTENT-01 — not-applicable, see project-level i18n finding.
  • NAV-03 — fails project-wide, see PROJECT-LEVEL.md.
  • SEC-01 — passes on this screen; the enumeration oracle lives in LoginChooseOrganization.vue / GET /organizations/:id/public and belongs to queue row #4.
  • FORM-01…FORM-11 — no text inputs on this view.
  • A11Y-02 — rows are full-width with px-5 py-4; passes.
  • A11Y-03 on the chooser rows themselves — passes. Real <button type="button"> elements inside <li>, with focus-visible:ring-2. The A11Y-03 failure in finding #4 is on the layout dropdown, not here.

Cross-project note ​

  • Finding #2 (zero-tenant dead end) is confirmed in playout's tenant chooser by the earlier audit in this batch — two of four projects, so it is an alignment candidate. Worth checking customer-portal, which is multi-company as well.
  • Finding #1 (stale tenant not re-validated, cached tenant rendered anyway) is the more interesting cross-project question: any app that remembers a tenant in localStorage and caches its payload under a tenant-keyed name has the same exposure. Check playout's tenant store and customer-portal's company selection for the same "seed from storage, never re-validate" shape.
  • Finding #4 is the same click-only-div trigger already recorded project-level for playout (MenuDropdown) and listed for all four in the alignment table.
  • Finding #3 is another instance of the cross-project MSG-06 row: a failed fetch rendered as an empty state.