toga-ai 1.0.940 → 1.0.941

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-28
9
+ updated: 2026-10-05
10
10
  owners: [bala, tcox]
11
11
  files:
12
12
  - _underscore/Model/Client/Users/DelegatorUser.php
@@ -112,8 +112,25 @@ paging. Two hard rules in
112
112
 
113
113
  ## Gotchas
114
114
 
115
- - **The table is empty in every environment.** "Delegation refused / dropdown empty" is the
116
- expected state until someone inserts rows; there is no UI or import to do it.
115
+ - **Rows are per signed-in user.** "No options found" in the Commerce pill means the signed-in
116
+ user has no rows **as `userId`** — data, not code (the own-record `GET /v2/users/{uuid}` returns
117
+ 200 with an empty list). Seen on client-sandbox 2026-09-30: user 4273 had none; all 8 seeded
118
+ rows belonged to Dev Team (127046). Diagnose with per-user counts of rows as `userId` vs as
119
+ `delegatorUserId`. Re-runnable copy of one user's list to another (no UI or import exists):
120
+
121
+ ```sql
122
+ INSERT INTO Users_DelegatorUsers (uuid, userId, delegatorUserId)
123
+ SELECT UUID(), 4273, d.delegatorUserId
124
+ FROM Users_DelegatorUsers d
125
+ WHERE d.userId = 127046
126
+ AND d.delegatorUserId <> 4273
127
+ AND NOT EXISTS (
128
+ SELECT 1 FROM Users_DelegatorUsers x
129
+ WHERE x.userId = 4273 AND x.delegatorUserId = d.delegatorUserId
130
+ );
131
+ ```
132
+
133
+ The TOGa DB MCP tool is read-only (SELECT/SHOW/DESCRIBE); a developer runs the INSERT.
117
134
  - **Never let the `isDelegatorAuthRequest` flag default to true** — the encrypted pair is
118
135
  unauthenticated, replayable input.
119
136
  - **Outbound logs never carry `userId` or `originalUserId`.** `_underscore/ApiRequest.php` and
@@ -151,8 +168,9 @@ paging. Two hard rules in
151
168
  - **The gate now blocks admin View As too.** `canUserOrderForOtherUser` guards
152
169
  `/users/encryptedUser` and `/auth/delegator` for everyone, so View As returns **403 EZ-1** for
153
170
  any target missing from the caller's `Users_DelegatorUsers` rows (api.beta, user `50160ccc`).
154
- On prod that means View As works only for listed people. Decide whether View As needs its own
155
- permission path.
171
+ On prod that means View As works only for listed people. Seen on beta and client-sandbox.
172
+ No front-end toast by decision (tcox, 2026-09-30) — prod carries the full list. Whether View
173
+ As needs its own permission path is still undecided.
156
174
  2. **`c_orderSubmittedById` must be stamped server side.** The column exists on `SalesOrders` in
157
175
  `Client_Compass`, `Client_CompassCanada`, `Client_Quad`, with `CustomRecordFields` id 40 on record
158
176
  14 and `_Model_Compass_SalesOrder` L51 as `FIELD_INTEGER`. It is a plain integer, not an FK, so
@@ -167,6 +185,9 @@ paging. Two hard rules in
167
185
 
168
186
  ## Change history
169
187
 
188
+ - 2026-09-30 — "No options found" traced to a user with no rows as `userId`; added the diagnosis
189
+ and the re-runnable copy-list seed; recorded the no-toast decision for the View As 403. (tcox)
190
+
170
191
  - 2026-09-28 — Recorded the `/auth/delegator` public-token 500 at `V2.php` L903 (no `user` guard,
171
192
  `Logs.Issue` T2) and the decision to leave it unfixed. (bala)
172
193
  - 2026-09-22 — Front end now switches the token, so the gate is live: recorded that
@@ -14,7 +14,7 @@
14
14
  | [Config-Driven Expedited Shipping Gating (Cart)](features/expedited-shipping-gating.md) | Expedited shipping options are config-gated to show only when the cart holds a computer kit; open when touching cart shipping-method options or the gate config. |
15
15
  | [Filter / Search-Results Page & the Two Search Entry Points](features/filter-search-results-page.md) | Two search entry points (Home in-place vs Header→/filter) render the same card via different code paths and FIELDS files; open for any search-results or kit-car |
16
16
  | [Force Logout on Deployment (DeploymentGuard) — commerce](features/force-logout-on-deployment.md) | Signs every user out when a deploy bumps a Core parameter, after showing a notice; open for surprise logouts, "why wasn't I logged out?", a repeating "new versi |
