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:
s.esmaeili
2026-10-12 11:48:59 +03:30
co-authored by Claude Opus 5
parent c518f9995b
commit ddcc8c5649
28 changed files with 1337 additions and 46 deletions
+11 -1
View File
@@ -6,7 +6,8 @@
already commented out in legacy's own App.vue, so neither ran there either.
-->
<template>
<div>
<div :class="{ 'is-actingAs': Boolean(session.actingAs) }">
<ActingAsBar />
<component :is="getLayout" class="relative">
<router-view v-slot="{ Component, route }">
<keep-alive>
@@ -35,8 +36,11 @@ import FullPageLayout from './layout/FullPageLayout.vue'
import AppUpdateGate from './components/AppUpdateGate.vue'
import ToastHost from './components/ui/ToastHost.vue'
import ProductMorph from './components/product/ProductMorph.vue'
import ActingAsBar from './components/expert/ActingAsBar.vue'
import { useSessionStore } from './stores/session'
const router = useRouter()
const session = useSessionStore()
// Registers the service worker (offline, install). Updates are not applied
// here: AppUpdateGate shows what changed and applies them on the user's tap.
useRegisterSW()
@@ -54,6 +58,12 @@ body::-webkit-scrollbar {
display: none;
}
/* The act-as strip is fixed, so push everything down by its height while it is
up — otherwise it covers the top of each page, including sticky headers. */
.is-actingAs {
padding-top: 30px;
}
#app {
overflow: hidden;
direction: rtl !important;
+35 -1
View File
@@ -2,6 +2,8 @@ import type { CreateOrderDto } from '@gold-bank/shared-types';
const BASE = '/api';
const TOKEN_KEY = 'gold-bank:token';
/** Must match stores/session.ts — the act-as target, if any. */
const ACTING_AS_KEY = 'gold-bank:actingAs';
/**
* Called when the API rejects our token. Wired up in main.ts rather than
@@ -30,8 +32,28 @@ function readToken(): string | null {
}
}
/**
* «ورود به حساب مشتری» — the customer a sales expert is working inside.
*
* Read from storage rather than the store for the same reason as the token: this
* module must not depend on Pinia. The server treats this as a request, not a
* fact: it re-checks on every call that the customer is actually referred by
* this expert, so a forged value is a 403 and reveals nothing.
*/
function readActingAsId(): string | null {
try {
const raw = sessionStorage.getItem(ACTING_AS_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as { id?: unknown };
return typeof parsed?.id === 'string' && parsed.id ? parsed.id : null;
} catch {
return null;
}
}
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const token = readToken();
const actingAs = readActingAsId();
const res = await fetch(`${BASE}${path}`, {
...options,
@@ -40,6 +62,11 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
// Customer JWT from POST /customer/auth/verify-otp. The API derives the
// customer from this, never from an id in the URL.
...(token ? { Authorization: `Bearer ${token}` } : {}),
// Sent on every request while impersonating, which is what makes the whole
// app render from the customer's point of view — the reference achieved
// the same by swapping the bearer token in its axios interceptor
// (henza_pwa plugins/axios.ts:18 via getUserToken()).
...(actingAs ? { 'X-Act-As-Customer': actingAs } : {}),
...options?.headers,
},
});
@@ -420,10 +447,17 @@ export const api = {
body.append('purpose', purpose);
const token = readToken();
// This route bypasses `request()`, so the act-as header has to be repeated
// here — without it an expert uploading a customer's documents would file
// them against their OWN account, silently and with no error.
const actingAs = readActingAsId();
const res = await fetch(`${BASE}/customer/me/documents`, {
method: 'POST',
// No Content-Type: the browser sets it with the boundary.
headers: token ? { Authorization: `Bearer ${token}` } : {},
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(actingAs ? { 'X-Act-As-Customer': actingAs } : {}),
},
body,
});
@@ -0,0 +1,112 @@
<!--
«در حال مشاهده به‌عنوان …» — shown while a sales expert is working inside one
of their customers' accounts.
NOT in the reference, and added on purpose. There, impersonation was invisible
once the token had been swapped (myDataStore.ts:163): the only way an expert
could tell whose account they were in was to notice the data looked wrong.
Placing an order on the wrong customer's account — their gold debt, their
ledger — was a single tap away with nothing to catch it.
It is also the exit. The reference's only way out was the logout button, which
dropped the expert's own session too (Dashboard.vue:781-789).
-->
<template>
<div v-if="session.actingAs" class="aab" role="status">
<span class="aab__dot" aria-hidden="true" />
<span class="aab__text">
در حال مشاهده به‌عنوان <strong>{{ session.actingAs.name || 'مشتری' }}</strong>
</span>
<button type="button" class="aab__exit" :disabled="leaving" @click="leave">
{{ leaving ? '…' : 'خروج' }}
</button>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useSessionStore } from '../../stores/session'
import { useCartStore } from '../../stores/cartStore'
import { toast } from '../../composables/useToast'
const session = useSessionStore()
const cart = useCartStore()
const router = useRouter()
const leaving = ref(false)
/**
* Back to the expert's own account. The cart is cleared in this direction too:
* a basket built inside the customer's account must not follow the expert out
* and land on their own order.
*/
async function leave() {
leaving.value = true
try {
cart.clear()
session.setActingAs(null)
await router.push({ name: 'home' })
toast.info('به حساب خودتان بازگشتید')
} finally {
leaving.value = false
}
}
</script>
<style scoped lang="less">
.aab {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 2000;
display: flex;
align-items: center;
gap: 8px;
padding: 7px 16px;
/* Deliberately not a brand colour: this is a warning strip, and it has to be
noticeable at a glance to do its job. */
background: #8d6a46;
color: #fff;
font-size: 12px;
font-weight: 600;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
}
.aab__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #ffd9a0;
flex: 0 0 auto;
}
.aab__text {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
strong {
font-weight: 800;
}
}
.aab__exit {
flex: 0 0 auto;
border: 1px solid rgba(255, 255, 255, 0.55);
background: transparent;
color: #fff;
border-radius: 8px;
padding: 3px 12px;
font-family: inherit;
font-size: 12px;
font-weight: 700;
cursor: pointer;
&:disabled {
opacity: 0.6;
cursor: default;
}
}
</style>
+8 -3
View File
@@ -21,10 +21,15 @@ export const useFavoritesStore = defineStore('favorites', () => {
}
}
// Loaded for whoever is signed in, cleared on logout — never another user's list.
// Loaded for whoever is signed in, cleared on logout — never another user's
// list. `actingAs` is watched alongside the token because «ورود به حساب
// مشتری» changes whose favourites these are WITHOUT changing the token: a
// sales expert entering a customer's account would otherwise see their own
// hearts on the customer's products, and tapping one would write to the
// customer's list.
watch(
() => session.token,
(token) => {
() => [session.token, session.actingAs?.id ?? null] as const,
([token]) => {
ids.value = []
if (token) void load()
},
+8 -2
View File
@@ -16,7 +16,8 @@
// making it deeply reactive would cost more than the request it saves.
//
// Keys under `me:` hold one customer's data. They are dropped whenever the
// session token changes, so the next user never sees the previous one's data.
// session token changes — or the act-as target does, which changes whose data
// they hold without changing the token — so one user never sees another's.
import { defineStore } from 'pinia'
import { watch } from 'vue'
import { useSessionStore } from './session'
@@ -91,8 +92,13 @@ export const useQueryCache = defineStore('queryCache', () => {
}
}
// The token changes when the user changes — and, with «ورود به حساب مشتری»,
// WHOSE data `me:` keys hold changes without the token changing at all. Both
// have to drop the cache, or a sales expert entering a customer's account
// would be shown their own profile, orders, balance and ledger from before
// the switch.
watch(
() => session.token,
() => [session.token, session.actingAs?.id ?? null],
() => clearUserScoped(),
)
+58
View File
@@ -7,6 +7,33 @@ const MOBILE_KEY = 'gold-bank:mobile';
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.
@@ -36,6 +63,14 @@ export const useSessionStore = defineStore('session', {
* 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. */
@@ -58,6 +93,23 @@ export const useSessionStore = defineStore('session', {
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
@@ -68,6 +120,12 @@ export const useSessionStore = defineStore('session', {
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);
+5 -1
View File
@@ -22,8 +22,12 @@ export const useWageQuoteStore = defineStore('wageQuotes', () => {
let timer: ReturnType<typeof setTimeout> | null = null
// A different customer has a different percent: never reuse another's quotes.
// That includes «ورود به حساب مشتری», which swaps WHICH customer we are
// quoting for without swapping the token — so a sales expert would otherwise
// see their own making charge on the customer's products, and the figure on
// screen would not be the one the order is placed at.
watch(
() => session.token,
() => [session.token, session.actingAs?.id ?? null],
() => {
quotes.value = {}
pending.clear()
@@ -14,15 +14,18 @@
scope is commented out at ApiMobileController.php:187, so every referred
customer appears whatever their state (D4: replicate, don't "fix")
Deliberately NOT ported — two things, both recorded in D21:
1. `changeUser` / act-as. The reference minted an api_token for each listed
customer (ApiMobileController.php:197-201) so the agent could act AS them.
That is a session-hijack primitive; every session here derives from the
caller's own token, so tapping a card opens their details instead.
2. The activate/deactivate toggle and the edit button, which posted to
`/customer/toggle-active` and the customer form. Those are admin-panel
operations here, and adding a write path to the app during a delivery is
not worth the risk. The state is shown, read-only.
`changeUser` / act-as IS ported — see `enterAccount` below and D29. It was
previously left out as a session-hijack primitive (D21); the owner asked for
it back, so the capability is the reference's but the mechanism is not. The
reference minted a permanent api_token per customer
(ApiMobileController.php:197-201) and swapped the bearer token; here the
expert keeps their own token and the client names the customer in a header,
which the server re-authorises on every request.
Still deliberately NOT ported (D21):
- The activate/deactivate toggle and the edit button, which posted to
`/customer/toggle-active` and the customer form. Those are admin-panel
operations here. The state is shown, read-only.
Scope is the server's: `referredByAgentId` = this expert, from the token.
-->
@@ -127,6 +130,9 @@
<dd>{{ active.active === 1 ? 'فعال' : 'غیرفعال' }}</dd>
</div>
</dl>
<button type="button" class="cch__enter" :disabled="entering" @click="enterAccount(active)">
{{ entering ? 'در حال ورود…' : 'ورود به حساب این مشتری' }}
</button>
<button type="button" class="cch__close" @click="detailOpen = false">بستن</button>
</div>
</Drawer>
@@ -135,6 +141,7 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Drawer } from 'ant-design-vue'
import { Loading, PullRefresh } from 'vant'
import Icon from '../../components/icon/Icon.vue'
@@ -142,6 +149,14 @@ import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
import SearchInput from '../../components/search/SearchInput.vue'
import { api, type ExpertCustomer } from '../../api/client'
import { useSessionStore } from '../../stores/session'
import { useCartStore } from '../../stores/cartStore'
import { toast } from '../../composables/useToast'
const router = useRouter()
const session = useSessionStore()
const cart = useCartStore()
const entering = ref(false)
const rows = ref<ExpertCustomer[]>([])
const loading = ref(false)
@@ -190,6 +205,30 @@ function openDetail(item: ExpertCustomer) {
detailOpen.value = true
}
/**
* «ورود به حساب مشتری» — the reference's `changeUser`
* (customerChoice.vue:155-167). There it swapped the bearer token for the
* customer's own; here the session simply records who we are acting as and the
* API client names them in a header, which the server authorises per request.
*
* The cart is cleared in both directions: it lives in localStorage, so without
* this the expert's own basket would follow them into the customer's account
* and end up on the customer's order.
*/
async function enterAccount(item: ExpertCustomer) {
entering.value = true
try {
cart.clear()
session.setActingAs({ id: item.id, name: item.name ?? item.fullName ?? item.mobile ?? '' })
detailOpen.value = false
// Same destination as the reference, which returned to the dashboard.
await router.push({ name: 'userPage::hasSubRoute' })
toast.success(`وارد حساب ${item.name ?? ''} شدید`)
} finally {
entering.value = false
}
}
onMounted(load)
</script>
@@ -334,7 +373,7 @@ onMounted(load)
font-weight: 600;
}
.cch__close {
.cch__enter {
margin-top: 18px;
width: 100%;
padding: 13px;
@@ -346,5 +385,25 @@ onMounted(load)
font-size: 14px;
font-weight: 700;
cursor: pointer;
&:disabled {
opacity: 0.6;
cursor: default;
}
}
/* Secondary now that "enter the account" is the primary action here. */
.cch__close {
margin-top: 10px;
width: 100%;
padding: 13px;
border: 1px solid fade(@primary, 35%);
border-radius: 14px;
background: transparent;
color: @primary;
font-family: inherit;
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
</style>