feat(api): sales-expert login to the customer apps (D21)
A sales expert can now sign into the apps with their AdminUser account, without reversing D17 — the expert remains an AdminUser and is never turned back into a Customer. The app token carries a `typ` of 'customer' or 'sales-expert'; the strategy resolves both to one principal whose `id` is always a Customer id, so every route already written for a customer works unchanged for an expert. That is what the reference gave them, where an agent was a customer with extra screens. Each expert gets ONE linked trading Customer row (additive migration, dry-run clean and rolled back before applying) created at first sign-in, seeded from the two numbers the reference read off the agent's own row: SalesExpertProfile .gramLevel -> goldCreditLimitGrams and AdminUser.wagePercent -> makingChargeOffsetPercent. Orders the expert places land there, exactly as the reference's single row did. New expert-only routes scope on the AdminUser id from the token: POST expert/child-orders (legacy /order/child, ApiMobileController:759, keeping its sts=5 filter as DELIVERED_TO_AGENT), POST expert/my-customers (legacy /my-child, :184) and GET expert/whoami so the apps can hide the entries. Security: jwt.strategy now rejects any token carrying an app `typ`. An expert's app token holds their AdminUser id in `sub`, and JWT_CUSTOMER_SECRET falls back to JWT_SECRET when unset — in that configuration the token would otherwise have validated as an admin with full panel permissions. NOT ported: myChild minted an api_token per listed customer (:197-201), letting an agent act AS that customer. Recorded in D21 rather than silently dropped. Tests: new test-sales-expert-app-login.cjs 36/36 — expert sign-in, trading account seeding, own-customers-only (with both negatives), child orders, the expert ordering for themselves, an ordinary customer refused both screens with their own app unaffected, and all four token boundaries including immediate lockout on deactivation. jest 45/45, admin smoke 43/43, apps smoke 43/43, B2B scan 34/34, B2B writes 25/25, auth-smoke 32/32. [ref: henza_server/app/Http/Controllers/Api/ApiMobileController.php:184-195,534-535,759-781] Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
53436ec855
commit
69f4ba2b29
@@ -1,32 +1,43 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { AdminRole } from '@prisma/client';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { customerJwtSecret } from './customer-auth.service';
|
||||
import {
|
||||
CUSTOMER_TOKEN_TYPE,
|
||||
EXPERT_TOKEN_TYPE,
|
||||
type AppJwtPayload,
|
||||
type AuthenticatedCustomer,
|
||||
} from './app-principal';
|
||||
|
||||
export {
|
||||
CUSTOMER_TOKEN_TYPE,
|
||||
EXPERT_TOKEN_TYPE,
|
||||
type AuthenticatedCustomer,
|
||||
} from './app-principal';
|
||||
|
||||
/** Kept for the existing imports; the payload now covers both principals. */
|
||||
export type CustomerJwtPayload = AppJwtPayload;
|
||||
|
||||
/**
|
||||
* Marks a token as a customer token. Checked on every customer route, so an
|
||||
* admin token — even one signed with the same secret by misconfiguration —
|
||||
* cannot be used to read customer data (T1).
|
||||
*/
|
||||
export const CUSTOMER_TOKEN_TYPE = 'customer' as const;
|
||||
|
||||
export interface CustomerJwtPayload {
|
||||
sub: string;
|
||||
mobile: string;
|
||||
typ: typeof CUSTOMER_TOKEN_TYPE;
|
||||
}
|
||||
|
||||
/** What `@CurrentCustomer()` hands a handler. */
|
||||
export interface AuthenticatedCustomer {
|
||||
id: string;
|
||||
mobile: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The customer principal, kept entirely separate from JwtStrategy (which reads
|
||||
* AdminUser). The reference had the same split: `auth:api` for admins and
|
||||
* `auth('api-customer')` for the app.
|
||||
* The app principal, kept entirely separate from JwtStrategy (which reads
|
||||
* AdminUser for the admin PANEL). The reference had the same split: `auth:api`
|
||||
* for admins and `auth('api-customer')` for the app.
|
||||
*
|
||||
* This strategy accepts TWO token types and resolves both to one shape:
|
||||
*
|
||||
* typ 'customer' -> the Customer row named by `sub`
|
||||
* typ 'sales-expert' -> the AdminUser named by `sub`, plus their trading
|
||||
* Customer row, which becomes `id`
|
||||
*
|
||||
* Because `id` is always a Customer id, every route already written for a
|
||||
* customer works unchanged for an expert — which is exactly the reference's
|
||||
* behaviour, where an agent was a customer with extra screens.
|
||||
*
|
||||
* An expert's app token is NOT an admin-panel token: its `typ` is
|
||||
* 'sales-expert', while JwtStrategy requires the admin type, so it is refused
|
||||
* on every admin route.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CustomerJwtStrategy extends PassportStrategy(Strategy, 'customer-jwt') {
|
||||
@@ -38,9 +49,13 @@ export class CustomerJwtStrategy extends PassportStrategy(Strategy, 'customer-jw
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: CustomerJwtPayload): Promise<AuthenticatedCustomer> {
|
||||
// A token that is not explicitly a customer token is refused, whatever else
|
||||
// it carries.
|
||||
async validate(payload: AppJwtPayload): Promise<AuthenticatedCustomer> {
|
||||
if (payload?.typ === EXPERT_TOKEN_TYPE) {
|
||||
return this.validateExpert(payload);
|
||||
}
|
||||
|
||||
// A token that is not explicitly a customer token is refused, whatever
|
||||
// else it carries.
|
||||
if (payload?.typ !== CUSTOMER_TOKEN_TYPE) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
@@ -56,6 +71,51 @@ export class CustomerJwtStrategy extends PassportStrategy(Strategy, 'customer-jw
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
return { id: customer.id, mobile: customer.mobile };
|
||||
return { id: customer.id, mobile: customer.mobile, type: CUSTOMER_TOKEN_TYPE };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* locks them out immediately rather than at token expiry.
|
||||
*
|
||||
* The trading Customer row is resolved here rather than stored in the token,
|
||||
* so a token issued before that row existed keeps working.
|
||||
*/
|
||||
private async validateExpert(payload: AppJwtPayload): Promise<AuthenticatedCustomer> {
|
||||
const admin = await this.prisma.adminUser.findUnique({
|
||||
where: { id: payload.sub },
|
||||
select: {
|
||||
id: true,
|
||||
mobile: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
salesExpertProfile: {
|
||||
select: {
|
||||
tradingCustomerId: true,
|
||||
tradingCustomer: { select: { id: true, isActive: true, isArchived: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!admin || !admin.isActive || admin.role !== AdminRole.SALES_EXPERT) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
const trading = admin.salesExpertProfile?.tradingCustomer;
|
||||
if (!trading || !trading.isActive || trading.isArchived) {
|
||||
// The account is created at sign-in; if it is missing or has been
|
||||
// deactivated, the session cannot act. Refused rather than silently
|
||||
// degraded, so an expert never places an order against nothing.
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
return {
|
||||
id: trading.id,
|
||||
mobile: admin.mobile,
|
||||
type: EXPERT_TOKEN_TYPE,
|
||||
adminUserId: admin.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user