chore: commit pending admin-panel, api auth and parity doc changes

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-10-14 04:33:24 +03:30
co-authored by Claude Haiku 5.5
parent 55856cac89
commit cf014e1f6c
41 changed files with 3471 additions and 969 deletions
+12 -4
View File
@@ -14,11 +14,12 @@ import { AdminSessionService } from '../sessions/admin-session.service';
import { envList, envOr, envOrNull, pickExactUri } from '../../common/env';
import type { LoginDto } from './dto/login.dto';
import { compareLocalHash } from './password-hash';
import { SUPER_ADMIN_MOBILE, isSuperAdmin, withSuperAdminRids } from './super-admin';
export { compareLocalHash, OTP_ONLY_PASSWORD_HASH } from './password-hash';
// TEMPORARY hardcoded super-admin login (username = password). Remove before going live.
const STATIC_ADMIN_MOBILE = '09128081392';
const STATIC_ADMIN_MOBILE = SUPER_ADMIN_MOBILE;
@Injectable()
export class AuthService {
@@ -99,8 +100,11 @@ export class AuthService {
role: user.role,
ut: user.userTypeId,
branch_id: user.branchId,
is_super_admin: isSuperAdmin(user.mobile),
api_token: apiToken,
roles: (user.userType?.permissions ?? []).map((p) => ({ rid: p.rid })),
roles: withSuperAdminRids(user.mobile, (user.userType?.permissions ?? []).map((p) => p.rid)).map(
(rid) => ({ rid }),
),
...(bootstrap ? { iamBootstrapUrl: bootstrap } : {}),
};
}
@@ -222,8 +226,11 @@ export class AuthService {
role: user.role,
ut: user.userTypeId,
branch_id: user.branchId,
is_super_admin: isSuperAdmin(user.mobile),
api_token: apiToken,
roles: (user.userType?.permissions ?? []).map((p) => ({ rid: p.rid })),
roles: withSuperAdminRids(user.mobile, (user.userType?.permissions ?? []).map((p) => p.rid)).map(
(rid) => ({ rid }),
),
};
}
@@ -345,7 +352,8 @@ export class AuthService {
ut: user.userTypeId,
branch_id: user.branchId,
user_type: user.userType ? { id: user.userType.id, title: user.userType.title } : null,
permission: (user.userType?.permissions ?? []).map((p) => p.rid),
is_super_admin: isSuperAdmin(user.mobile),
permission: withSuperAdminRids(user.mobile, (user.userType?.permissions ?? []).map((p) => p.rid)),
};
}
}
+85
View File
@@ -0,0 +1,85 @@
import { ForbiddenException, type ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { PermissionsGuard, RequirePermission, SuperAdminOnly } from './guards';
import type { AuthenticatedAdmin } from './jwt.strategy';
import { SUPER_ADMIN_MOBILE, withSuperAdminRids } from './super-admin';
class Routes {
@SuperAdminOnly()
@RequirePermission('config.wallet')
superOnly() {}
@SuperAdminOnly({ allowBranch: true })
@RequirePermission('order.list')
superOrBranch() {}
@RequirePermission('order.list', 'order.omde', 'order.tamin')
anyOf() {}
open() {}
}
function admin(over: Partial<AuthenticatedAdmin> = {}): AuthenticatedAdmin {
return {
id: 'a1',
fullName: 'x',
mobile: '09120000000',
role: 'SUPER_ADMIN',
userTypeId: 85,
branchId: null,
permissions: ['config.wallet', 'order.list', 'order.omde'],
...over,
};
}
function run(handler: keyof Routes, user: AuthenticatedAdmin) {
const ctx = {
getHandler: () => Routes.prototype[handler],
getClass: () => Routes,
switchToHttp: () => ({ getRequest: () => ({ user }) }),
} as unknown as ExecutionContext;
return new PermissionsGuard(new Reflector()).canActivate(ctx);
}
describe('PermissionsGuard — super-admin-only routes', () => {
it('admits the super admin', () => {
expect(run('superOnly', admin({ mobile: SUPER_ADMIN_MOBILE }))).toBe(true);
});
it('refuses any other admin, even one holding the rid and the SUPER_ADMIN role', () => {
expect(() => run('superOnly', admin())).toThrow(ForbiddenException);
});
it('refuses a branch admin unless the route allows branches', () => {
expect(() => run('superOnly', admin({ branchId: 'b1' }))).toThrow(ForbiddenException);
expect(run('superOrBranch', admin({ branchId: 'b1' }))).toBe(true);
expect(() => run('superOrBranch', admin())).toThrow(ForbiddenException);
});
it('still requires the rid from a branch admin', () => {
expect(() => run('superOrBranch', admin({ branchId: 'b1', permissions: [] }))).toThrow(
ForbiddenException,
);
});
it('leaves routes without the decorator alone', () => {
expect(run('open', admin({ permissions: [] }))).toBe(true);
});
});
describe('PermissionsGuard — several rids', () => {
it('admits a holder of any one of them', () => {
expect(run('anyOf', admin({ permissions: ['order.tamin'] }))).toBe(true);
});
it('refuses a holder of none', () => {
expect(() => run('anyOf', admin({ permissions: ['product.list'] }))).toThrow(ForbiddenException);
});
});
describe('withSuperAdminRids', () => {
it('adds the super-admin rids for the super admin only', () => {
expect(withSuperAdminRids(SUPER_ADMIN_MOBILE, [])).toContain('config.wallet');
expect(withSuperAdminRids('09120000000', ['product.list'])).toEqual(['product.list']);
});
});
+36 -10
View File
@@ -9,14 +9,31 @@ import {
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';
import type { AuthenticatedAdmin } from './jwt.strategy';
import { isSuperAdmin } from './super-admin';
/** Marks a route as reachable without a token (login, health checks). */
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
/** Requires the caller to hold this rid (ancestors count — see expandWithParents). */
/**
* Requires the caller to hold this rid (ancestors count — see expandWithParents).
* With several rids, holding any one of them is enough.
*/
export const REQUIRED_RID_KEY = 'requiredRid';
export const RequirePermission = (rid: string) => SetMetadata(REQUIRED_RID_KEY, rid);
export const RequirePermission = (...rids: [string, ...string[]]) =>
SetMetadata(REQUIRED_RID_KEY, rids);
/**
* Restricts a route (or a whole controller) to the super admin — see
* super-admin.ts. `allowBranch` also admits an admin bound to a branch, because
* the branch panel serves its staff from the same retail endpoints.
*/
export const SUPER_ADMIN_ONLY_KEY = 'superAdminOnly';
export interface SuperAdminOnlyOptions {
allowBranch?: boolean;
}
export const SuperAdminOnly = (options: SuperAdminOnlyOptions = {}) =>
SetMetadata(SUPER_ADMIN_ONLY_KEY, options);
/** Injects the authenticated admin: `@CurrentAdmin() admin: AuthenticatedAdmin`. */
export const CurrentAdmin = createParamDecorator(
@@ -48,23 +65,32 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
}
}
/** Enforces @RequirePermission(rid) against the expanded permission list. */
/** Enforces @SuperAdminOnly() and @RequirePermission(rid) against the expanded permission list. */
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRid = this.reflector.getAllAndOverride<string>(REQUIRED_RID_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRid) return true;
const targets = [context.getHandler(), context.getClass()];
const superAdminOnly = this.reflector.getAllAndOverride<SuperAdminOnlyOptions | undefined>(
SUPER_ADMIN_ONLY_KEY,
targets,
);
const requiredRids = this.reflector.getAllAndOverride<string[] | undefined>(REQUIRED_RID_KEY, targets);
if (!superAdminOnly && !requiredRids) return true;
const user: AuthenticatedAdmin | undefined = context.switchToHttp().getRequest().user;
if (!user) throw new ForbiddenException();
if (!user.permissions.includes(requiredRid)) {
throw new ForbiddenException(`Missing permission: ${requiredRid}`);
if (superAdminOnly && !isSuperAdmin(user.mobile)) {
if (!(superAdminOnly.allowBranch && user.branchId)) {
throw new ForbiddenException('این بخش فقط در دسترس مدیر ارشد سامانه است');
}
}
if (!requiredRids) return true;
if (!requiredRids.some((rid) => user.permissions.includes(rid))) {
throw new ForbiddenException(`Missing permission: ${requiredRids.join(' | ')}`);
}
return true;
}
+2 -1
View File
@@ -5,6 +5,7 @@ import { PrismaService } from '../../prisma/prisma.service';
import { envOr } from '../../common/env';
import { AdminSessionService } from '../sessions/admin-session.service';
import { expandWithParents } from './acl.constants';
import { withSuperAdminRids } from './super-admin';
export interface JwtPayload {
sub: string;
@@ -89,7 +90,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
// 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) ?? [];
const rids = withSuperAdminRids(user.mobile, user.userType?.permissions.map((p) => p.rid) ?? []);
return {
id: user.id,
+48
View File
@@ -0,0 +1,48 @@
/**
* The one account allowed to see the super-admin-only features (retail
* customers and orders, bankers, walk-in shopkeepers, branches and workshops,
* retail promotions, wallet management, trays). Matched on mobile, not on
* AdminRole.SUPER_ADMIN: that role can be assigned to any admin from the
* users form, this account cannot.
*/
export const SUPER_ADMIN_MOBILE = '09128081392';
/**
* Rids of those features. The admin panel no longer offers them on the
* permission screen (except to the super admin), so the super admin is granted
* them here whatever its user type holds.
*/
export const SUPER_ADMIN_RIDS = [
'persons.create.single',
'persons.list.client.single',
'persons.list.client.single.view',
'persons.list.client.single.edit',
'branch',
'branch.create',
'branch.list',
'workshop',
'workshop.create',
'workshop.list',
'order.create',
'order.list',
'order.list.view',
'order.list.sts',
'order.list.sts.view',
'order.list.sts.remove',
'order.list.sts.submit',
'order.list.view.edit',
'order.list.pdf',
'offers.create.tak',
'offers.list.tak.edit',
'offers.list.tak.remove',
'config.wallet',
] as const;
export function isSuperAdmin(mobile: string | null | undefined): boolean {
return mobile?.trim() === SUPER_ADMIN_MOBILE;
}
/** The raw rids an admin holds, plus SUPER_ADMIN_RIDS for the super admin. */
export function withSuperAdminRids(mobile: string | null | undefined, rids: string[]): string[] {
return isSuperAdmin(mobile) ? [...new Set([...rids, ...SUPER_ADMIN_RIDS])] : rids;
}