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.
- package/knowledge/2.0/apps/_underscore/features/order-for-delegation.md +26 -5
- package/knowledge/2.0/apps/toga2-commerce/INDEX.md +1 -1
- package/knowledge/2.0/apps/toga2-commerce/features/header-order-for-dropdown.md +55 -39
- package/knowledge/2.0/apps/toga2-commerce/features/view-as-session-tokens.md +14 -9
- package/knowledge/2.0/apps/toga2-commerce/workflows/cypress-testing.md +5 -2
- package/package.json +1 -1
|
@@ -6,7 +6,7 @@ project: _Underscore
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
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
|
-
- **
|
|
116
|
-
|
|
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.
|
|
155
|
-
|
|
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
|
|
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
|
|
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-
|
|
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
|
|
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
|
|
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
|
-
**
|
|
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
|
|
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
|
|
60
|
+
5. Clear the cart, **await** `clearPersistedQueryCache(queryClient)` (`src/lib/queryCacheRestoration.ts` — never `queryClient.clear()` + `removeItem("commerce")`), `navigate("/")`, `window.location.reload()`.
|
|
54
61
|
|
|
55
|
-
**"
|
|
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
|
|
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.**
|
|
66
|
-
-
|
|
67
|
-
- `Users_DelegatorUsers`
|
|
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
|
|
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` (
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
-
|
|
96
|
-
-
|
|
97
|
-
-
|
|
98
|
-
-
|
|
99
|
-
-
|
|
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)`
|
|
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
|
-
- **
|
|
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.
|
|
110
|
-
- **Order of operations on return matters.** In `useReturnToOriginalUser`, `setUser(owner)`
|
|
111
|
-
- **
|
|
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).
|
|
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
|
|
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
|
-
-
|
|
122
|
-
- `
|
|
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-
|
|
127
|
-
- 2026-09-
|
|
128
|
-
- 2026-09-
|
|
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-
|
|
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.
|
|
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
|
-
`
|
|
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
|
|
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
|
-
|
|
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 `
|
|
85
|
-
-
|
|
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 (
|
|
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-
|
|
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
|
|
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