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>
86 lines
2.8 KiB
TypeScript
86 lines
2.8 KiB
TypeScript
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
|
import { PassportStrategy } from '@nestjs/passport';
|
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
|
import { PrismaService } from '../../prisma/prisma.service';
|
|
import { expandWithParents } from './acl.constants';
|
|
|
|
export interface JwtPayload {
|
|
sub: string;
|
|
mobile: string;
|
|
/**
|
|
* Set on APP tokens only ('customer' or 'sales-expert'). An admin-panel
|
|
* token has no `typ`. Checked in validate() below — see the note there.
|
|
*/
|
|
typ?: string;
|
|
}
|
|
|
|
/**
|
|
* App token types, which must NEVER authenticate an admin-panel route.
|
|
*
|
|
* This matters because a sales expert's app token carries their AdminUser id
|
|
* in `sub` (D21). The two token families are signed with different secrets, so
|
|
* normally the signature alone stops it — but `JWT_CUSTOMER_SECRET` falls back
|
|
* to `JWT_SECRET` when unset, and in that configuration an expert's app token
|
|
* WOULD verify here and be handed full admin-panel permissions. This check
|
|
* closes that regardless of how the secrets are configured.
|
|
*/
|
|
const APP_TOKEN_TYPES = new Set(['customer', 'sales-expert']);
|
|
|
|
/**
|
|
* The authenticated principal attached to `req.user`.
|
|
*
|
|
* `permissions` is already expanded with ancestors, so guards can test a rid with
|
|
* a plain `includes()` — the same shape the legacy frontend held in its Vuex store
|
|
* after checkParent().
|
|
*/
|
|
export interface AuthenticatedAdmin {
|
|
id: string;
|
|
fullName: string;
|
|
mobile: string;
|
|
role: string;
|
|
userTypeId: number | null;
|
|
branchId: string | null;
|
|
permissions: string[];
|
|
}
|
|
|
|
@Injectable()
|
|
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|
constructor(private readonly prisma: PrismaService) {
|
|
super({
|
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
|
ignoreExpiration: false,
|
|
secretOrKey: process.env.JWT_SECRET ?? 'dev-only-insecure-secret',
|
|
});
|
|
}
|
|
|
|
async validate(payload: JwtPayload): Promise<AuthenticatedAdmin> {
|
|
// An app token is never an admin-panel token, whatever it is signed with.
|
|
if (payload?.typ && APP_TOKEN_TYPES.has(payload.typ)) {
|
|
throw new UnauthorizedException();
|
|
}
|
|
|
|
const user = await this.prisma.adminUser.findUnique({
|
|
where: { id: payload.sub },
|
|
include: { userType: { include: { permissions: true } } },
|
|
});
|
|
|
|
if (!user || !user.isActive) {
|
|
throw new UnauthorizedException();
|
|
}
|
|
|
|
// Permissions are re-read on every request rather than baked into the token,
|
|
// so a permission change takes effect immediately instead of at next login.
|
|
const rids = user.userType?.permissions.map((p) => p.rid) ?? [];
|
|
|
|
return {
|
|
id: user.id,
|
|
fullName: user.fullName,
|
|
mobile: user.mobile,
|
|
role: user.role,
|
|
userTypeId: user.userTypeId,
|
|
branchId: user.branchId,
|
|
permissions: expandWithParents(rids),
|
|
};
|
|
}
|
|
}
|