feat(apps): a sales expert can work inside their customer's account (D29)
Reported missing by the owner: in the reference an expert could open any
of their referred customers and use the app AS them — browse, see
balance and debt, place orders — «یکجورایی انگار با اکانت اون ها لاگین
کرده». D21 had deliberately left it out as a session-hijack primitive.
The owner asked for the capability back with the implementation left to
judgement, so the scope is the reference's and the mechanism is not.
Scope: everything, as the reference had it. Its axios interceptor
swapped the bearer token, so every request went as the customer,
document upload included. No route is closed in act-as mode.
Mechanism: the reference minted and handed over the customer's own
permanent api_token. Here the expert keeps their own token and sends
X-Act-As-Customer, resolved in one place — CustomerJwtStrategy
.validateExpert. That single point is why all 17 customer routes work
unchanged: validate() already returns a principal whose `id` is a
Customer id and every customer route reads it. It also fixes four real
defects of the original:
- ownership is re-checked on EVERY request, with referredByAgentId in
the same where clause as the id, so another expert's customer and a
non-existent id both answer 403, and a customer moved to another
expert loses access at once rather than at token expiry;
- nothing durable is handed out — the reference's token sat in
localStorage with no expiry and kept working after the expert left;
- adminUserId survives, so an order placed this way is attributable;
in the reference the request was literally the customer's;
- 403 rather than 401, so the app clears the act-as state instead of
logging a perfectly valid session out.
Performance: one findFirst on the primary key, only when the header is
present, and it replaces the trading-account lookup rather than adding
to it.
Three things the reference never needed, because it swapped the whole
session. queryCache's `me:` keys, favouritesStore and wageQuoteStore all
dropped their data on a TOKEN change, which act-as does not cause — so
an expert would have seen their own profile, orders, balance, ledger and
favourites inside the customer's account. The wage-quote one is the
money case: making charge is per customer, so the figure on screen would
not have been the one the order was priced at. And uploadDocument builds
its own headers, so an expert's upload would have been filed against
their own account, silently.
Added with no reference equivalent: a permanent «در حال مشاهده بهعنوان
…» bar. There, impersonation was invisible once the token had been
swapped, and placing an order on the wrong customer's gold debt was one
tap away. It is also the exit — the reference's only way out was a
logout that ended the expert's own session too. The cart is cleared in
both directions.
CustomerJwtStrategy had no test coverage at all and is now where this
feature's whole authorisation boundary lives: act-as-customer.spec.ts
covers 11 cases, including the header being ignored on a customer token,
the no-header path asserting the extra query does not run, and a revoked
session short-circuiting before the header is read.
api: 308/308 tests, 28 suites; both apps vue-tsc clean.
اپ: کارشناس فروش میتواند وارد حساب مشتریان خود شود، وضعیتشان را ببیند و از طرفشان سفارش ثبت کند.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c518f9995b
commit
ddcc8c5649
@@ -991,3 +991,97 @@ apply in production. Full api suite **295/295, 27 suites**; both apps `vue-tsc`
|
||||
clean.
|
||||
|
||||
_No data was deleted in this entry._
|
||||
|
||||
---
|
||||
|
||||
## 2026-10-12 — Phase 2 — «ورود به حساب مشتری» restored (EXP-01/02/03, D29)
|
||||
|
||||
- **What:** A sales expert can again open one of their referred customers'
|
||||
accounts and use the whole app as them — browse, see balance, debt and
|
||||
reports, and place orders that land on the customer's account. Entered from
|
||||
the customer's card on «لیست مشتریان»; a permanent bar names whose account it
|
||||
is and exits.
|
||||
- **Why:** owner report — the reference had it («یکجورایی انگار با اکانت اون ها
|
||||
لاگین کرده … و این خیلی کاربردی بود ولی الان نیست») and D21 had deliberately
|
||||
left it out as a session-hijack primitive. The owner asked for the capability
|
||||
back, with the implementation left to judgement: «همان قابلیت هارو میخوام حالا
|
||||
هرجور که فکر میکنی از لحاظ امنیت و پرفورمنس بهتره».
|
||||
- **Reference:** `customerChoice.vue:155-167` → `myDataStore.ts:159-169` →
|
||||
`tokenManage.ts:8` → `plugins/axios.ts:18`; server
|
||||
`ApiMobileController.php:197-201`.
|
||||
- **Target:** `customer-auth/app-principal.ts`, `customer-jwt.strategy.ts`,
|
||||
`orders.controller.ts`, `orders.service.ts`, `sales-expert-app.controller.ts`;
|
||||
both apps' `stores/session.ts`, `api/client.ts`, `stores/queryCache.ts`,
|
||||
`stores/favoritesStore.ts`, `stores/wageQuoteStore.ts`,
|
||||
`views/expert/CustomerChoiceView.vue`, `components/expert/ActingAsBar.vue`,
|
||||
`App.vue`.
|
||||
- **Matrix row:** EXP-01, EXP-02, EXP-03 in `ISSUES.md`; D29 in `DECISIONS.md`;
|
||||
D21's "deliberately not ported" paragraph marked superseded.
|
||||
- **Commit:** see below.
|
||||
|
||||
**Scope is the reference's, in full.** Its axios interceptor swapped the bearer
|
||||
token, so *every* request went as the customer — including document upload. No
|
||||
route is closed in act-as mode. (The plan had proposed closing documents and
|
||||
logout; the owner asked for the reference's behaviour, so that was dropped.)
|
||||
|
||||
**The mechanism is not the reference's.** It minted a permanent `api_token` per
|
||||
customer and handed it over. Instead the expert keeps their own token and sends
|
||||
`X-Act-As-Customer`, resolved in one place — `CustomerJwtStrategy.validateExpert`.
|
||||
That single point is why all 17 customer routes work unchanged: `validate()`
|
||||
already returns a principal whose `id` is a Customer id, and every customer route
|
||||
reads it. Four consequences, each fixing a real defect of the original:
|
||||
|
||||
1. **Ownership is re-checked on every request**, with `referredByAgentId` in the
|
||||
same `where` as the id — so another expert's customer and a non-existent id
|
||||
give the same answer (403), and a customer moved to another expert loses
|
||||
access at once rather than at token expiry.
|
||||
2. **Nothing durable is handed out.** The reference's token sat in
|
||||
`localStorage` with no expiry and kept working after the expert left. Here
|
||||
the target is in `sessionStorage` and is only a hint; the authority is the
|
||||
expert's own token plus the per-request check.
|
||||
3. **The real actor survives.** `adminUserId` is kept, so an order placed this
|
||||
way is logged as `sales expert <id> acting as them`. In the reference the
|
||||
request was literally the customer's and nothing could be attributed
|
||||
afterwards.
|
||||
4. **403, not 401**, for a customer that is not theirs: the expert's own session
|
||||
is valid, so the app clears the act-as state instead of logging them out.
|
||||
|
||||
**Performance.** One `findFirst` on the primary key, and only when the header is
|
||||
present — it *replaces* the `tradingCustomer` lookup rather than adding to it, so
|
||||
the query count is unchanged in act-as mode and untouched for everyone else.
|
||||
|
||||
**Three things the reference did not need, because it swapped the whole session.**
|
||||
Found by reading the stores rather than assuming:
|
||||
|
||||
- `queryCache` (`me:` keys), `favoritesStore` and `wageQuoteStore` each dropped
|
||||
their data on a **token** change. Act-as changes whose data they hold without
|
||||
changing the token, so without this an expert would have seen their own
|
||||
profile, orders, balance, ledger and favourites inside the customer's account.
|
||||
The wage-quote one is the money case: making charge is per customer, so the
|
||||
figure on screen would not have been the one the order was priced at. Each
|
||||
watcher's own comment already stated the rule it was about to break.
|
||||
- `uploadDocument` bypasses `request()` and builds its own headers, so an
|
||||
expert's upload would have been filed against their own account, silently.
|
||||
`grep Authorization` on the client returning two call sites is what caught it.
|
||||
- The cart is in `localStorage`, so it is cleared in **both** directions —
|
||||
otherwise the expert's own basket follows them in and ends up on the
|
||||
customer's order.
|
||||
|
||||
**Added on purpose, with no reference equivalent:** the permanent «در حال مشاهده
|
||||
بهعنوان …» bar. There, impersonation was invisible once the token had been
|
||||
swapped — the only way to tell whose account you were in was to notice the data
|
||||
looked wrong, and placing an order on the wrong customer's gold debt was one tap
|
||||
away. It is also the exit; the reference's only way out was the logout button,
|
||||
which ended the expert's own session too.
|
||||
|
||||
**Verification.** `CustomerJwtStrategy` had **no test coverage at all** before
|
||||
this, and it is now where the whole authorisation boundary of the feature lives,
|
||||
so `act-as-customer.spec.ts` covers 11 cases: the happy path, the scoped query
|
||||
itself, another expert's customer, an inactive customer, a deactivated expert, a
|
||||
demoted expert, the header being **ignored** on a customer token, the no-header
|
||||
fallback (asserting the extra query does not run), blank and duplicated headers,
|
||||
and a revoked session short-circuiting before the header is read. Plus 2 cases on
|
||||
the order audit trail. Full api suite **308/308, 28 suites**; both apps
|
||||
`vue-tsc` clean.
|
||||
|
||||
_No data was deleted in this entry._
|
||||
|
||||
Reference in New Issue
Block a user