17
- | ["Order For" — header pill, delegated token switch, and the cart hand-off](features/header-order-for-dropdown.md) | Header pill that switches the session to a delegated token so a buyer orders **as** someone else; open before touching the pill, `useOrderForZu`, the delegation |
17
+ | ["Order For" — header pill (a mode), delegated token switch, and the cart lock](features/header-order-for-dropdown.md) | Header pill that puts the session into "Order For" mode (a delegated token) so a buyer orders **as** someone else; open before touching the pill, `useOrderForZu |
18
18
  | [Inactive-item purchase gating (standalone lines only — kits are exempt by design)](features/inactive-item-purchase-gating.md) | `Items.isActive = 0` blocks standalone purchase but means something different on kit components, so the gate keys on purchase CONTEXT not the flag; open when to |
19
19
  | [Multi-Tenant Resolution & Theming](features/multi-tenant-theming.md) | How the hostname resolves the tenant and fans out to API base URL, theme (CSS variables), and fields; open when adding a tenant or debugging wrong-theme/wrong-A |
20
20
  | [Order-submit sync sequencing (useSubmitOrder) — why these calls must not run in parallel](features/order-submit-sync-sequencing.md) | The item sync must finish before the header sync on order submit, or concurrent line deletes race a depth-3 response into an uncaught api2 500; open when touchi |
@@ -1,12 +1,12 @@
1
1
  ---
2
- title: "Order For" — header pill, delegated token switch, and the cart hand-off
2
+ title: "Order For" — header pill (a mode), delegated token switch, and the cart lock
3
3
  framework: "2.0"
4
4
  repo: toga2-commerce
5
5
  project: TOGa Commerce
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-22
9
+ updated: 2026-10-05
10
10
  owners: [tcox, bala]
11
11
  files:
12
12
  - src/hooks/useOrderForDelegation.ts
@@ -15,10 +15,15 @@ files:
15
15
  - src/stores/useOrderForZu.ts
16
16
  - src/components/OrderForDropdown/OrderForDropdown.tsx
17
17
  - src/components/OrderForDropdown/useOrderForDropdownViewModel.ts
18
+ - src/components/OrderForDropdown/types.ts
18
19
  - src/pages/Cart/api/CartApi.ts
19
20
  - src/pages/Cart/viewModel/useCartViewModel.ts
20
21
  - src/pages/Cart/CartPage.tsx
22
+ - src/contexts/AuthContext.tsx
23
+ - src/lib/authStorage.ts
21
24
  - src/pages/Home/viewModel/FIELDS/COMPASS/ENGLISH/ADMIN/HEADERFIELDS.json
25
+ - src/pages/Home/viewModel/FIELDS/COMPASS/ENGLISH/MANAGER/HEADERFIELDS.json
26
+ - src/pages/Home/viewModel/FIELDS/COMPASS/ENGLISH/SUPERUSER/HEADERFIELDS.json
22
27
  - cypress/e2e/homePage/orderForDropdown.cy.ts
23
28
  related:
24
29
  - 2.0/apps/toga2-commerce/architecture.md
@@ -30,13 +35,15 @@ related:
30
35
  - 2.0/apps/toga2-commerce/workflows/cypress-testing.md
31
36
  ---
32
37
 
33
- Header pill that switches the session to a delegated token so a buyer orders **as** someone else; open before touching the pill, `useOrderForZu`, the delegation hooks, or the cart `orderFor` field.
38
+ Header pill that puts the session into "Order For" mode (a delegated token) so a buyer orders **as** someone else; open before touching the pill, `useOrderForZu`, the delegation hooks, or the cart `orderFor` field.
34
39
 
35
40
  ## Summary
36
41
 
37
- TRUE-81851 (pill) + TRUE-81852 (persistence) + the 2026-09-22 token switch. Picking a person in the header **"Order for <Name>"** pill now mints that person's token pair and reloads the app as them — the same mechanism as [View As](view-as-session-tokens.md), with a different meaning (you may order; you are not read-only).
42
+ TRUE-81851 (pill) + TRUE-81852 (persistence) + the 2026-09-22 token switch + the 2026-10-05 mode redesign. Picking a person in the header pill mints that person's token pair and reloads the app as them — the same mechanism as [View As](view-as-session-tokens.md), with a different meaning (you may order; you are not read-only).
38
43
 
39
- **Rejected: browser-side "catalog scoping".** Storing the pick and scoping the catalog in the browser was dropped. Only a real token switch makes api2's `Users_DelegatorUsers` gate, the target's catalog and prices, and `originalUserId` logging apply. Server side: [order-for delegation](../../_underscore/features/order-for-delegation.md).
44
+ **The pill is a MODE, not a mirror of the cart** (decided 2026-10-05, Matt's review). It reads only the delegation state (`useOrderForDelegation.isDelegationActive` + the user store). The cart's "User" field, its remembered pick (`useOrderForZu.orderForUser`) and its selected user never feed the pill, and the pill never writes them — except the cart lock in §4.
45
+
46
+ **Rejected: browser-side "catalog scoping".** Only a real token switch makes api2's `Users_DelegatorUsers` gate, the target's catalog and prices, and `originalUserId` logging apply. Server side: [order-for delegation](../../_underscore/features/order-for-delegation.md).
40
47
 
41
48
  **Per-role, per-tenant gated.** Copy and the on/off switch live in `HEADERFIELDS.json` under an `orderFor` key, Compass USA only: `isEnabled` true for ADMIN / MANAGER / SUPERUSER, false for USER. Compass Canada and Quad have no key, so the component renders nothing.
42
49
 
@@ -46,15 +53,15 @@ TRUE-81851 (pill) + TRUE-81852 (persistence) + the 2026-09-22 token switch. Pick
46
53
 
47
54
  On picking a person:
48
55
 
49
- 1. `GET /users/encryptedUser?uuid=<target>` then `POST /auth/delegator` for the target's pair.
50
- 2. `beginDelegation(pair, ownerUuid)` parks the owner's pair (`src/api/sessionTokens.ts`).
56
+ 1. `GET /users/encryptedUser?uuid=<target>`, then `CartApi.fetchUserTokens` (`POST /auth/delegator`) returns the target's pair. It throws on a non-string/empty token and calls `clearPublicToken()`; the caller installs the pair.
57
+ 2. `beginDelegation(pair, ownerUuid)` parks the owner's pair (`src/api/sessionTokens.ts`); `setActiveTokens()` bumps the identity epoch.
51
58
  3. `setDelegation({targetUuid, ownerUuid})` writes the Order For marker.
52
59
  4. Load the target: `fetchUser`, `fetchUserPageSettings`, `fetchUserPersonaData` (query key `userPersonaDataOnOrderFor`), then `setUser`.
53
- 5. Clear the react-query cache, `localStorage["commerce"]` and the cart; `navigate("/")`; `window.location.reload()`.
60
+ 5. Clear the cart, **await** `clearPersistedQueryCache(queryClient)` (`src/lib/queryCacheRestoration.ts` — never `queryClient.clear()` + `removeItem("commerce")`), `navigate("/")`, `window.location.reload()`.
54
61
 
55
- **"Myself"** restores the parked pair through the shared `useReturnToOriginalUser` hook (moved out of `AuthLayout` so both features use one copy).
62
+ **Leaving the mode** = the pill's "x" (`exitMode` → `returnToMyself`), which restores the parked pair through the shared `useReturnToOriginalUser` hook.
56
63
 
57
- The hook also **repairs a half-finished switch** on load (a reload landing between step 3 and step 4): restore the owner pair, or end the session. That repair reads the stores **live**, because the header mounts the hook twice (desktop + mobile) — a stale second copy would roll back again, find no parked pair, and log the user out.
64
+ The hook also **repairs a half-finished switch** on load (a reload between step 3 and step 4): restore the owner pair, or end the session. That repair reads the stores **live** — the header mounts the hook twice (desktop + mobile), and a stale second copy would roll back again, find no parked pair, and log the user out. `endSession` uses `void logout().then(() => navigate("/"))` because logout is async (it clears the persisted cache first).
58
65
 
59
66
  ### 2. The delegation list (`fetchDelegationList` in `src/pages/Cart/api/CartApi.ts`)
60
67
 
@@ -62,13 +69,13 @@ Reads `userDelegatorUsers.delegatorUser.{uuid,_name,email}` off `GET /users/{own
62
69
 
63
70
  - **Call it before the token switch** — on the target's token the owner's own record 403s.
64
71
  - **Never request `id`** — it 403s the whole call. Use `uuid`.
65
- - **No paging.** The "Load more" button and the `loadMoreLabel` copy key were removed; search filters the list in the browser.
66
- - The list is snapshotted in `useOrderForZu.delegationOptions` so it survives on the target's token.
67
- - `Users_DelegatorUsers` ships **empty**, so the pill shows only "Myself" until rows exist (8 rows seeded for Dev Team on client-sandbox, 2026-09-22).
72
+ - **No paging.** Search filters the list in the browser.
73
+ - Snapshotted in `useOrderForZu.delegationOptions` so it survives on the target's token.
74
+ - **"No options found" = no `Users_DelegatorUsers` rows for the signed-in user** (data, not code; the own-record GET returns 200 with an empty list). Diagnose and seed per [order-for delegation](../../_underscore/features/order-for-delegation.md).
68
75
 
69
76
  ### 3. Telling Order For apart from View As (`src/stores/useOrderForZu.ts`)
70
77
 
71
- Both park the original user in `useReturnOriginalUserFromViewAsStore` and share **one** parked-token slot, so they cannot nest. Order For is identified by its own marker `delegation {targetUuid, ownerUuid}` in `useOrderForZu` (localStorage `order-for-zu`), separate from the cart's pick `orderForUser` — so clearing the cart never flips the mode.
78
+ Both park the original user in `useReturnOriginalUserFromViewAsStore` and share **one** parked-token slot, so they cannot nest. Order For is identified by its own marker `delegation {targetUuid, ownerUuid}` in `useOrderForZu` (localStorage `order-for-zu`), separate from the cart's pick `orderForUser`.
72
79
 
73
80
  Helpers (store) and their hooks (`src/hooks/useIsViewAsActive.ts`):
74
81
 
@@ -78,54 +85,63 @@ Helpers (store) and their hooks (`src/hooks/useIsViewAsActive.ts`):
78
85
  | `isOrderForSwitchPending(delegation, currentUuid, originalUuid)` | marker written, user store still the owner — mid-switch |
79
86
  | `useIsViewAsActive` | parked user present **and** not Order For active **and** not switch pending |
80
87
 
81
- Consumers: `CartPage.tsx` (checkout gate `isViewAsOn`), `AuthLayout.tsx` (banner), `NavIconList.tsx` (the mount effect that clears the cart now runs for real View As only, so an Order For cart survives a refresh; starting View As also calls `endDelegation()`).
88
+ Consumers: `CartPage.tsx` (checkout gate `isViewAsOn`), `AuthLayout.tsx` (banner), `NavIconList.tsx` (cart-clear mount effect runs for real View As only; starting View As calls `endDelegation()`).
82
89
 
83
- ### 4. The cart while delegated
90
+ ### 4. The cart while delegated — the only header-to-cart link
84
91
 
85
- `src/pages/Cart/viewModel/useCartViewModel.ts` forces the `CARTPAGEFIELDS` `orderFor` input (`valueKey: "orderFor"`) to `isEditable: false` while delegated: the "User" field is read-only text holding the picked person — the same path basic users already take. `CartPage` re-seeds that read-only field with the current user after **Clear cart** (it otherwise went to `—` with no way to pick again; pre-existing for basic users). "Myself" leaves the input as the role config says.
92
+ `useCartViewModel.ts` forces the `CARTPAGEFIELDS` `orderFor` input (`valueKey: "orderFor"`) to `isEditable: false` while delegated: the "User" field is read-only text holding the picked person. `CartPage` re-seeds that field with the current user after **Clear cart**. **Clear cart clears only the cart's own pick** — it never leaves the mode.
86
93
 
87
- Scope decision: My Settings, My Shipping Addresses and Get Support stay read-only in both modes.
94
+ The cart owns the order-for cascade (contact, manager, cost center, notification emails) — see [cart-notification-emails](cart-notification-emails.md). The header must not duplicate it or write `useSelectedUserZu`.
88
95
 
89
- The cart still owns the order-for cascade (contact, manager, cost center, notification emails) — see [cart-notification-emails](cart-notification-emails.md). The header must not duplicate it or write `useSelectedUserZu` directly.
96
+ Scope decision: My Settings, My Shipping Addresses and Get Support stay read-only in both modes.
90
97
 
91
- ### 5. The pill
98
+ ### 5. The pill (`src/components/OrderForDropdown/`)
92
99
 
93
- MVVM split under `src/components/OrderForDropdown/`, rendered from `Header.tsx`, hidden in edit-order mode; mobile copy inside `SlideMenu`.
100
+ MVVM split, rendered from `Header.tsx`, hidden in edit-order mode; mobile copy inside `SlideMenu`. The view model sets `currentOrderForUser = delegated ? user : null` and `isOrderingForSomeoneElse = isDelegationActive` (shared `runSwitch` helper for pick and exit).
94
101
 
95
- - Label: **"Order for Myself"**, else **"Order for <Name>"**.
96
- - Teal `#00B9A8` outline + inset glow **only on hover and while open**. Ordering for someone else is shown by the bold name and `data-active="yes"`, not a permanent highlight — a permanent one reads as "hover stuck". No design exists for a distinct active state.
97
- - Menu: search, yourself first as **"<Name> (Myself)"**, then the list. Check mark on the current pick, `aria` listbox/option roles, Escape closes and returns focus, outside click closes.
98
- - Copy keys under `orderFor`: `isEnabled`, `label`, `searchPlaceholder`, `myselfLabel`, `loadingMessage`, `noOptionsFoundMessage`.
99
- - Test ids: `order-for-dropdown`, `order-for-trigger`, `order-for-current-name`, `order-for-menu`, `order-for-search`, `order-for-option` (mobile variants add `-mobile`).
102
+ - **Default:** icon + "Order for" on one line, no name. It **never** says "Myself".
103
+ - **In the mode:** "Order for" with the person's name under it, plus an **"x"** button at the right end (FontAwesome regular `xmark` via `getFontAwesomeIcon`) that leaves the mode.
104
+ - Structure: a wrapper `div` carries the outline/glow; the trigger button and the "x" button sit inside it as siblings — a button cannot nest a button.
105
+ - Teal `#00B9A8` outline + inset glow **only on hover and while open**. No permanent "in mode" style yet (needs design); the mode shows by the name, the "x" and `data-active="yes"`.
106
+ - Menu: search, then the list. **The signed-in person is never listed** (the old "Name (Myself)" row is gone). Check mark on the current pick, `aria` listbox/option roles, Escape closes and returns focus, outside click closes.
107
+ - Copy keys under `orderFor`: `isEnabled`, `label`, `searchPlaceholder`, `loadingMessage`, `noOptionsFoundMessage`, `clearLabel` (screen-reader name of the "x", default "Clear order for"). `myselfLabel` is still in the JSON but unused by the header.
108
+ - Test ids: `order-for-dropdown`, `order-for-trigger`, `order-for-current-name` (mode only), `order-for-clear` (mode only), `order-for-menu`, `order-for-search`, `order-for-option`; mobile variants add `-mobile`.
100
109
 
101
110
  ### 6. `useOrderForZu` — persisted, owner-scoped
102
111
 
103
- Zustand `persist`, localStorage `order-for-zu`. Holds `orderForUser {uuid, name, email, ownerUuid}`, `delegation` and `delegationOptions`. Read the pick through `getOrderForUserFor(pick, ownerUuid)` — it returns the pick only when `pick.ownerUuid` matches the signed-in user and the uuid is a real uuid. `clearOrderForUser()` drops the cart-facing pick only; `endDelegation()` drops everything (return to self, logout, View As start).
112
+ Zustand `persist`, localStorage `order-for-zu`. Holds the cart's pick `orderForUser {uuid, name, email, ownerUuid}`, `delegation` and `delegationOptions`. Read the cart pick through `getOrderForUserFor(pick, ownerUuid)` (owner match + real uuid). `clearOrderForUser()` drops the cart pick only; `endDelegation()` drops everything (return to self, View As start, and `AuthContext.resetClientState` on sign-in).
113
+
114
+ `originalAuthTokens` and `order-for-zu` are in `AUTH_STORAGE_KEYS` (`src/lib/authStorage.ts`), so every sign-in / sign-out / 401 logout path drops the parked pair and the marker.
104
115
 
105
116
  ## Gotchas
106
117
 
107
- - **Never test "View As is on" with `Object.keys(returnOriginalUser).length > 0`.** Order For parks the same user, so that test reports View As and blocks checkout. Use `useIsViewAsActive`.
118
+ - **The header reads only the delegation state.** Do not wire the cart's pick or selected user into the pill (either direction) — that was the old mirror design, rejected 2026-10-05.
119
+ - **Never test "View As is on" with `Object.keys(returnOriginalUser).length > 0`.** Order For parks the same user; use `useIsViewAsActive`.
108
120
  - **Order For and View As cannot nest** — one `originalAuthTokens` slot, and `beginDelegation` refuses to overwrite it.
109
- - **The marker is written before the target is loaded**, so there is a gap where the parked user exists but the user store is still the owner. That gap flashed the View As banner; `isOrderForSwitchPending` covers it. Any new "am I delegated?" check must handle it.
110
- - **Order of operations on return matters.** In `useReturnToOriginalUser`, `setUser(owner)` then `clearOriginalUser` then `endDelegation` must run right after `restoreOriginalTokens` and **before any await** (token refresh, refetch). Otherwise an expired parked token's refresh opens a window where the "parked pair gone while delegated" guard logs the user out, and the banner / cart-clear flicker.
111
- - **Read the stores live in the repair path.** The header mounts `useOrderForDelegation` twice (desktop + mobile); a captured snapshot makes the second copy roll back a switch that already finished and log the user out.
112
- - **The `ownerUuid` guard is what makes persistence safe — never drop it.** A pick with no owner check is inherited by the next login or by a delegated target.
121
+ - **The marker is written before the target is loaded**, so there is a gap where the parked user exists but the user store is still the owner. `isOrderForSwitchPending` covers it; any new "am I delegated?" check must handle it.
122
+ - **Order of operations on return matters.** In `useReturnToOriginalUser`, `setUser(owner)` → `clearOriginalUser` → `endDelegation` must run right after `restoreOriginalTokens` and **before any await**.
123
+ - **The `ownerUuid` guard is what makes persistence safe — never drop it.**
113
124
  - **A missing `orderFor` key renders nothing, by design** (`useAssignClientFields` has no fallback). Adding a tenant means adding the key to every role file for that tenant.
114
- - **Delegation failures are silent.** `onError` only logs to Sentry — no toast (developer's call). A refused pick looks like nothing happened.
125
+ - **Delegation failures are silent.** `onError` only logs to Sentry — no toast (developer's call).
126
+ - **"The fix is not there" after a merge is usually a branch that never had the commits.** Check `git branch --contains <sha>` / `git log A..B` before calling it a regression (the cart lock "stopped working" on `_sandbox-client` because the feature was never merged there).
127
+ - **Merge of `_feature-OrderForDropDown` into `_sandbox-client` (6d84f565):** sandbox wins on the session layer (TRUE-81991, see [view-as-session-tokens](view-as-session-tokens.md)), feature wins on Order For. The old store API (`pendingOrderForUser`, `getPendingOrderForUserFor`, `clearPendingOrderForUser`, `fetchUserAccessToken`) no longer exists — do not bring it back from a stale branch.
115
128
  - **`_production` and `_sandbox-client` have diverged in `EditCart.tsx`**, so a stashed patch will not apply cleanly. Move work with `git diff stash@{0}^1 stash@{0} > patch`, `git apply --check --3way patch`, then `git apply --3way --exclude=<conflicting file> patch` and hand-port the rest.
116
129
  - **Tailwind: `max-h-300px` is not a token** — use `max-h-[300px]`.
117
- - **`.env.development` points at `https://api.beta.togahub.com/v2` by default.** client-sandbox is `https://api.client.sandbox.togahub.com/v2` — check the host before concluding a call was never made.
130
+ - **`.env.development` points at `https://api.beta.togahub.com/v2` by default.** client-sandbox is `https://api.client.sandbox.togahub.com/v2` — check `authority` in the API response JSON before reading the wrong environment's logs.
118
131
 
119
132
  ## Open items
120
133
 
121
- - `SalesOrders.c_orderSubmittedById` is still never set. It belongs in `_Model_Compass_SalesOrder::prePost()` from `$api->delegator->user`, not in the client. The earlier client-side write was dropped.
122
- - `Logs_Compass.Api.originalUserId` was **NULL** for every row of an Order For session on client-sandbox (2026-09-22, userId 303631, including `POST /v2/sales-orders`) — the delegator is not being stamped there. See [order-for delegation](../../_underscore/features/order-for-delegation.md).
134
+ - No permanent "in mode" pill style — needs a design.
135
+ - `SalesOrders.c_orderSubmittedById` is still never set. It belongs in `_Model_Compass_SalesOrder::prePost()` from `$api->delegator->user`, not in the client.
136
+ - `Logs_Compass.Api.originalUserId` was **NULL** for every row of an Order For session on client-sandbox (2026-09-22). See [order-for delegation](../../_underscore/features/order-for-delegation.md).
123
137
 
124
138
  ## Change history
125
139
 
126
- - 2026-09-22 — Order For now switches the token (encryptedUser, /auth/delegator, beginDelegation, reload) instead of scoping the catalog in the browser; added the `delegation` marker plus `useIsViewAsActive` / `useIsOrderForDelegationActive`, the read-only cart `orderFor` field while delegated, the shared `useReturnToOriginalUser`, and the no-paging delegation list. Fixed the View As banner flash mid-switch and the return-flow ordering. Pill highlight is hover/open only. (tcox)
127
- - 2026-09-21 — Corrected the "no delegation API exists" finding: `Users_DelegatorUsers`, route `user-delegator-users` and the enforced `/auth/delegator` now exist (TRUE-81848); added `fetchDelegationList`. (bala)
128
- - 2026-09-17 — Pick persisted to `order-for-zu` with an `ownerUuid` guard (TRUE-81852); submit / edit-order-exit / view-as-return / reload clears removed. (tcox)
140
+ - 2026-10-05 — Pill redesigned as a mode (Matt's review): default "Order for" with no name and never "Myself"; in the mode the name shows under the label plus an "x" that returns to self; the signed-in person is no longer listed; the header no longer reads or writes the cart's pick; Clear cart clears only the cart pick. Added `clearLabel` and test id `order-for-clear`. (tcox)
141
+ - 2026-09-30 — Merged into `_sandbox-client` (6d84f565) on top of the TRUE-81991 session layer: `originalAuthTokens` + `order-for-zu` added to `AUTH_STORAGE_KEYS`; `fetchUserTokens` returns the pair; `resetClientState` calls `endDelegation()`; switch and return use `clearPersistedQueryCache`. Old pending-pick store API removed. (tcox)
142
+ - 2026-09-22 — Order For now switches the token (encryptedUser, /auth/delegator, beginDelegation, reload); added the `delegation` marker, `useIsViewAsActive` / `useIsOrderForDelegationActive`, the read-only cart `orderFor` field while delegated, the shared `useReturnToOriginalUser`, and the no-paging list. (tcox)
143
+ - 2026-09-21 — `Users_DelegatorUsers`, route `user-delegator-users` and the enforced `/auth/delegator` exist (TRUE-81848); added `fetchDelegationList`. (bala)
144
+ - 2026-09-17 — Pick persisted to `order-for-zu` with an `ownerUuid` guard (TRUE-81852). (tcox)
129
145
  - 2026-09-17 — Header order-for pill added (TRUE-81851). (tcox)
130
146
 
131
147
  ## Related
@@ -6,12 +6,15 @@ project: TOGa Commerce
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-22
9
+ updated: 2026-10-05
10
10
  owners: [tcox, bala, apeterson]
11
11
  files:
12
12
  - src/api/sessionTokens.ts
13
13
  - src/api/apiBaseUrl.ts
14
14
  - src/api/axiosInstance.ts
15
+ - src/lib/session.ts
16
+ - src/lib/authStorage.ts
17
+ - src/lib/queryCacheRestoration.ts
15
18
  - src/contexts/AuthContext.tsx
16
19
  - src/hooks/useReturnToOriginalUser.ts
17
20
  - src/hooks/useIsViewAsActive.ts
@@ -41,7 +44,7 @@ All token plumbing lives in **`src/api/sessionTokens.ts`** (new). `src/api/apiBa
41
44
  1. `fetchUserTokens(uuid, "/auth/delegator")` mints the delegated pair for the target user.
42
45
  2. `beginDelegation(delegatedPair, adminUuid)` parks the admin's current pair under localStorage key **`originalAuthTokens`** as `{ v: 1, access, refresh, userUuid, apiHost, savedAt }`, then writes the delegated pair into `accessToken` / `refreshToken`.
43
46
  3. It **never overwrites an existing parked pair** — view-as is one level deep. `isViewAsEnabled` also hides the button when `hasSavedOriginalTokens()` is true.
44
- 4. On any error entering view-as, it rolls back: `restoreOriginalTokens` if a pair is parked, plus `clearOriginalUser()`.
47
+ 4. On any error entering view-as, it rolls back: restores the admin's label language (switched before the persona fetch), `restoreOriginalTokens` if a pair is parked, plus `clearOriginalUser()`. If the parked pair cannot be restored it logs out: `useAuth().logout().then(() => navigate("/"))`.
45
48
  5. It also calls `endDelegation()` so an old Order For marker cannot make the new session read as Order For, and its mount effect that clears the cart is gated on `useIsViewAsActive` — otherwise it wiped an Order For cart on every load.
46
49
 
47
50
  ### Which mode is running — `useIsViewAsActive`
@@ -57,17 +60,17 @@ Moved out of `AuthLayout` so View As and Order For return through one copy. `set
57
60
  1. Checks `userUuid` **and** `apiHost` match, and age ≤ `PARKED_PAIR_MAX_AGE_MS` (**12h**). It **always removes** the parked key, pass or fail.
58
61
  2. On success: puts the admin pair back; if the access JWT is expired (`isAccessTokenExpired` decodes `exp`), calls `refreshAuthTokens()` once **before** the cache clear.
59
62
  3. On failure: logs a Sentry warning, clears the view-as store, logs out, navigates to `/`. There is **no** `/auth/encrypted` fallback.
60
- 4. Removes `localStorage["commerce"]` (the persisted react-query cache) so the impersonated user's rows cannot rehydrate under the admin.
63
+ 4. Calls `clearPersistedQueryCache(queryClient)` (`src/lib/queryCacheRestoration.ts`: waits for a pending restore, cancels in-flight queries, empties memory + `localStorage["commerce"]`) so the impersonated user's rows cannot rehydrate under the admin. The no-parked-pair path logs out with `void logout().then(() => navigate("/"))` — logout is async.
61
64
 
62
65
  ### Where else the parked pair is cleared
63
66
 
64
- `login()` clears it (and the view-as store) first; `fetchPublicToken` and `performLogout` clear it; module load sweeps it when there is no `accessToken`.
67
+ `originalAuthTokens` (and Order For's `order-for-zu`) are in `AUTH_STORAGE_KEYS` (`src/lib/authStorage.ts`); `clearAuthStorage` runs on sign-in (`resetClientState`), sign-out and the 401 `performLogout`. `fetchPublicToken` clears it too; module load sweeps it when there is no `accessToken`. `hasSession()` (`src/lib/session.ts`) is keyed on localStorage `user`.
65
68
 
66
69
  ### Refresh safety (`axiosInstance.ts`)
67
70
 
68
- The 401 refresh is **single-flight** (one module-level promise shared by concurrent 401s) and **identity-bound**: it returns `null` if `localStorage.refreshToken` changed while the refresh was in flight. That stops an in-flight admin refresh from overwriting a just-minted delegated pair (or the reverse).
71
+ The 401 refresh (`performTokenRefresh`) is **single-flight** (one module-level promise shared by concurrent 401s) and **identity-bound** by an **identity epoch** (`getIdentityEpoch` / `bumpIdentityEpoch` in `src/lib/session.ts`) plus a refresh-token comparison. The epoch is bumped on sign-in, sign-out and every token swap (`sessionTokens.setActiveTokens()`). The token comparison alone was not enough: an older admin refresh finishing late restored the **higher-privilege** admin token under the delegated identity. See [SSO & session gating](./sso-redirect-and-session-gating.md).
69
72
 
70
- Since 2026-09-22 the binding is an **identity epoch** in `src/lib/session.ts`, bumped on sign-in, sign-out **and the view-as swap**, checked alongside the refresh-token comparison. The token comparison alone was not enough: on the view-as path an older refresh finishing late restored the **higher-privilege** admin token under the delegated identity. See [SSO & session gating](./sso-redirect-and-session-gating.md).
73
+ If the epoch or refresh token changed while the refresh was in flight, it **stores nothing and returns the current `localStorage.accessToken`** (whoever is current now). A stale current token just gets its own 401 and a fresh refresh.
71
74
 
72
75
  ### Decisions (CSO review — SHIP WITH FIXES)
73
76
 
@@ -81,22 +84,24 @@ Seeds a long-lived `ADMIN_ACCESS_JWT`; stubs `/auth/delegator` → `mock-delegat
81
84
 
82
85
  - **Never re-mint the admin's tokens on return.** The old `/auth/encrypted` path was a privileged call made from the browser. Restore the parked pair, or log out — those are the only two outcomes.
83
86
  - **Always remove `originalAuthTokens`, even on a failed restore.** Leaving a stale parked pair blocks the next view-as (`beginDelegation` refuses to overwrite) and keeps an old admin refresh token on disk.
84
- - **Clear `localStorage["commerce"]` on return.** The persisted query cache is shared; without the clear, the impersonated user's data rehydrates under the admin identity (see the 24h persisted-cache gotcha in [architecture](../architecture.md)).
85
- - **Keep the refresh identity-bound.** A refresh that resolves after the identity changed must return `null`, not write tokens. Bump the epoch on every identity change, including the view-as swap.
87
+ - **Clear the persisted cache on every identity switch with `clearPersistedQueryCache`**, never `queryClient.clear()` + `removeItem("commerce")` — a pending restore can write the old rows back. Without a clear, the impersonated user's data rehydrates under the admin (see the 24h persisted-cache gotcha in [architecture](../architecture.md)).
88
+ - **⚠ A refresh that lost the identity race must never return `null`.** Every 401 waiting on the shared refresh then calls `performLogout` (`hasSession()` is still true), so a delegated token expiring just before return-to-self logged the owner out (found by cso, 2026-09-30). Return the current access token, write nothing. Bump the epoch on every identity change.
86
89
  - **⚠ Optional-chain the whole token path on the swap.** `response?.data.tokens.access` wrote the literal string `"undefined"` on a malformed reply — truthy, so every later call sent `Bearer undefined`.
87
90
  - **Never test "View As is on" with `Object.keys(returnOriginalUser).length > 0`.** Order For parks the same user; use `useIsViewAsActive`.
88
91
  - **Clear the delegated state before any await on return.** `setUser(owner)` / `clearOriginalUser` / `endDelegation` must land before the token refresh or any refetch, or an expired parked token's refresh opens a window where the "parked pair gone while delegated" guard logs the user out (and the banner and cart-clear flicker).
89
- - **⚠ Admin View As is now gated by the Order For delegation list.** `canUserOrderForOtherUser` guards `/users/encryptedUser` and `/auth/delegator` for everyone, so View As returns **403 EZ-1** for any target not on the signed-in user's `Users_DelegatorUsers` rows (seen on api.beta 2026-09-22 for user `50160ccc`). The UI shows nothing — `onError` only logs to Sentry. On prod, View As works only for listed people.
92
+ - **⚠ Admin View As is now gated by the Order For delegation list.** `canUserOrderForOtherUser` guards `/users/encryptedUser` and `/auth/delegator` for everyone, so View As returns **403 EZ-1** for any target not on the signed-in user's `Users_DelegatorUsers` rows (beta and client-sandbox). The UI shows nothing — `onError` only logs to Sentry; no toast by decision (tcox), since prod carries the full list.
90
93
  - **A view-as test must stub `/v2/pages/meta?page=home-page&app=commerce`.** Unstubbed, the real call fails and triggers the enter-view-as rollback, so the spec sees a return-to-admin it did not ask for.
91
94
 
92
95
  ## Open items (out of ticket, not fixed)
93
96
 
97
+ - On a 401 logout the in-memory zustand stores are not reset, so a store write in the ≤1.5s before the redirect can write `zu-cart` / `order-for-zu` back. Low risk: login runs `resetClientState` (cso NIT, 2026-09-30).
94
98
  - `SENTRY_AUTH_TOKEN` is committed in 25 tracked `.env*` files — treat it as compromised and rotate it. (Never store the value in this KB.)
95
99
  - ~~Confirm api2 authorizes `/auth/delegator` **server-side**~~ — **answered 2026-09-21:** it now re-checks `_Model_Client_User::canUserOrderForOtherUser` at mint time (and `/auth/refresh` re-checks it), so a captured encrypted pair cannot be replayed. See [order-for delegation](../../_underscore/features/order-for-delegation.md).
96
100
  - ~~The parked-token slot is shared with "Order For" — decide precedence~~ — **settled 2026-09-22:** one slot, no nesting; the mode is told apart by the Order For marker, not the slot.
97
101
 
98
102
  ## Change history
99
103
 
104
+ - 2026-09-30 — Order For merged onto the TRUE-81991 session layer on `_sandbox-client`: parked pair cleared via `AUTH_STORAGE_KEYS`; return and switch use `clearPersistedQueryCache`; a refresh that lost the identity race now returns the current token instead of `null` (stopped a false logout on return); View As `onError` restores the admin's language and logs out if the parked pair is gone. (tcox)
100
105
  - 2026-09-22 — Refresh identity binding upgraded to an epoch bumped on the view-as swap; `Bearer undefined` swap bug fixed. TRUE-81991 (`e546db3d`). (apeterson)
101
106
  - 2026-09-22 — Return moved to the shared `useReturnToOriginalUser` hook (state cleared before any await); mode now tested with `useIsViewAsActive` because Order For parks the same user; View As start calls `endDelegation()` and its cart-clear effect is gated; recorded the 403 EZ-1 when the target is not on the delegation list. (tcox)
102
107
  - 2026-09-21 — `/auth/delegator` is now gated server side by the Order For delegation check; noted that Order For will share the single parked-token slot. (bala)
@@ -6,7 +6,7 @@ project: TOGa Commerce
6
6
  client: shared
7
7
  type: workflow
8
8
  status: active
9
- updated: 2026-09-22
9
+ updated: 2026-10-05
10
10
  owners: [tcox]
11
11
  files:
12
12
  - toga2-commerce/cypress/e2e/cartPage/cartV2.cy.ts
@@ -101,7 +101,7 @@ The cart UI was instrumented with `data-testid` attributes so specs anchor on **
101
101
 
102
102
  ### Other active specs
103
103
 
104
- - `cypress/e2e/homePage/orderForDropdown.cy.ts` — the header order-for pill, COMPASS only, **5 tests**. Now covers the delegated **token switch**: stubs `users/{admin}?*userDelegatorUsers*`, `encryptedUser`, `POST auth/delegator`, the target-user fetch by regex (`delay: 1500`), `pages/meta` and bundles. Ids `order-for-*` (see [header-order-for-dropdown](../features/header-order-for-dropdown.md)).
104
+ - `cypress/e2e/homePage/orderForDropdown.cy.ts` — the header order-for pill, COMPASS only, **5 tests**. Now covers the delegated **token switch**: stubs `users/{admin}?*userDelegatorUsers*`, `encryptedUser`, `POST auth/delegator`, the target-user fetch by regex (`delay: 1500`), `pages/meta` and bundles. Ids `order-for-*`; `order-for-current-name` and `order-for-clear` (the "x") exist **only in the mode**, so assert `not.exist` for them in the default state (see [header-order-for-dropdown](../features/header-order-for-dropdown.md)).
105
105
 
106
106
  **Testing a token switch — four rules learned here:**
107
107
 
@@ -142,9 +142,12 @@ On prod base `aedd1f7a`, Compass tenant: **13 failures** — `getSupportV2` (4),
142
142
  - **ESLint fails repo-wide in a fresh checkout** because `.eslintrc.cjs` references `eslint-plugin-react-compiler`, which is not in `package.json`, so `npm run lint` fails on a clean install. Check changed files with `tsc` plus `npx eslint --no-eslintrc --plugin react-hooks --plugin @typescript-eslint <files>`. This is a local-environment issue, not a spec problem.
143
143
  - **`npm install` rewrites `package-lock.json` on a newer npm** (e.g. tanstack 5.101.4 → 5.103.2). Do not ship the lock file in a feature commit unless the bump is intended.
144
144
  - **Never start a second Vite dev server in a repo someone else is running.** It corrupts `node_modules/.vite` and breaks their browser. Recovery: stop every server, delete `node_modules/.vite`, restart one.
145
+ - **First run after a source edit often fails one test** with "Expected to find element ... but never found it" while Vite re-bundles on the running dev server. Rerun before debugging; a real failure repeats.
145
146
 
146
147
  ## Change history
147
148
 
149
+ - 2026-10-05 — `orderForDropdown.cy.ts` follows the pill-as-mode redesign (`order-for-clear`, mode-only ids); added the first-run-after-edit Vite flake. (tcox)
150
+
148
151
  - 2026-09-22 — `orderForDropdown.cy.ts` now covers the delegated token switch (5 tests); added the four token-switch testing rules (reload race, non-retrying "never appears" check, 403 not 401, catch-all intercept ordering) plus the package-lock drift and second-Vite-server warnings. (tcox)
149
152
  - 2026-09-17 — Added `viewAsUser.cy.ts`; orderForDropdown now green; recorded the `ELECTRON_RUN_AS_NODE` failure, the intercept pitfalls, and the 13 pre-existing red specs on base `aedd1f7a`. (tcox)
150
153
  - 2026-09-17 — Created with the V2 convention set and the `cartV2.cy.ts` cart harness (slice 1). (tcox)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.940",
3
+ "version": "1.0.941",
4
4
  "description": "TOGA Technology Team Claude Knowledge System — shared AI coding harness with skills, knowledge base CLI, and project installer for Claude Code.",
5
5
  "keywords": [
6
6
  "claude",