Files
tehran_tala/apps/user-app-v2/src/stores/session.ts
T
s.esmaeiliandClaude Opus 5 ddcc8c5649 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>
2026-10-12 11:48:59 +03:30

137 lines
5.2 KiB
TypeScript

import { defineStore } from 'pinia';
const TOKEN_KEY = 'gold-bank:token';
const CUSTOMER_ID_KEY = 'gold-bank:customerId';
const MOBILE_KEY = 'gold-bank:mobile';
/** Legacy's isUserLoggedIn() (Mixins/checkAccess.ts) reads this key. */
const LEGACY_FLAG_KEY = 'appToken';
/** D21 — cached so the expert menu entries render before whoami resolves. */
const EXPERT_KEY = 'gold-bank:isSalesExpert';
/**
* «ورود به حساب مشتری» — which of the expert's customers they are working
* inside, if any.
*
* Deliberately `sessionStorage`, not `localStorage`: closing the tab ends the
* impersonation. The reference kept the customer's own permanent api_token in
* localStorage (myDataStore.ts:163), so it survived restarts indefinitely and
* outlived the expert's employment. Here it is only a hint anyway — the server
* re-checks on every request that this customer is actually theirs.
*/
const ACTING_AS_KEY = 'gold-bank:actingAs';
export interface ActingAs {
id: string;
name: string;
}
function readActingAs(): ActingAs | null {
try {
const raw = sessionStorage.getItem(ACTING_AS_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as ActingAs;
return parsed && typeof parsed.id === 'string' ? parsed : null;
} catch {
return null;
}
}
/**
* The logged-in customer's session.
*
* `token` is the credential. It is a real customer JWT issued by
* POST /api/customer/auth/verify-otp after the SMS code is checked server-side,
* and it is what every /customer/me* request is authorised with.
*
* `customerId` is kept for display and for cart bookkeeping only — it is NOT an
* identity claim any more. Until the OTP migration, the app sent this id in the
* URL of /customers/:id and the server trusted it, which meant anyone could
* read anyone's profile, orders and ledger by changing the value. The API no
* longer has an id-bearing customer route at all; the server derives the
* customer from the token. See SEC-08 in docs/parity/ISSUES.md.
*/
export const useSessionStore = defineStore('session', {
state: () => ({
token: localStorage.getItem(TOKEN_KEY) as string | null,
customerId: localStorage.getItem(CUSTOMER_ID_KEY) as string | null,
mobile: localStorage.getItem(MOBILE_KEY) as string | null,
/**
* D21 — whether this session belongs to a sales expert.
*
* A CONVENIENCE ONLY: it decides whether the two expert entries are
* rendered. It is never an authorisation claim — the server scopes both
* expert routes on the AdminUser id inside the token and answers 403 for
* an ordinary customer, so flipping this in devtools reveals nothing.
*/
isSalesExpert: localStorage.getItem(EXPERT_KEY) === '1',
/**
* The customer this expert is currently working as, or null.
*
* Display and request-header state only — never an authorisation claim.
* Putting another customer's id here gets a 403 from the server, which
* scopes the lookup to `referredByAgentId` on every single request.
*/
actingAs: readActingAs() as ActingAs | null,
}),
getters: {
/** Authenticated means holding a token, not merely knowing an id. */
isLoggedIn: (state) => Boolean(state.token),
},
actions: {
login(token: string, customerId: string, mobile: string) {
this.token = token;
this.customerId = customerId;
this.mobile = mobile;
localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem(CUSTOMER_ID_KEY, customerId);
localStorage.setItem(MOBILE_KEY, mobile);
localStorage.setItem(LEGACY_FLAG_KEY, token);
},
/** Set from GET /expert/whoami right after login. */
setSalesExpert(value: boolean) {
this.isSalesExpert = value;
if (value) localStorage.setItem(EXPERT_KEY, '1');
else localStorage.removeItem(EXPERT_KEY);
},
/**
* Enter or leave a customer's account. `null` returns the expert to their
* own. The reference did the same through `toggleActiveUser`
* (myDataStore.ts:159-169), which swapped the token; nothing is swapped
* here — the API client just starts naming this customer in a header.
*/
setActingAs(value: ActingAs | null) {
this.actingAs = value;
try {
if (value) sessionStorage.setItem(ACTING_AS_KEY, JSON.stringify(value));
else sessionStorage.removeItem(ACTING_AS_KEY);
} catch {
// A private window with storage blocked: the in-memory value still
// drives this tab, which is all the feature needs.
}
},
/**
* Clears everything. Called on explicit logout and by the API client when a
* request comes back 401 — an expired or revoked token must not leave stale
* personal data on screen.
*/
logout() {
this.token = null;
this.customerId = null;
this.mobile = null;
this.isSalesExpert = false;
this.actingAs = null;
try {
sessionStorage.removeItem(ACTING_AS_KEY);
} catch {
/* storage blocked; the in-memory reset above is what matters */
}
localStorage.removeItem(EXPERT_KEY);
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(CUSTOMER_ID_KEY);
localStorage.removeItem(MOBILE_KEY);
localStorage.removeItem(LEGACY_FLAG_KEY);
},
},
});