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
@@ -0,0 +1,184 @@
|
|||||||
|
import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { AdminRole } from '@prisma/client';
|
||||||
|
|
||||||
|
// passport-jwt reads the secret at construction; give it something to find.
|
||||||
|
process.env.JWT_CUSTOMER_SECRET = 'test-secret';
|
||||||
|
|
||||||
|
// @nestjs/jwt v12 is ESM only and this jest setup is CommonJS. The strategy
|
||||||
|
// pulls it in transitively (customer-auth.service exports customerJwtSecret),
|
||||||
|
// so it is stubbed here exactly as master-otp.spec.ts does.
|
||||||
|
jest.mock('@nestjs/jwt', () => ({ JwtService: class {} }));
|
||||||
|
|
||||||
|
import { CustomerJwtStrategy } from './customer-jwt.strategy';
|
||||||
|
import {
|
||||||
|
ACT_AS_CUSTOMER_HEADER,
|
||||||
|
CUSTOMER_TOKEN_TYPE,
|
||||||
|
EXPERT_TOKEN_TYPE,
|
||||||
|
} from './app-principal';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «ورود به حساب مشتری» — a sales expert working inside one of their own
|
||||||
|
* customers' accounts (owner request 2026-10-12).
|
||||||
|
*
|
||||||
|
* The reference did this by handing the expert the customer's own permanent
|
||||||
|
* `api_token` (ApiMobileController.php:197-201), which meant the request was
|
||||||
|
* literally the customer's: unauditable, valid forever, and still working after
|
||||||
|
* the expert left. Here the expert keeps their own token and names the customer
|
||||||
|
* in a header, and this strategy is the ONLY place that decides whether that is
|
||||||
|
* allowed — so these cases are the whole security boundary of the feature.
|
||||||
|
*
|
||||||
|
* The strategy had no test coverage at all before this file.
|
||||||
|
*/
|
||||||
|
describe('CustomerJwtStrategy — a sales expert acting as their customer', () => {
|
||||||
|
const EXPERT = {
|
||||||
|
id: 'adm_1',
|
||||||
|
mobile: '09120000001',
|
||||||
|
role: AdminRole.SALES_EXPERT,
|
||||||
|
isActive: true,
|
||||||
|
salesExpertProfile: {
|
||||||
|
tradingCustomerId: 'cus_trading',
|
||||||
|
tradingCustomer: { id: 'cus_trading', isActive: true, isArchived: false },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The expert's own referred customer, as the scoped lookup would find them. */
|
||||||
|
const MINE = { id: 'cus_mine', mobile: '09125555555' };
|
||||||
|
|
||||||
|
function build(opts: { admin?: unknown; scopedCustomer?: unknown; customer?: unknown } = {}) {
|
||||||
|
const prisma = {
|
||||||
|
adminUser: { findUnique: jest.fn().mockResolvedValue('admin' in opts ? opts.admin : EXPERT) },
|
||||||
|
customer: {
|
||||||
|
// The act-as lookup: id AND referredByAgentId in one where clause, so
|
||||||
|
// "not mine" and "does not exist" are the same answer.
|
||||||
|
findFirst: jest.fn().mockResolvedValue('scopedCustomer' in opts ? opts.scopedCustomer : MINE),
|
||||||
|
findUnique: jest.fn().mockResolvedValue(
|
||||||
|
'customer' in opts
|
||||||
|
? opts.customer
|
||||||
|
: { id: 'cus_1', mobile: '09121111111', isActive: true, isArchived: false },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// Session tracking off — isActive() answers true without a query.
|
||||||
|
const sessions = { isActive: jest.fn().mockResolvedValue(true) };
|
||||||
|
const strategy = new CustomerJwtStrategy(prisma as never, sessions as never);
|
||||||
|
return { strategy, prisma };
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = (actAs?: string) =>
|
||||||
|
({ headers: actAs === undefined ? {} : { [ACT_AS_CUSTOMER_HEADER]: actAs } }) as never;
|
||||||
|
|
||||||
|
const expertToken = { sub: 'adm_1', mobile: '09120000001', typ: EXPERT_TOKEN_TYPE } as never;
|
||||||
|
const customerToken = { sub: 'cus_1', mobile: '09121111111', typ: CUSTOMER_TOKEN_TYPE } as never;
|
||||||
|
|
||||||
|
it('serves the customer as themselves, keeping the expert as the real actor', async () => {
|
||||||
|
const { strategy } = build();
|
||||||
|
const me = await strategy.validate(req('cus_mine'), expertToken);
|
||||||
|
|
||||||
|
// `id` is what every customer route reads — this is the whole feature.
|
||||||
|
expect(me.id).toBe('cus_mine');
|
||||||
|
expect(me.actingAsCustomerId).toBe('cus_mine');
|
||||||
|
// The real actor is never lost, which is what the reference could not do.
|
||||||
|
expect(me.adminUserId).toBe('adm_1');
|
||||||
|
expect(me.type).toBe(EXPERT_TOKEN_TYPE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scopes the lookup to this expert in the query itself', async () => {
|
||||||
|
const { strategy, prisma } = build();
|
||||||
|
await strategy.validate(req('cus_mine'), expertToken);
|
||||||
|
expect(prisma.customer.findFirst).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({
|
||||||
|
id: 'cus_mine',
|
||||||
|
referredByAgentId: 'adm_1',
|
||||||
|
isActive: true,
|
||||||
|
isArchived: false,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses another expert's customer with 403, not 401", async () => {
|
||||||
|
// The scoped lookup finds nothing — same answer as a non-existent id, so an
|
||||||
|
// id cannot be probed for existence.
|
||||||
|
const { strategy } = build({ scopedCustomer: null });
|
||||||
|
// 403 and not 401 on purpose: the expert's own session is fine, so the app
|
||||||
|
// clears the act-as state rather than logging them out.
|
||||||
|
await expect(strategy.validate(req('cus_someone_else'), expertToken)).rejects.toThrow(
|
||||||
|
ForbiddenException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses an inactive or archived customer', async () => {
|
||||||
|
// isActive/isArchived are part of the same where clause, so this is the
|
||||||
|
// identical "no row" path; asserted separately because it is a distinct
|
||||||
|
// business case that must not quietly start working.
|
||||||
|
const { strategy } = build({ scopedCustomer: null });
|
||||||
|
await expect(strategy.validate(req('cus_disabled'), expertToken)).rejects.toThrow(
|
||||||
|
ForbiddenException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cuts the expert off immediately once they are deactivated', async () => {
|
||||||
|
const { strategy } = build({ admin: { ...EXPERT, isActive: false } });
|
||||||
|
await expect(strategy.validate(req('cus_mine'), expertToken)).rejects.toThrow(
|
||||||
|
UnauthorizedException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cuts them off immediately once they are no longer a sales expert', async () => {
|
||||||
|
const { strategy } = build({ admin: { ...EXPERT, role: AdminRole.ACCOUNTANT } });
|
||||||
|
await expect(strategy.validate(req('cus_mine'), expertToken)).rejects.toThrow(
|
||||||
|
UnauthorizedException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores the header on an ordinary customer token', async () => {
|
||||||
|
// Not an error — simply no effect. A customer cannot grant themselves
|
||||||
|
// anything by sending it, and there is nothing for them to discover.
|
||||||
|
const { strategy, prisma } = build();
|
||||||
|
const me = await strategy.validate(req('cus_mine'), customerToken);
|
||||||
|
expect(me.id).toBe('cus_1');
|
||||||
|
expect(me.actingAsCustomerId).toBeUndefined();
|
||||||
|
expect(prisma.customer.findFirst).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the trading account when no header is sent', async () => {
|
||||||
|
const { strategy, prisma } = build();
|
||||||
|
const me = await strategy.validate(req(), expertToken);
|
||||||
|
expect(me.id).toBe('cus_trading');
|
||||||
|
expect(me.actingAsCustomerId).toBeUndefined();
|
||||||
|
// No extra query on the ordinary path — the act-as lookup never runs.
|
||||||
|
expect(prisma.customer.findFirst).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a blank or whitespace header as absent', async () => {
|
||||||
|
const { strategy } = build();
|
||||||
|
await expect(strategy.validate(req(' '), expertToken)).resolves.toMatchObject({
|
||||||
|
id: 'cus_trading',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes the first value when the header is sent twice', async () => {
|
||||||
|
const { strategy, prisma } = build();
|
||||||
|
const dup = { headers: { [ACT_AS_CUSTOMER_HEADER]: ['cus_mine', 'cus_other'] } } as never;
|
||||||
|
await strategy.validate(dup, expertToken);
|
||||||
|
expect(prisma.customer.findFirst).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ where: expect.objectContaining({ id: 'cus_mine' }) }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a revoked session before looking at the header at all', async () => {
|
||||||
|
const prisma = {
|
||||||
|
adminUser: { findUnique: jest.fn() },
|
||||||
|
customer: { findFirst: jest.fn(), findUnique: jest.fn() },
|
||||||
|
};
|
||||||
|
const strategy = new CustomerJwtStrategy(
|
||||||
|
prisma as never,
|
||||||
|
{ isActive: jest.fn().mockResolvedValue(false) } as never,
|
||||||
|
);
|
||||||
|
await expect(strategy.validate(req('cus_mine'), expertToken)).rejects.toThrow(
|
||||||
|
UnauthorizedException,
|
||||||
|
);
|
||||||
|
expect(prisma.adminUser.findUnique).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -54,13 +54,37 @@ export interface AppJwtPayload {
|
|||||||
* Customer.referredByAgentId points at the AdminUser, not at the trading row.
|
* Customer.referredByAgentId points at the AdminUser, not at the trading row.
|
||||||
*/
|
*/
|
||||||
export interface AuthenticatedCustomer {
|
export interface AuthenticatedCustomer {
|
||||||
/** Customer id. For an expert, their own trading account. */
|
/**
|
||||||
|
* Customer id. For an expert: their own trading account, or — while acting as
|
||||||
|
* one of their customers — that customer. Every customer route reads this, so
|
||||||
|
* setting it here is what makes the whole app render from the customer's point
|
||||||
|
* of view without touching a single route.
|
||||||
|
*/
|
||||||
id: string;
|
id: string;
|
||||||
mobile: string;
|
mobile: string;
|
||||||
/** Which kind of principal this is. */
|
/** Which kind of principal this is. */
|
||||||
type: AppTokenType;
|
type: AppTokenType;
|
||||||
/** The AdminUser id — present only for a sales expert. */
|
/** The AdminUser id — present only for a sales expert. */
|
||||||
adminUserId?: string;
|
adminUserId?: string;
|
||||||
|
/**
|
||||||
|
* Set only while a sales expert is acting as one of their referred customers
|
||||||
|
* («ورود به حساب مشتری»). Equal to `id` when set.
|
||||||
|
*
|
||||||
|
* It exists so the real actor is never lost: `id` says whose data this is,
|
||||||
|
* `adminUserId` says who is actually driving. The reference had no equivalent
|
||||||
|
* — it handed the expert the customer's own `api_token`, so a request was
|
||||||
|
* indistinguishable from the customer's own and nothing could be audited
|
||||||
|
* afterwards.
|
||||||
|
*/
|
||||||
|
actingAsCustomerId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The request header that asks to act as one of the expert's own customers. */
|
||||||
|
export const ACT_AS_CUSTOMER_HEADER = 'x-act-as-customer';
|
||||||
|
|
||||||
|
/** True when this principal is a sales expert acting as one of their customers. */
|
||||||
|
export function isActingAsCustomer(me: AuthenticatedCustomer): boolean {
|
||||||
|
return Boolean(me.actingAsCustomerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True when this principal is a sales expert. */
|
/** True when this principal is a sales expert. */
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
import { ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { AdminRole } from '@prisma/client';
|
import { AdminRole } from '@prisma/client';
|
||||||
|
import type { Request } from 'express';
|
||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { customerJwtSecret } from './customer-auth.service';
|
import { customerJwtSecret } from './customer-auth.service';
|
||||||
import {
|
import {
|
||||||
|
ACT_AS_CUSTOMER_HEADER,
|
||||||
CUSTOMER_TOKEN_TYPE,
|
CUSTOMER_TOKEN_TYPE,
|
||||||
EXPERT_TOKEN_TYPE,
|
EXPERT_TOKEN_TYPE,
|
||||||
type AppJwtPayload,
|
type AppJwtPayload,
|
||||||
@@ -13,8 +15,10 @@ import {
|
|||||||
import { AppSessionService } from '../sessions/app-session.service';
|
import { AppSessionService } from '../sessions/app-session.service';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
ACT_AS_CUSTOMER_HEADER,
|
||||||
CUSTOMER_TOKEN_TYPE,
|
CUSTOMER_TOKEN_TYPE,
|
||||||
EXPERT_TOKEN_TYPE,
|
EXPERT_TOKEN_TYPE,
|
||||||
|
isActingAsCustomer,
|
||||||
type AuthenticatedCustomer,
|
type AuthenticatedCustomer,
|
||||||
} from './app-principal';
|
} from './app-principal';
|
||||||
|
|
||||||
@@ -50,10 +54,13 @@ export class CustomerJwtStrategy extends PassportStrategy(Strategy, 'customer-jw
|
|||||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
ignoreExpiration: false,
|
ignoreExpiration: false,
|
||||||
secretOrKey: customerJwtSecret(),
|
secretOrKey: customerJwtSecret(),
|
||||||
|
// Needed only to read the act-as header; the token itself is still the
|
||||||
|
// only thing that establishes WHO is calling.
|
||||||
|
passReqToCallback: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async validate(payload: AppJwtPayload): Promise<AuthenticatedCustomer> {
|
async validate(req: Request, payload: AppJwtPayload): Promise<AuthenticatedCustomer> {
|
||||||
// نشست باطلشده (خروج سراسری از IdP یا ابطال محلی). در حالت خاموشِ ردیابی،
|
// نشست باطلشده (خروج سراسری از IdP یا ابطال محلی). در حالت خاموشِ ردیابی،
|
||||||
// بدون هیچ کوئری true برمیگردد ⇒ هزینه و رفتار دقیقاً مثل قبل.
|
// بدون هیچ کوئری true برمیگردد ⇒ هزینه و رفتار دقیقاً مثل قبل.
|
||||||
if (!(await this.sessions.isActive(payload?.jti))) {
|
if (!(await this.sessions.isActive(payload?.jti))) {
|
||||||
@@ -61,7 +68,7 @@ export class CustomerJwtStrategy extends PassportStrategy(Strategy, 'customer-jw
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (payload?.typ === EXPERT_TOKEN_TYPE) {
|
if (payload?.typ === EXPERT_TOKEN_TYPE) {
|
||||||
return this.validateExpert(payload);
|
return this.validateExpert(payload, this.actAsHeader(req));
|
||||||
}
|
}
|
||||||
|
|
||||||
// A token that is not explicitly a customer token is refused, whatever
|
// A token that is not explicitly a customer token is refused, whatever
|
||||||
@@ -84,6 +91,17 @@ export class CustomerJwtStrategy extends PassportStrategy(Strategy, 'customer-jw
|
|||||||
return { id: customer.id, mobile: customer.mobile, type: CUSTOMER_TOKEN_TYPE };
|
return { id: customer.id, mobile: customer.mobile, type: CUSTOMER_TOKEN_TYPE };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The act-as header, or null. Deliberately read ONLY for an expert token: on
|
||||||
|
* a customer's token the header has no effect at all, rather than being an
|
||||||
|
* error — a customer cannot grant themselves anything by sending it.
|
||||||
|
*/
|
||||||
|
private actAsHeader(req: Request): string | null {
|
||||||
|
const raw = (req?.headers as Record<string, unknown> | undefined)?.[ACT_AS_CUSTOMER_HEADER];
|
||||||
|
const value = Array.isArray(raw) ? raw[0] : raw;
|
||||||
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A sales expert. `sub` is their AdminUser id, so the role and active flag
|
* A sales expert. `sub` is their AdminUser id, so the role and active flag
|
||||||
* are re-checked on every request: demoting an expert or deactivating them
|
* are re-checked on every request: demoting an expert or deactivating them
|
||||||
@@ -91,8 +109,28 @@ export class CustomerJwtStrategy extends PassportStrategy(Strategy, 'customer-jw
|
|||||||
*
|
*
|
||||||
* The trading Customer row is resolved here rather than stored in the token,
|
* The trading Customer row is resolved here rather than stored in the token,
|
||||||
* so a token issued before that row existed keeps working.
|
* so a token issued before that row existed keeps working.
|
||||||
|
*
|
||||||
|
* ACTING AS A CUSTOMER («ورود به حساب مشتری», owner request 2026-10-12)
|
||||||
|
*
|
||||||
|
* With `actAs` set, the returned principal's `id` is that customer, so every
|
||||||
|
* customer route — orders, me, ledger, report, favourites, documents — serves
|
||||||
|
* the customer's own data with no change to any route. That is the whole
|
||||||
|
* feature, and it is why this is the only place the behaviour lives.
|
||||||
|
*
|
||||||
|
* How this differs from the reference, which did the same thing by handing the
|
||||||
|
* expert the customer's own `api_token` (ApiMobileController.php:197-201):
|
||||||
|
*
|
||||||
|
* - no token is minted, so nothing durable leaks into a device's storage
|
||||||
|
* and ending the app session ends the impersonation;
|
||||||
|
* - ownership is re-checked on EVERY request, so moving a customer to
|
||||||
|
* another expert cuts access off at once instead of at token expiry;
|
||||||
|
* - `adminUserId` survives, so the real actor is auditable — in the
|
||||||
|
* reference the request was indistinguishable from the customer's own.
|
||||||
*/
|
*/
|
||||||
private async validateExpert(payload: AppJwtPayload): Promise<AuthenticatedCustomer> {
|
private async validateExpert(
|
||||||
|
payload: AppJwtPayload,
|
||||||
|
actAs: string | null,
|
||||||
|
): Promise<AuthenticatedCustomer> {
|
||||||
const admin = await this.prisma.adminUser.findUnique({
|
const admin = await this.prisma.adminUser.findUnique({
|
||||||
where: { id: payload.sub },
|
where: { id: payload.sub },
|
||||||
select: {
|
select: {
|
||||||
@@ -113,6 +151,36 @@ export class CustomerJwtStrategy extends PassportStrategy(Strategy, 'customer-jw
|
|||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (actAs) {
|
||||||
|
// Scoped by referredByAgentId in the same query as the id, so another
|
||||||
|
// expert's customer and a non-existent id are the same answer: no row,
|
||||||
|
// 403. The lookup cannot be talked out of its scope.
|
||||||
|
const customer = await this.prisma.customer.findFirst({
|
||||||
|
where: {
|
||||||
|
id: actAs,
|
||||||
|
referredByAgentId: admin.id,
|
||||||
|
isActive: true,
|
||||||
|
isArchived: false,
|
||||||
|
},
|
||||||
|
select: { id: true, mobile: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!customer) {
|
||||||
|
// Not 401: the expert's own session is perfectly valid, it is this
|
||||||
|
// particular impersonation that is refused — so the app can clear the
|
||||||
|
// act-as state instead of logging them out.
|
||||||
|
throw new ForbiddenException('دسترسی به این مشتری مجاز نیست');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: customer.id,
|
||||||
|
mobile: customer.mobile,
|
||||||
|
type: EXPERT_TOKEN_TYPE,
|
||||||
|
adminUserId: admin.id,
|
||||||
|
actingAsCustomerId: customer.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const trading = admin.salesExpertProfile?.tradingCustomer;
|
const trading = admin.salesExpertProfile?.tradingCustomer;
|
||||||
if (!trading || !trading.isActive || trading.isArchived) {
|
if (!trading || !trading.isActive || trading.isArchived) {
|
||||||
// The account is created at sign-in; if it is missing or has been
|
// The account is created at sign-in; if it is missing or has been
|
||||||
|
|||||||
@@ -22,11 +22,23 @@ import type { AuthenticatedCustomer } from '../customer-auth/customer-jwt.strate
|
|||||||
export class OrdersController {
|
export class OrdersController {
|
||||||
constructor(private readonly orders: OrdersService) {}
|
constructor(private readonly orders: OrdersService) {}
|
||||||
|
|
||||||
/** The customer comes from the token; the body's `customerId` is ignored. */
|
/**
|
||||||
|
* The customer comes from the token; the body's `customerId` is ignored.
|
||||||
|
*
|
||||||
|
* `me.id` is already the right customer when a sales expert is acting as one
|
||||||
|
* of theirs, so such an order lands on that customer's account and ledger with
|
||||||
|
* server-side pricing (MON-08) exactly like one they placed themselves — which
|
||||||
|
* is what the reference did by lending the expert the customer's token.
|
||||||
|
* `actingAsExpertId` records who actually placed it, which the reference could
|
||||||
|
* not do.
|
||||||
|
*/
|
||||||
@CustomerAuth()
|
@CustomerAuth()
|
||||||
@Post()
|
@Post()
|
||||||
create(@Body() dto: CreateOrderDto, @CurrentCustomer() me: AuthenticatedCustomer) {
|
create(@Body() dto: CreateOrderDto, @CurrentCustomer() me: AuthenticatedCustomer) {
|
||||||
return this.orders.createOrder(dto, me.id, { placedBy: 'customer' });
|
return this.orders.createOrder(dto, me.id, {
|
||||||
|
placedBy: 'customer',
|
||||||
|
actingAsExpertId: me.actingAsCustomerId ? me.adminUserId : undefined,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -154,6 +154,39 @@ describe('OrdersService.createOrder — server-side making charge', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «ورود به حساب مشتری»: the order is the customer's in every respect — their
|
||||||
|
* account, their pricing — and the expert is recorded only in the log, which
|
||||||
|
* is the audit trail the reference lost by lending out the customer's token.
|
||||||
|
*/
|
||||||
|
it('records which expert placed an order on a customer’s behalf', async () => {
|
||||||
|
const log = jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||||
|
try {
|
||||||
|
const honest = { items: [{ productVariantId: 'v1', quantity: 1, wagePercent: 10 }] } as never;
|
||||||
|
const { service, created } = build();
|
||||||
|
await service.createOrder(honest, 'c1', { placedBy: 'customer', actingAsExpertId: 'adm_9' });
|
||||||
|
// Still the customer's order, priced by the server.
|
||||||
|
expect(created[0].customerId).toBe('c1');
|
||||||
|
expect(created[0].totalGoldWeightGrams.toString()).toBe('11');
|
||||||
|
const lines = log.mock.calls.map((c) => String(c[0])).join('\n');
|
||||||
|
expect(lines).toContain('sales expert adm_9 acting as them');
|
||||||
|
} finally {
|
||||||
|
log.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs no expert for an ordinary customer order', async () => {
|
||||||
|
const log = jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||||
|
try {
|
||||||
|
const honest = { items: [{ productVariantId: 'v1', quantity: 1, wagePercent: 10 }] } as never;
|
||||||
|
await build().service.createOrder(honest, 'c1', { placedBy: 'customer' });
|
||||||
|
const lines = log.mock.calls.map((c) => String(c[0])).join('\n');
|
||||||
|
expect(lines).not.toContain('acting as');
|
||||||
|
} finally {
|
||||||
|
log.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('stays quiet when the app sent the right figures', async () => {
|
it('stays quiet when the app sent the right figures', async () => {
|
||||||
const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -45,7 +45,16 @@ export class OrdersService {
|
|||||||
async createOrder(
|
async createOrder(
|
||||||
dto: CreateOrderDto,
|
dto: CreateOrderDto,
|
||||||
customerId: string,
|
customerId: string,
|
||||||
opts: { placedBy: 'customer' | 'operator' } = { placedBy: 'operator' },
|
opts: {
|
||||||
|
placedBy: 'customer' | 'operator';
|
||||||
|
/**
|
||||||
|
* Set when a sales expert placed this through «ورود به حساب مشتری». The
|
||||||
|
* order is the customer's in every other respect; this only records who
|
||||||
|
* actually pressed the button, which the reference lost entirely by
|
||||||
|
* lending out the customer's own token.
|
||||||
|
*/
|
||||||
|
actingAsExpertId?: string;
|
||||||
|
} = { placedBy: 'operator' },
|
||||||
) {
|
) {
|
||||||
const customer = await this.prisma.customer.findUnique({ where: { id: customerId } });
|
const customer = await this.prisma.customer.findUnique({ where: { id: customerId } });
|
||||||
if (!customer) throw new NotFoundException(`Customer ${customerId} not found`);
|
if (!customer) throw new NotFoundException(`Customer ${customerId} not found`);
|
||||||
@@ -169,6 +178,15 @@ export class OrdersService {
|
|||||||
// Legacy sent these after the order was committed
|
// Legacy sent these after the order was committed
|
||||||
// (ApiMobileController.php:733-766), so a rolled-back order never texts
|
// (ApiMobileController.php:733-766), so a rolled-back order never texts
|
||||||
// anyone. Fire-and-forget per D20.
|
// anyone. Fire-and-forget per D20.
|
||||||
|
// The audit trail for «ورود به حساب مشتری». Deliberately logged after the
|
||||||
|
// commit, so it only ever claims an order that exists.
|
||||||
|
if (opts.actingAsExpertId) {
|
||||||
|
this.logger.log(
|
||||||
|
`Order ${placed.orderNumber} for customer ${customerId} was placed by ` +
|
||||||
|
`sales expert ${opts.actingAsExpertId} acting as them.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
this.notifyOrderPlaced(placed.orderNumber, customer, totalGoldWeightGrams, isSuspendedOverCreditLimit);
|
this.notifyOrderPlaced(placed.orderNumber, customer, totalGoldWeightGrams, isSuspendedOverCreditLimit);
|
||||||
|
|
||||||
return placed;
|
return placed;
|
||||||
|
|||||||
@@ -57,10 +57,18 @@ export class SalesExpertAppController {
|
|||||||
* Whether the signed-in principal is a sales expert. The apps call this once
|
* Whether the signed-in principal is a sales expert. The apps call this once
|
||||||
* after login to decide whether to show the two expert entries; the answer
|
* after login to decide whether to show the two expert entries; the answer
|
||||||
* comes from the token, so a customer cannot make it say yes.
|
* comes from the token, so a customer cannot make it say yes.
|
||||||
|
*
|
||||||
|
* `actingAsCustomerId` lets an app that was reloaded mid-impersonation render
|
||||||
|
* its "viewing as …" bar from the server's view of the session rather than
|
||||||
|
* trusting its own stored state.
|
||||||
*/
|
*/
|
||||||
@CustomerAuth()
|
@CustomerAuth()
|
||||||
@Get('whoami')
|
@Get('whoami')
|
||||||
whoami(@CurrentCustomer() me: AuthenticatedCustomer) {
|
whoami(@CurrentCustomer() me: AuthenticatedCustomer) {
|
||||||
return { isSalesExpert: Boolean(me.adminUserId), type: me.type };
|
return {
|
||||||
|
isSalesExpert: Boolean(me.adminUserId),
|
||||||
|
type: me.type,
|
||||||
|
actingAsCustomerId: me.actingAsCustomerId ?? null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
already commented out in legacy's own App.vue, so neither ran there either.
|
already commented out in legacy's own App.vue, so neither ran there either.
|
||||||
-->
|
-->
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div :class="{ 'is-actingAs': Boolean(session.actingAs) }">
|
||||||
|
<ActingAsBar />
|
||||||
<component :is="getLayout" class="relative">
|
<component :is="getLayout" class="relative">
|
||||||
<router-view v-slot="{ Component, route }">
|
<router-view v-slot="{ Component, route }">
|
||||||
<keep-alive>
|
<keep-alive>
|
||||||
@@ -35,8 +36,11 @@ import FullPageLayout from './layout/FullPageLayout.vue'
|
|||||||
import AppUpdateGate from './components/AppUpdateGate.vue'
|
import AppUpdateGate from './components/AppUpdateGate.vue'
|
||||||
import ToastHost from './components/ui/ToastHost.vue'
|
import ToastHost from './components/ui/ToastHost.vue'
|
||||||
import ProductMorph from './components/product/ProductMorph.vue'
|
import ProductMorph from './components/product/ProductMorph.vue'
|
||||||
|
import ActingAsBar from './components/expert/ActingAsBar.vue'
|
||||||
|
import { useSessionStore } from './stores/session'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const session = useSessionStore()
|
||||||
// Registers the service worker (offline, install). Updates are not applied
|
// Registers the service worker (offline, install). Updates are not applied
|
||||||
// here: AppUpdateGate shows what changed and applies them on the user's tap.
|
// here: AppUpdateGate shows what changed and applies them on the user's tap.
|
||||||
useRegisterSW()
|
useRegisterSW()
|
||||||
@@ -54,6 +58,12 @@ body::-webkit-scrollbar {
|
|||||||
display: none;
|
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 {
|
#app {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
direction: rtl !important;
|
direction: rtl !important;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import type { CreateOrderDto } from '@gold-bank/shared-types';
|
|||||||
|
|
||||||
const BASE = '/api';
|
const BASE = '/api';
|
||||||
const TOKEN_KEY = 'gold-bank:token';
|
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
|
* 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> {
|
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
const token = readToken();
|
const token = readToken();
|
||||||
|
const actingAs = readActingAsId();
|
||||||
|
|
||||||
const res = await fetch(`${BASE}${path}`, {
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
...options,
|
...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 JWT from POST /customer/auth/verify-otp. The API derives the
|
||||||
// customer from this, never from an id in the URL.
|
// customer from this, never from an id in the URL.
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
...(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,
|
...options?.headers,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -403,10 +430,17 @@ export const api = {
|
|||||||
body.append('purpose', purpose);
|
body.append('purpose', purpose);
|
||||||
|
|
||||||
const token = readToken();
|
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`, {
|
const res = await fetch(`${BASE}/customer/me/documents`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
// No Content-Type: the browser sets it with the boundary.
|
// 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,
|
body,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<!--
|
||||||
|
«در حال مشاهده بهعنوان …» — shown while a sales expert is working inside one
|
||||||
|
of their customers' accounts. Same logic as user-app's copy; the colours are
|
||||||
|
this app's own.
|
||||||
|
|
||||||
|
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>
|
||||||
@@ -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(
|
watch(
|
||||||
() => session.token,
|
() => [session.token, session.actingAs?.id ?? null] as const,
|
||||||
(token) => {
|
([token]) => {
|
||||||
ids.value = []
|
ids.value = []
|
||||||
if (token) void load()
|
if (token) void load()
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,7 +16,8 @@
|
|||||||
// making it deeply reactive would cost more than the request it saves.
|
// 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
|
// 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 { defineStore } from 'pinia'
|
||||||
import { watch } from 'vue'
|
import { watch } from 'vue'
|
||||||
import { useSessionStore } from './session'
|
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(
|
watch(
|
||||||
() => session.token,
|
() => [session.token, session.actingAs?.id ?? null],
|
||||||
() => clearUserScoped(),
|
() => clearUserScoped(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,33 @@ const MOBILE_KEY = 'gold-bank:mobile';
|
|||||||
const LEGACY_FLAG_KEY = 'appToken';
|
const LEGACY_FLAG_KEY = 'appToken';
|
||||||
/** D21 — cached so the expert menu entries render before whoami resolves. */
|
/** D21 — cached so the expert menu entries render before whoami resolves. */
|
||||||
const EXPERT_KEY = 'gold-bank:isSalesExpert';
|
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.
|
* 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.
|
* an ordinary customer, so flipping this in devtools reveals nothing.
|
||||||
*/
|
*/
|
||||||
isSalesExpert: localStorage.getItem(EXPERT_KEY) === '1',
|
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: {
|
getters: {
|
||||||
/** Authenticated means holding a token, not merely knowing an id. */
|
/** 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');
|
if (value) localStorage.setItem(EXPERT_KEY, '1');
|
||||||
else localStorage.removeItem(EXPERT_KEY);
|
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
|
* 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
|
* 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.customerId = null;
|
||||||
this.mobile = null;
|
this.mobile = null;
|
||||||
this.isSalesExpert = false;
|
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(EXPERT_KEY);
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
localStorage.removeItem(CUSTOMER_ID_KEY);
|
localStorage.removeItem(CUSTOMER_ID_KEY);
|
||||||
|
|||||||
@@ -22,8 +22,12 @@ export const useWageQuoteStore = defineStore('wageQuotes', () => {
|
|||||||
let timer: ReturnType<typeof setTimeout> | null = null
|
let timer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
// A different customer has a different percent: never reuse another's quotes.
|
// 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(
|
watch(
|
||||||
() => session.token,
|
() => [session.token, session.actingAs?.id ?? null],
|
||||||
() => {
|
() => {
|
||||||
quotes.value = {}
|
quotes.value = {}
|
||||||
pending.clear()
|
pending.clear()
|
||||||
|
|||||||
@@ -14,15 +14,18 @@
|
|||||||
scope is commented out at ApiMobileController.php:187, so every referred
|
scope is commented out at ApiMobileController.php:187, so every referred
|
||||||
customer appears whatever their state (D4: replicate, don't "fix")
|
customer appears whatever their state (D4: replicate, don't "fix")
|
||||||
|
|
||||||
Deliberately NOT ported — two things, both recorded in D21:
|
`changeUser` / act-as IS ported — see `enterAccount` below and D29. It was
|
||||||
1. `changeUser` / act-as. The reference minted an api_token for each listed
|
previously left out as a session-hijack primitive (D21); the owner asked for
|
||||||
customer (ApiMobileController.php:197-201) so the agent could act AS them.
|
it back, so the capability is the reference's but the mechanism is not. The
|
||||||
That is a session-hijack primitive; every session here derives from the
|
reference minted a permanent api_token per customer
|
||||||
caller's own token, so tapping a card opens their details instead.
|
(ApiMobileController.php:197-201) and swapped the bearer token; here the
|
||||||
2. The activate/deactivate toggle and the edit button, which posted to
|
expert keeps their own token and the client names the customer in a header,
|
||||||
`/customer/toggle-active` and the customer form. Those are admin-panel
|
which the server re-authorises on every request.
|
||||||
operations here, and adding a write path to the app during a delivery is
|
|
||||||
not worth the risk. The state is shown, read-only.
|
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.
|
Scope is the server's: `referredByAgentId` = this expert, from the token.
|
||||||
-->
|
-->
|
||||||
@@ -127,6 +130,9 @@
|
|||||||
<dd>{{ active.active === 1 ? 'فعال' : 'غیرفعال' }}</dd>
|
<dd>{{ active.active === 1 ? 'فعال' : 'غیرفعال' }}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
<button type="button" class="cch__enter" :disabled="entering" @click="enterAccount(active)">
|
||||||
|
{{ entering ? 'در حال ورود…' : 'ورود به حساب این مشتری' }}
|
||||||
|
</button>
|
||||||
<button type="button" class="cch__close" @click="detailOpen = false">بستن</button>
|
<button type="button" class="cch__close" @click="detailOpen = false">بستن</button>
|
||||||
</div>
|
</div>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
@@ -135,6 +141,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { Drawer } from 'ant-design-vue'
|
import { Drawer } from 'ant-design-vue'
|
||||||
import { Loading, PullRefresh } from 'vant'
|
import { Loading, PullRefresh } from 'vant'
|
||||||
import Icon from '../../components/icon/Icon.vue'
|
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 Spinner from '../../components/Spinner.vue'
|
||||||
import SearchInput from '../../components/search/SearchInput.vue'
|
import SearchInput from '../../components/search/SearchInput.vue'
|
||||||
import { api, type ExpertCustomer } from '../../api/client'
|
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 rows = ref<ExpertCustomer[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -190,6 +205,30 @@ function openDetail(item: ExpertCustomer) {
|
|||||||
detailOpen.value = true
|
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)
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -347,7 +386,7 @@ onMounted(load)
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cch__close {
|
.cch__enter {
|
||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 13px;
|
padding: 13px;
|
||||||
@@ -359,5 +398,25 @@ onMounted(load)
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
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(@ink, 35%);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: transparent;
|
||||||
|
color: @ink;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
already commented out in legacy's own App.vue, so neither ran there either.
|
already commented out in legacy's own App.vue, so neither ran there either.
|
||||||
-->
|
-->
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div :class="{ 'is-actingAs': Boolean(session.actingAs) }">
|
||||||
|
<ActingAsBar />
|
||||||
<component :is="getLayout" class="relative">
|
<component :is="getLayout" class="relative">
|
||||||
<router-view v-slot="{ Component, route }">
|
<router-view v-slot="{ Component, route }">
|
||||||
<keep-alive>
|
<keep-alive>
|
||||||
@@ -35,8 +36,11 @@ import FullPageLayout from './layout/FullPageLayout.vue'
|
|||||||
import AppUpdateGate from './components/AppUpdateGate.vue'
|
import AppUpdateGate from './components/AppUpdateGate.vue'
|
||||||
import ToastHost from './components/ui/ToastHost.vue'
|
import ToastHost from './components/ui/ToastHost.vue'
|
||||||
import ProductMorph from './components/product/ProductMorph.vue'
|
import ProductMorph from './components/product/ProductMorph.vue'
|
||||||
|
import ActingAsBar from './components/expert/ActingAsBar.vue'
|
||||||
|
import { useSessionStore } from './stores/session'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const session = useSessionStore()
|
||||||
// Registers the service worker (offline, install). Updates are not applied
|
// Registers the service worker (offline, install). Updates are not applied
|
||||||
// here: AppUpdateGate shows what changed and applies them on the user's tap.
|
// here: AppUpdateGate shows what changed and applies them on the user's tap.
|
||||||
useRegisterSW()
|
useRegisterSW()
|
||||||
@@ -54,6 +58,12 @@ body::-webkit-scrollbar {
|
|||||||
display: none;
|
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 {
|
#app {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
direction: rtl !important;
|
direction: rtl !important;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import type { CreateOrderDto } from '@gold-bank/shared-types';
|
|||||||
|
|
||||||
const BASE = '/api';
|
const BASE = '/api';
|
||||||
const TOKEN_KEY = 'gold-bank:token';
|
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
|
* 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> {
|
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
const token = readToken();
|
const token = readToken();
|
||||||
|
const actingAs = readActingAsId();
|
||||||
|
|
||||||
const res = await fetch(`${BASE}${path}`, {
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
...options,
|
...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 JWT from POST /customer/auth/verify-otp. The API derives the
|
||||||
// customer from this, never from an id in the URL.
|
// customer from this, never from an id in the URL.
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
...(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,
|
...options?.headers,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -420,10 +447,17 @@ export const api = {
|
|||||||
body.append('purpose', purpose);
|
body.append('purpose', purpose);
|
||||||
|
|
||||||
const token = readToken();
|
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`, {
|
const res = await fetch(`${BASE}/customer/me/documents`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
// No Content-Type: the browser sets it with the boundary.
|
// 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,
|
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>
|
||||||
@@ -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(
|
watch(
|
||||||
() => session.token,
|
() => [session.token, session.actingAs?.id ?? null] as const,
|
||||||
(token) => {
|
([token]) => {
|
||||||
ids.value = []
|
ids.value = []
|
||||||
if (token) void load()
|
if (token) void load()
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,7 +16,8 @@
|
|||||||
// making it deeply reactive would cost more than the request it saves.
|
// 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
|
// 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 { defineStore } from 'pinia'
|
||||||
import { watch } from 'vue'
|
import { watch } from 'vue'
|
||||||
import { useSessionStore } from './session'
|
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(
|
watch(
|
||||||
() => session.token,
|
() => [session.token, session.actingAs?.id ?? null],
|
||||||
() => clearUserScoped(),
|
() => clearUserScoped(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,33 @@ const MOBILE_KEY = 'gold-bank:mobile';
|
|||||||
const LEGACY_FLAG_KEY = 'appToken';
|
const LEGACY_FLAG_KEY = 'appToken';
|
||||||
/** D21 — cached so the expert menu entries render before whoami resolves. */
|
/** D21 — cached so the expert menu entries render before whoami resolves. */
|
||||||
const EXPERT_KEY = 'gold-bank:isSalesExpert';
|
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.
|
* 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.
|
* an ordinary customer, so flipping this in devtools reveals nothing.
|
||||||
*/
|
*/
|
||||||
isSalesExpert: localStorage.getItem(EXPERT_KEY) === '1',
|
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: {
|
getters: {
|
||||||
/** Authenticated means holding a token, not merely knowing an id. */
|
/** 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');
|
if (value) localStorage.setItem(EXPERT_KEY, '1');
|
||||||
else localStorage.removeItem(EXPERT_KEY);
|
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
|
* 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
|
* 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.customerId = null;
|
||||||
this.mobile = null;
|
this.mobile = null;
|
||||||
this.isSalesExpert = false;
|
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(EXPERT_KEY);
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
localStorage.removeItem(CUSTOMER_ID_KEY);
|
localStorage.removeItem(CUSTOMER_ID_KEY);
|
||||||
|
|||||||
@@ -22,8 +22,12 @@ export const useWageQuoteStore = defineStore('wageQuotes', () => {
|
|||||||
let timer: ReturnType<typeof setTimeout> | null = null
|
let timer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
// A different customer has a different percent: never reuse another's quotes.
|
// 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(
|
watch(
|
||||||
() => session.token,
|
() => [session.token, session.actingAs?.id ?? null],
|
||||||
() => {
|
() => {
|
||||||
quotes.value = {}
|
quotes.value = {}
|
||||||
pending.clear()
|
pending.clear()
|
||||||
|
|||||||
@@ -14,15 +14,18 @@
|
|||||||
scope is commented out at ApiMobileController.php:187, so every referred
|
scope is commented out at ApiMobileController.php:187, so every referred
|
||||||
customer appears whatever their state (D4: replicate, don't "fix")
|
customer appears whatever their state (D4: replicate, don't "fix")
|
||||||
|
|
||||||
Deliberately NOT ported — two things, both recorded in D21:
|
`changeUser` / act-as IS ported — see `enterAccount` below and D29. It was
|
||||||
1. `changeUser` / act-as. The reference minted an api_token for each listed
|
previously left out as a session-hijack primitive (D21); the owner asked for
|
||||||
customer (ApiMobileController.php:197-201) so the agent could act AS them.
|
it back, so the capability is the reference's but the mechanism is not. The
|
||||||
That is a session-hijack primitive; every session here derives from the
|
reference minted a permanent api_token per customer
|
||||||
caller's own token, so tapping a card opens their details instead.
|
(ApiMobileController.php:197-201) and swapped the bearer token; here the
|
||||||
2. The activate/deactivate toggle and the edit button, which posted to
|
expert keeps their own token and the client names the customer in a header,
|
||||||
`/customer/toggle-active` and the customer form. Those are admin-panel
|
which the server re-authorises on every request.
|
||||||
operations here, and adding a write path to the app during a delivery is
|
|
||||||
not worth the risk. The state is shown, read-only.
|
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.
|
Scope is the server's: `referredByAgentId` = this expert, from the token.
|
||||||
-->
|
-->
|
||||||
@@ -127,6 +130,9 @@
|
|||||||
<dd>{{ active.active === 1 ? 'فعال' : 'غیرفعال' }}</dd>
|
<dd>{{ active.active === 1 ? 'فعال' : 'غیرفعال' }}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
<button type="button" class="cch__enter" :disabled="entering" @click="enterAccount(active)">
|
||||||
|
{{ entering ? 'در حال ورود…' : 'ورود به حساب این مشتری' }}
|
||||||
|
</button>
|
||||||
<button type="button" class="cch__close" @click="detailOpen = false">بستن</button>
|
<button type="button" class="cch__close" @click="detailOpen = false">بستن</button>
|
||||||
</div>
|
</div>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
@@ -135,6 +141,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { Drawer } from 'ant-design-vue'
|
import { Drawer } from 'ant-design-vue'
|
||||||
import { Loading, PullRefresh } from 'vant'
|
import { Loading, PullRefresh } from 'vant'
|
||||||
import Icon from '../../components/icon/Icon.vue'
|
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 Spinner from '../../components/Spinner.vue'
|
||||||
import SearchInput from '../../components/search/SearchInput.vue'
|
import SearchInput from '../../components/search/SearchInput.vue'
|
||||||
import { api, type ExpertCustomer } from '../../api/client'
|
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 rows = ref<ExpertCustomer[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -190,6 +205,30 @@ function openDetail(item: ExpertCustomer) {
|
|||||||
detailOpen.value = true
|
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)
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -334,7 +373,7 @@ onMounted(load)
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cch__close {
|
.cch__enter {
|
||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 13px;
|
padding: 13px;
|
||||||
@@ -346,5 +385,25 @@ onMounted(load)
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
# طرح — بازگرداندن «ورود کارشناس به حساب مشتری» (act-as)
|
||||||
|
|
||||||
|
تاریخ: ۱۴۰۵/۰۷/۲۰ (2026-10-12)
|
||||||
|
|
||||||
|
## وضعیت: ✅ **انجام شد** — تصمیم مالک ۲۰۲۶/۱۰/۱۲
|
||||||
|
|
||||||
|
> «نه دقیقا همون چیزی که بود در ساختار مرجع همون رو بیار ولی با ساختار درستی که
|
||||||
|
> خودت میدونی»
|
||||||
|
|
||||||
|
یعنی: **دامنه کامل مرجع**، با سازوکار امنِ بند ۳. پس **بند ۴ (مرزها) حذف شد** —
|
||||||
|
هیچ مسیری در حالت act-as بسته نیست، آپلود مدارک و logout هم باز است، چون مرجع
|
||||||
|
هم interceptor سراسری داشت و همهچیز از طرف مشتری میرفت.
|
||||||
|
|
||||||
|
بقیه طرح عیناً اجرا شد. سه چیز که در طرح نبود و در حین کار پیدا شد (هر سه رفع
|
||||||
|
شد): پاکسازی `queryCache`/علاقهمندی/اجرت کششده در جابجایی، و هدر در مسیر
|
||||||
|
آپلود که `request()` را دور میزند. جزئیات: `ISSUES.md` EXP-01/02/03 و D29.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### متن اصلی طرح (برای سابقه)
|
||||||
|
|
||||||
|
> **خواسته مالک:** «کارشناس فروش میتونه از طریق اپ خودش وارد صفحه مشتری هاش بشه و
|
||||||
|
> از جانب اون ها ثبت سفارش کنه و وضعیت اون هارو ببینه و یکجورایی انگار با اکانت
|
||||||
|
> اون ها لاگین کرده همه چیز رو از دید اون مشتریش میدید… همان قابلیت هارو میخوام،
|
||||||
|
> حالا هرجور که فکر میکنی از لحاظ امنیت و پرفورمنس بهتره.»
|
||||||
|
|
||||||
|
**همان قابلیت، بدون سه عیبِ روش قدیم.** تصمیمهای فنی زیر با من است؛ هرجا چیزی
|
||||||
|
از مرجع **کم** شده باشد، صریح نوشتهام.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۱. مرجع چه میکرد (بررسیشده، نه حدس)
|
||||||
|
|
||||||
|
| مرحله | کد مرجع |
|
||||||
|
|---|---|
|
||||||
|
| لیست مشتریان | `POST /my-child` → `ApiMobileController.php:185-196`، فیلتر `reagent_id = auth()->id()` |
|
||||||
|
| **توکن مشتری ساخته میشد** | `:197-201` — اگر `api_token` مشتری خالی بود، `Str::random(60)` میساخت و **ذخیره** میکرد |
|
||||||
|
| کلیک روی کارت | `customerChoice.vue:155` → `changeUser()` → `myStore.toggleActiveUser(user)` |
|
||||||
|
| ذخیره توکن | `myDataStore.ts:163` → `localStorage.setItem('activeChildToken', value.api_token)` |
|
||||||
|
| اعمال روی همه درخواستها | `tokenManage.ts:8` `getUserToken()` = توکن مشتری **یا** توکن خودش → `plugins/axios.ts:18` روی **هر** درخواست |
|
||||||
|
| خروج از حساب مشتری | `Dashboard.vue:784` → پاک کردن `activeChildToken` |
|
||||||
|
|
||||||
|
یعنی دامنه مرجع: **هر کاری که مشتری میتوانست**، چون interceptor سراسری بود.
|
||||||
|
|
||||||
|
## ۲. سه عیبی که تکرار نمیکنم
|
||||||
|
|
||||||
|
۱. **توکن دائمی مشتری در دست کارشناس.** `api_token` انقضا نداشت و در
|
||||||
|
`localStorage` میماند. کارشناسی که فردا قطع همکاری میکرد، توکن مشتریانش را
|
||||||
|
داشت و همچنان کار میکرد.
|
||||||
|
۲. **هیچ ردی نمیماند.** درخواست عیناً از طرف مشتری میآمد، پس در سرور معلوم
|
||||||
|
نبود این سفارش را مشتری ثبت کرده یا کارشناس. برای مالی، این یعنی غیرقابل
|
||||||
|
حسابرسی.
|
||||||
|
۳. **بررسی مالکیت فقط یکبار.** توکن در لحظه گرفتن صادر میشد؛ بعد از آن اگر
|
||||||
|
مشتری به کارشناس دیگری منتقل میشد، توکن قدیمی همچنان کار میکرد.
|
||||||
|
|
||||||
|
## ۳. طراحی پیشنهادی
|
||||||
|
|
||||||
|
**هسته: توکنِ مشتری صادر نمیشود. کارشناس با توکن خودش درخواست میدهد و
|
||||||
|
میگوید «از طرف مشتری X».**
|
||||||
|
|
||||||
|
### چرا این انتخاب (و نه گزینههای دیگر)
|
||||||
|
|
||||||
|
`CustomerJwtStrategy.validate()` در تارگت همیشه یک `AuthenticatedCustomer`
|
||||||
|
میسازد که `id` آن **یک Customer id** است، و **هر ۱۷ مسیر مشتری** از همان
|
||||||
|
استفاده میکنند. پس اگر جابجایی را همانجا اعمال کنم، همه آن مسیرها **بدون یک خط
|
||||||
|
تغییر** از دید مشتری کار میکنند — همان «همهچیز را از دید او میبیند» که خواستی،
|
||||||
|
با کمترین سطح تماس با کد.
|
||||||
|
|
||||||
|
| گزینه | نتیجه | رأی |
|
||||||
|
|---|---|---|
|
||||||
|
| صدور توکن مشتری (روش مرجع) | ساده، ولی هر سه عیب بالا | ❌ |
|
||||||
|
| پارامتر `customerId` روی هر مسیر | باید ۱۷ مسیر و همه سرویسها عوض شوند؛ فراموش کردن یکی = حفره | ❌ |
|
||||||
|
| **هدر `X-Act-As-Customer` + اعمال در strategy** | یک نقطه تغییر، هر ۱۷ مسیر خودکار، قابل حسابرسی | ✅ |
|
||||||
|
|
||||||
|
### سمت سرور
|
||||||
|
|
||||||
|
**۱. هدر درخواست.** کارشناس توکن خودش را میفرستد بهعلاوه
|
||||||
|
`X-Act-As-Customer: <customerId>`.
|
||||||
|
|
||||||
|
**۲. اعمال در `validateExpert`** (تنها نقطه تغییر منطق دسترسی):
|
||||||
|
|
||||||
|
```
|
||||||
|
اگر هدر بود و principal کارشناس بود:
|
||||||
|
مشتری را بخوان: id = هدر، referredByAgentId = admin.id، isActive، !isArchived
|
||||||
|
اگر نبود → 403 (مشتری دیگری، یا مشتریِ کارشناس دیگر)
|
||||||
|
اگر بود → { id: customer.id, ← از دید مشتری
|
||||||
|
actingAsCustomerId: customer.id,
|
||||||
|
adminUserId: admin.id, ← هویت واقعی حفظ میشود
|
||||||
|
type: 'sales-expert' }
|
||||||
|
```
|
||||||
|
|
||||||
|
نکات مهم این طراحی:
|
||||||
|
|
||||||
|
- **بررسی مالکیت در هر درخواست** انجام میشود، نه یکبار. مشتری که به کارشناس
|
||||||
|
دیگری منتقل شود، دسترسی **در همان لحظه** قطع میشود. (عیب ۳ رفع شد.)
|
||||||
|
- کارشناس غیرفعال/تنزلیافته همانجا ۴۰۱ میگیرد، چون `validateExpert` از قبل
|
||||||
|
`isActive` و `role` را هر بار میخواند. (عیب ۱ رفع شد.)
|
||||||
|
- `adminUserId` حفظ میشود ⇒ هویت واقعی هرگز گم نمیشود. (عیب ۲ رفع شد.)
|
||||||
|
- توکن **عوض نمیشود**؛ پس چیزی در `localStorage` نمینشیند که بعداً سوءاستفاده
|
||||||
|
شود. بستن اپ = پایان حالت act-as.
|
||||||
|
|
||||||
|
**۳. هزینه پرفورمنس (خواستهات).** یک `findUnique` اضافه روی `Customer` با
|
||||||
|
`where: { id }` — کلید اصلی، پس index-only. **فقط** وقتی هدر وجود دارد؛ یعنی
|
||||||
|
درخواستهای مشتری عادی و کارشناس در حالت معمولی **صفر هزینه اضافه** دارند. در
|
||||||
|
حالت act-as هم جایگزین کوئریِ `tradingCustomer` میشود، نه اضافه بر آن — پس
|
||||||
|
تعداد کوئری ثابت میماند.
|
||||||
|
|
||||||
|
**۴. ⛔ این بند اجرا نشد.** مالک دامنه کامل مرجع را خواست، پس هیچکدام از این
|
||||||
|
مرزها گذاشته نشد — همه مسیرها در حالت act-as باز است:
|
||||||
|
|
||||||
|
| مسیر | در حالت act-as | چرا |
|
||||||
|
|---|---|---|
|
||||||
|
| `POST /orders` | ✅ مجاز | خواسته اصلی |
|
||||||
|
| مشاهده: `me`، `me/orders`، `me/ledger-entries`، `me/report*`، `accounting-balance`، `wage-quote` | ✅ مجاز | «وضعیت آنها را ببیند» |
|
||||||
|
| علاقهمندیها | ✅ مجاز | بیخطر، و بخشی از «از دید او» |
|
||||||
|
| `POST /customer/me/documents` و `complete-profile` | ❌ **۴۰۳** | آپلود مدارک هویتی بهجای مشتری. کارشناس مدرکِ مشتری را ندارد و این کار جعل هویت است، نه خدمت. در پنل ادمین انجام میشود. |
|
||||||
|
| `POST /auth/logout` | ❌ **۴۰۳** | نشست مشتری را نباید کارشناس ببندد |
|
||||||
|
| مسیرهای `expert/*` | ❌ **۴۰۳** | در حالت act-as بیمعناست؛ از ابهام جلوگیری میکند |
|
||||||
|
|
||||||
|
**۵. ثبت در سفارش.** `OrdersService.createOrder` از قبل `placedBy` دارد. سفارشِ
|
||||||
|
act-as با `placedBy: 'operator'` ثبت میشود — یعنی قیمت را سرور تحمیل نمیکند و
|
||||||
|
عدد کارشناس میماند؟ **نه**: برای این حالت `placedBy: 'customer'` میگذارم تا
|
||||||
|
قیمتگذاری سرور (MON-08) اعمال شود، چون این سفارشِ مشتری است نه اپراتورِ پنل.
|
||||||
|
علاوه بر آن `salesAgentId` از قبل خودکار از `customer.referredByAgentId` پر
|
||||||
|
میشود — که همان کارشناس است. **ردّ حسابرسی** هم در لاگ ثبت میشود:
|
||||||
|
`expert <adminUserId> acting as customer <id>`.
|
||||||
|
|
||||||
|
**۶. پیامک.** مشتری پیامک «سفارش شما ثبت شد» را **میگیرد** (مثل مرجع). دلیل:
|
||||||
|
روی حساب و بدهی طلای او نشسته و باید بداند. کارشناس هم پیامک ارجاعدهنده را
|
||||||
|
میگیرد، چون `referredByAgentId` خودش است — رفتار موجود، تغییری لازم نیست.
|
||||||
|
|
||||||
|
### سمت اپ (هر دو اپ)
|
||||||
|
|
||||||
|
**۱. وضعیت act-as** در `sessionStore` (نه `localStorage`): `actingAs = {id, name}`.
|
||||||
|
چون در `sessionStorage`/حافظه است، بستن تب پایانش میدهد — برعکس مرجع.
|
||||||
|
|
||||||
|
**۲. کلاینت API**: اگر `actingAs` ست بود، هدر `X-Act-As-Customer` به همه
|
||||||
|
درخواستها اضافه شود (یک نقطه، در `request()` کلاینت).
|
||||||
|
|
||||||
|
**۳. `CustomerChoiceView`**: زدن روی کارت ⇒ `changeUser` ⇒ ست کردن `actingAs` و
|
||||||
|
رفتن به خانه، **عیناً مثل مرجع** (`customerChoice.vue:155-167`).
|
||||||
|
|
||||||
|
**۴. ⚠️ نوار هشدار همیشگی** — چیزی که مرجع نداشت و از نظر من ضروری است: یک نوار
|
||||||
|
ثابت بالای صفحه «در حال مشاهده بهعنوان **فلانی** — خروج». بدون این، کارشناس
|
||||||
|
نمیفهمد در حساب کیست و ممکن است سفارش را روی حساب اشتباهی ثبت کند. این ریسک
|
||||||
|
واقعیِ روش مرجع بود.
|
||||||
|
|
||||||
|
**۵. پاک کردن سبد در جابجایی.** سبد در `localStorage` است؛ اگر پاک نشود، کالاهای
|
||||||
|
سبدِ کارشناس وارد سفارش مشتری میشود. در جابجایی (ورود و خروج) سبد خالی میشود.
|
||||||
|
|
||||||
|
## ۴. تستها
|
||||||
|
|
||||||
|
| تست | میسنجد |
|
||||||
|
|---|---|
|
||||||
|
| کارشناس + هدر مشتریِ خودش | دادههای آن مشتری برمیگردد |
|
||||||
|
| کارشناس + هدر **مشتریِ کارشناس دیگر** | ۴۰۳ |
|
||||||
|
| کارشناس + هدر مشتری غیرفعال/آرشیو | ۴۰۳ |
|
||||||
|
| **مشتری عادی** + هدر | هدر **نادیده** گرفته میشود (نه ۴۰۳ خاموش؛ اصلاً اثر ندارد) |
|
||||||
|
| کارشناس غیرفعالشده + هدر معتبر | ۴۰۱ فوری |
|
||||||
|
| سفارش در حالت act-as | `customerId` = مشتری، `salesAgentId` = کارشناس، قیمت از سرور |
|
||||||
|
| مسیرهای ممنوع (مدارک، logout) | ۴۰۳ |
|
||||||
|
| بدون هدر | رفتار امروز، بدون تغییر (regression) |
|
||||||
|
|
||||||
|
## ۵. چه چیزی عمداً مثل مرجع نیست
|
||||||
|
|
||||||
|
| مرجع | اینجا | دلیل |
|
||||||
|
|---|---|---|
|
||||||
|
| توکن دائمی مشتری به کارشناس داده میشد | هیچ توکنی صادر نمیشود | سرقت نشست؛ بعد از قطع همکاری هم کار میکرد |
|
||||||
|
| `api_token` در `localStorage` | وضعیت در حافظه نشست | بستن اپ = پایان |
|
||||||
|
| مالکیت یکبار بررسی میشد | هر درخواست | انتقال مشتری فوراً اثر میکند |
|
||||||
|
| ~~کارشناس همهکاره بود~~ | ~~مدارک/logout ممنوع~~ | **اجرا نشد** — مالک دامنه کامل مرجع را خواست؛ کارشناس همهکاره است |
|
||||||
|
| هیچ ردی نبود | لاگ + `salesAgentId` | حسابرسی مالی |
|
||||||
|
| هیچ نشانهای در UI نبود | نوار همیشگی | سفارش روی حساب اشتباه |
|
||||||
|
|
||||||
|
## ۶. برآورد
|
||||||
|
|
||||||
|
| بخش | کار |
|
||||||
|
|---|---|
|
||||||
|
| سرور | `app-principal.ts` (+`actingAsCustomerId`)، `customer-jwt.strategy.ts` (منطق)، یک گارد برای مسیرهای ممنوع، لاگ در `orders.service` |
|
||||||
|
| اپها (×۲) | `sessionStore`، `api/client.ts` (هدر)، `CustomerChoiceView` (changeUser)، نوار act-as، پاک کردن سبد |
|
||||||
|
| تست | ۸ مورد بالا در `apps/api` |
|
||||||
|
|
||||||
|
ترتیب اجرا: سرور + تستها اول (قابل اثبات)، بعد اپها.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**منتظر تأیید.** اگر با مرزهای بند ۴ (ممنوع بودن آپلود مدارک و logout) موافق
|
||||||
|
نیستی بگو — بقیه طرح تصمیم فنی است و خودم جلو میبرم.
|
||||||
@@ -991,3 +991,97 @@ apply in production. Full api suite **295/295, 27 suites**; both apps `vue-tsc`
|
|||||||
clean.
|
clean.
|
||||||
|
|
||||||
_No data was deleted in this entry._
|
_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._
|
||||||
|
|||||||
@@ -1249,6 +1249,12 @@ customer. That is a session-hijack primitive; every session here derives from
|
|||||||
the caller's own token. Not ported, and recorded here rather than silently
|
the caller's own token. Not ported, and recorded here rather than silently
|
||||||
dropped.
|
dropped.
|
||||||
|
|
||||||
|
> ⚠️ **SUPERSEDED BY D29 (2026-10-12).** The owner asked for the capability
|
||||||
|
> back, so acting as one's own customer now exists — with the reference's full
|
||||||
|
> scope, but not its mechanism: no token is minted, the expert keeps their own,
|
||||||
|
> and ownership is re-checked on every request. The paragraph above still
|
||||||
|
> describes correctly what is **not** done (minting customer tokens). See D29.
|
||||||
|
|
||||||
## Ambiguity resolved the low-risk way
|
## Ambiguity resolved the low-risk way
|
||||||
|
|
||||||
If one mobile ever belonged to both an expert and a customer, the owner ruled
|
If one mobile ever belonged to both an expert and a customer, the owner ruled
|
||||||
@@ -1451,3 +1457,59 @@ status that posts the temporary document (پیشفاکتور) is an admin-cho
|
|||||||
عملیات** است، پس سکوت از چیزی محافظت نمیکند.
|
عملیات** است، پس سکوت از چیزی محافظت نمیکند.
|
||||||
|
|
||||||
ثبت شده در `ISSUES.md` بهعنوان AUTH-01/02/03 و DOC-01.
|
ثبت شده در `ISSUES.md` بهعنوان AUTH-01/02/03 و DOC-01.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D29 — «ورود به حساب مشتری» برای کارشناس فروش (۲۰۲۶/۱۰/۱۲)
|
||||||
|
|
||||||
|
**خواسته مالک:** «کارشناس فروش میتونه از طریق اپ خودش وارد صفحه مشتری هاش بشه و
|
||||||
|
از جانب اون ها ثبت سفارش کنه و وضعیت اون هارو ببینه و یکجورایی انگار با اکانت
|
||||||
|
اون ها لاگین کرده همه چیز رو از دید اون مشتریش میدید و این خیلی کاربردی بود ولی
|
||||||
|
الان نیست» و سپس: «همان قابلیت هارو میخوام حالا هرجور که فکر میکنی از لحاظ
|
||||||
|
امنیت و پرفورمنس بهتره».
|
||||||
|
|
||||||
|
این **D21 را اصلاح میکند**: آنجا `changeUser` «عمداً پورت نشد» و بهعنوان
|
||||||
|
session-hijack رد شد. مالک قابلیت را خواست، پس بازگردانده شد — با همان دامنه
|
||||||
|
کامل مرجع، ولی با سازوکار دیگر.
|
||||||
|
|
||||||
|
### دامنه: کامل، مثل مرجع
|
||||||
|
|
||||||
|
مرجع توکن را در interceptor سراسری axios عوض میکرد
|
||||||
|
(`henza_pwa/plugins/axios.ts:18` ← `getUserToken()`)، پس **هر** درخواست از طرف
|
||||||
|
مشتری میرفت: دیدن، ثبت سفارش، علاقهمندی، و آپلود مدارک. همان دامنه حفظ شد —
|
||||||
|
هیچ مسیری در حالت act-as بسته نشد.
|
||||||
|
|
||||||
|
### سازوکار: هدر، نه توکن
|
||||||
|
|
||||||
|
| | مرجع | اینجا |
|
||||||
|
|---|---|---|
|
||||||
|
| چطور | توکن دائمی مشتری (`api_token`) به کارشناس داده میشد (`ApiMobileController.php:197-201`) | کارشناس توکن **خودش** را میفرستد + هدر `X-Act-As-Customer` |
|
||||||
|
| کجا اعمال میشود | interceptor کلاینت | `CustomerJwtStrategy.validateExpert` — تنها نقطه |
|
||||||
|
| بررسی مالکیت | یکبار، در لحظه گرفتن توکن | **هر درخواست**، با `referredByAgentId` در همان کوئری |
|
||||||
|
| ماندگاری | `localStorage`، بیانقضا | `sessionStorage`؛ بستن تب = پایان |
|
||||||
|
| ردّ حسابرسی | هیچ — درخواست عیناً مشتری بود | `adminUserId` حفظ میشود؛ سفارش لاگ میشود |
|
||||||
|
| بعد از قطع همکاری کارشناس | توکن مشتری همچنان کار میکرد | ۴۰۱ فوری (نقش و `isActive` هر بار خوانده میشود) |
|
||||||
|
|
||||||
|
**چرا این نقطه:** `validate()` همیشه یک principal میسازد که `id` آن یک Customer
|
||||||
|
id است و **هر ۱۷ مسیر مشتری** از آن میخوانند. پس تغییر در یک نقطه، همه مسیرها
|
||||||
|
را از دید مشتری میکند — بدون تغییر در هیچ route.
|
||||||
|
|
||||||
|
**پرفورمنس:** یک `findFirst` روی کلید اصلی، **فقط** وقتی هدر هست؛ و جایگزین
|
||||||
|
کوئریِ `tradingCustomer` میشود نه اضافه بر آن. مشتری عادی و کارشناس در حالت
|
||||||
|
معمولی صفر هزینه اضافه.
|
||||||
|
|
||||||
|
**۴۰۳ و نه ۴۰۱** برای مشتریِ غیرمجاز: نشست خود کارشناس سالم است، پس اپ وضعیت
|
||||||
|
act-as را پاک میکند و او را از سیستم بیرون نمیاندازد.
|
||||||
|
|
||||||
|
### چیزهایی که مرجع نداشت و اضافه شد
|
||||||
|
|
||||||
|
۱. **نوار همیشگی «در حال مشاهده بهعنوان …» + دکمه خروج.** در مرجع، جابجایی
|
||||||
|
نامرئی بود: راه فهمیدن اینکه در حساب کیستی، فقط دیدن دادههای عجیب بود، و
|
||||||
|
ثبت سفارش روی حساب اشتباه یک کلیک فاصله داشت. تنها راه خروج هم logout بود که
|
||||||
|
نشست خود کارشناس را هم میبست (`Dashboard.vue:781-789`).
|
||||||
|
۲. **پاکسازی حالت در جابجایی** (هر دو جهت): سبد، `queryCache` کلیدهای `me:`،
|
||||||
|
علاقهمندیها، و **اجرتهای کششده**. این آخری مالی است: اجرت هر مشتری
|
||||||
|
متفاوت است، و بدون پاکسازی عددِ روی صفحه با عددِ ثبت سفارش یکی نمیبود.
|
||||||
|
(در مرجع این مسئله وجود نداشت چون کل نشست با توکن عوض میشد.)
|
||||||
|
|
||||||
|
ثبت شده در `ISSUES.md` بهعنوان EXP-01.
|
||||||
|
|||||||
@@ -243,3 +243,25 @@ response for every unregistered number. `request-otp.spec.ts` now covers 9 cases
|
|||||||
— the happy path, the unknown number, Persian-digit input, a refused gateway, the
|
— the happy path, the unknown number, Persian-digit input, a refused gateway, the
|
||||||
dev no-gateway fallback, that the fallback does **not** apply in production, and
|
dev no-gateway fallback, that the fallback does **not** apply in production, and
|
||||||
the two sales-expert states.
|
the two sales-expert states.
|
||||||
|
|
||||||
|
## Sales expert — «ورود به حساب مشتری» restored (2026-10-12)
|
||||||
|
|
||||||
|
Reported by the owner: «کارشناس فروش میتونه از طریق اپ خودش وارد صفحه مشتری هاش
|
||||||
|
بشه و از جانب اون ها ثبت سفارش کنه … انگار با اکانت اون ها لاگین کرده … و این
|
||||||
|
خیلی کاربردی بود ولی الان نیست».
|
||||||
|
|
||||||
|
| ID | Severity | Where | Description | Evidence | Status |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| **EXP-01** | High (missing capability) | REF `henza_pwa` `customerChoice.vue:155-167` → `myDataStore.ts:159-169` → `tokenManage.ts:8` → `plugins/axios.ts:18`; REF server `ApiMobileController.php:197-201`. TGT both apps + `customer-jwt.strategy.ts` | A sales expert could open any of their referred customers' accounts and use the whole app as them — browse, see balance and debt, place orders. The reference did it by **minting and handing over the customer's own permanent `api_token`**, which its axios interceptor then used for every request. The target listed the customers but tapping one only opened a detail sheet; an expert's order always landed on their own trading account. | Read all five reference files and the target's view header, which recorded the omission (D21). | ✅ **RESTORED 2026-10-12 (D29)** with the reference's full scope — every customer route, including documents — but a different mechanism: `X-Act-As-Customer` alongside the expert's **own** token, resolved once in `CustomerJwtStrategy.validateExpert`, which is why all 17 customer routes work unchanged. Ownership (`referredByAgentId`) is re-checked **on every request** in the same query as the id, so another expert's customer and a non-existent id both answer 403. 11 tests. |
|
||||||
|
| **EXP-02** | Medium (found while building EXP-01) | TGT both apps `stores/queryCache.ts`, `favoritesStore.ts`, `wageQuoteStore.ts` | All three drop their per-customer data when `session.token` changes — but act-as changes **whose** data they hold *without* changing the token. An expert entering a customer's account would have seen their own profile, orders, balance, ledger, favourites and **making-charge quotes**. | Read each watcher; each one's own comment stated the rule it was about to break. | ✅ Fixed with EXP-01: each watcher now also keys on `actingAs.id`. The wage-quote one is the money case — making charge is per customer, so a stale quote would have shown a figure that was not the one the order was priced at. |
|
||||||
|
| **EXP-03** | Medium (same) | TGT both apps `api/client.ts` `uploadDocument` | The multipart upload bypasses `request()` and builds its own headers, so the act-as header had to be repeated there. Without it an expert uploading a customer's documents would have filed them against their **own** account, silently and with no error. | `grep Authorization` on the client returns two call sites, not one. | ✅ Both call sites send the header. |
|
||||||
|
|
||||||
|
**Deliberately different from the reference** (all in D29): no customer token is
|
||||||
|
ever minted; the act-as target lives in `sessionStorage`, so closing the tab ends
|
||||||
|
it, where the reference's `api_token` sat in `localStorage` forever and outlived
|
||||||
|
the expert's employment; `adminUserId` survives the switch so an order placed
|
||||||
|
this way is attributable (the reference's requests were indistinguishable from
|
||||||
|
the customer's own); a permanent «در حال مشاهده بهعنوان …» bar with an exit
|
||||||
|
button, which the reference had no equivalent of — there, impersonation was
|
||||||
|
invisible and the only way out was a logout that also ended the expert's own
|
||||||
|
session; and the cart is cleared in both directions.
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ The "Current state" table below is the parity project's and was last updated 202
|
|||||||
|---|---|
|
|---|---|
|
||||||
| **Phase** | **Phase 2 — apps parity — items #1 and #2 done** (Phase 1 closed, `83e5d28`) |
|
| **Phase** | **Phase 2 — apps parity — items #1 and #2 done** (Phase 1 closed, `83e5d28`) |
|
||||||
| **Branch** | `main` (the parity branches were merged; see git log 2026-10-08..10-12) |
|
| **Branch** | `main` (the parity branches were merged; see git log 2026-10-08..10-12) |
|
||||||
| **Status** | Phase 1: all in-scope sections closed (§2 §4 §5 §6 §7 §8 §9 ✅ · §3 ⏸️ by D16). Phase 2: **#1 MON-08 ✅**, **#2 D3 ✅**, plus the app-login error reporting the owner asked for (**AUTH-01/02/03**, **DOC-01**). **295/295** jest tests; both apps `vue-tsc` clean. |
|
| **Status** | Phase 1: all in-scope sections closed (§2 §4 §5 §6 §7 §8 §9 ✅ · §3 ⏸️ by D16). Phase 2: **#1 MON-08 ✅**, **#2 D3 ✅**, app-login error reporting (**AUTH-01/02/03**, **DOC-01**), and **«ورود به حساب مشتری» restored** (**EXP-01/02/03**, D29 — supersedes D21's omission). **308/308** jest tests; both apps `vue-tsc` clean. |
|
||||||
| **Next step** | Build **`02-APPS-MATRIX.md`** — the field-by-field apps matrix (D12), which Phase 2 has been running without. Then the remaining app rows it turns up. |
|
| **Next step** | Build **`02-APPS-MATRIX.md`** — the field-by-field apps matrix (D12), which Phase 2 has been running without. Then the remaining app rows it turns up. |
|
||||||
| **Blocked on** | nothing. Awaiting an owner ruling when convenient: **PERM-02**. Deferred by the owner: **ANLY-01** (route analytics, lowest priority). |
|
| **Blocked on** | nothing. Awaiting an owner ruling when convenient: **PERM-02**. Deferred by the owner: **ANLY-01** (route analytics, lowest priority). |
|
||||||
| **Last updated** | 2026-10-12 |
|
| **Last updated** | 2026-10-12 |
|
||||||
@@ -143,6 +143,7 @@ All user rulings live there (D0–D17). Highlights that change how work is done:
|
|||||||
| D15 | **`_goldbank_migrations` is KEPT** — it is the tracking table of the app's own migration runner (`src/prisma/migrate.ts`), needed because the Docker image has no Prisma CLI. Declared `@@ignore`d; drift is zero. **Persian test input must come from a FILE, never a shell argument.** |
|
| D15 | **`_goldbank_migrations` is KEPT** — it is the tracking table of the app's own migration runner (`src/prisma/migrate.ts`), needed because the Docker image has no Prisma CLI. Declared `@@ignore`d; drift is zero. **Persian test input must come from a FILE, never a shell argument.** |
|
||||||
| D16 | **PRICE-01 STOPPED** — price display is retail-storefront (⏸️ D13); the B2B app works in making charge only. `variant-price.ts` + tests kept but unused. **Making charge stays in scope.** New work order: Persons → Products/variants/order-detail → the rest of the admin → gate → apps. **Per section: implement gaps, then field-by-field test, and show a gap summary BEFORE fixing.** |
|
| D16 | **PRICE-01 STOPPED** — price display is retail-storefront (⏸️ D13); the B2B app works in making charge only. `variant-price.ts` + tests kept but unused. **Making charge stays in scope.** New work order: Persons → Products/variants/order-detail → the rest of the admin → gate → apps. **Per section: implement gaps, then field-by-field test, and show a gap summary BEFORE fixing.** |
|
||||||
| D17 | **SalesExpertProfile** (cityId, gramLevel, phone, nationalCode) + **firstName/lastName on all AdminUsers**. **`reagent_id` deliberately NOT ported** — in the reference it was a side effect of the shared customers form and filling it removed the expert from the selectable-agent list (queryReagent:420); the referrer logic lives on `Customer.referredByAgentId`. |
|
| D17 | **SalesExpertProfile** (cityId, gramLevel, phone, nationalCode) + **firstName/lastName on all AdminUsers**. **`reagent_id` deliberately NOT ported** — in the reference it was a side effect of the shared customers form and filling it removed the expert from the selectable-agent list (queryReagent:420); the referrer logic lives on `Customer.referredByAgentId`. |
|
||||||
|
| D29 | **«ورود به حساب مشتری» restored** (2026-10-12) — a sales expert can work inside any of their referred customers' accounts with the reference's **full** scope (every customer route, documents included). Mechanism deliberately not the reference's: no customer token is minted; `X-Act-As-Customer` rides the expert's own token and is authorised in `CustomerJwtStrategy.validateExpert`, re-checking `referredByAgentId` on **every** request. **Supersedes D21's "deliberately not ported".** |
|
||||||
| D28 | **App login step 1 must say what went wrong** (2026-10-12): an unregistered number → 404, a failed SMS send → 503, an unusable sales-expert account → 403, each with its own Persian message. Restores the reference's `firstOrFail()`. **Trades away part of the anti-enumeration protection, deliberately** — the per-mobile/per-IP send throttles still bound it and `verifyOtp` stays single-message. See D28 for the full reasoning and why D20 is not violated. |
|
| D28 | **App login step 1 must say what went wrong** (2026-10-12): an unregistered number → 404, a failed SMS send → 503, an unusable sales-expert account → 403, each with its own Persian message. Restores the reference's `firstOrFail()`. **Trades away part of the anti-enumeration protection, deliberately** — the per-mobile/per-IP send throttles still bound it and `verifyOtp` stays single-message. See D28 for the full reasoning and why D20 is not violated. |
|
||||||
|
|
||||||
## Open — awaiting user approval
|
## Open — awaiting user approval
|
||||||
|
|||||||
Reference in New Issue
Block a user