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
@@ -1,7 +1,7 @@
import { Body, Controller, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
import { AdminBranchesService } from './admin-branches.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { RequirePermission } from '../auth/guards';
import { RequirePermission, SuperAdminOnly } from '../auth/guards';
/**
* Legacy endpoints (BranchController + WorkshopController):
@@ -12,6 +12,7 @@ import { RequirePermission } from '../auth/guards';
* GET user/list -> the branch-manager picker
*/
@Controller('admin/branches')
@SuperAdminOnly()
export class AdminBranchesController {
constructor(private readonly branches: AdminBranchesService) {}
@@ -1,7 +1,7 @@
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put, Query } from '@nestjs/common';
import { AdminCategoriesService } from './admin-categories.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { RequirePermission } from '../auth/guards';
import { RequirePermission, SuperAdminOnly } from '../auth/guards';
/**
* Legacy endpoints (CategoryController + the collection screens):
@@ -37,6 +37,7 @@ export class AdminCategoriesController {
// ── collections ("سینی") ─────────────────────────────────────────
@Post('collections/list')
@HttpCode(200)
@SuperAdminOnly()
@RequirePermission('collection.list')
listCollections(@Body() query: TableQueryDto) {
return this.categories.listCollections(query);
@@ -44,12 +45,14 @@ export class AdminCategoriesController {
@Post('collections')
@HttpCode(200)
@SuperAdminOnly()
@RequirePermission('collection.create')
createCollection(@Body() body: { title: string; description?: string; is_active?: boolean }) {
return this.categories.createCollection(body);
}
@Put('collections/:id')
@SuperAdminOnly()
@RequirePermission('collection.list.edit')
updateCollection(
@Param('id') id: string,
@@ -59,6 +62,7 @@ export class AdminCategoriesController {
}
@Delete('collections/:id')
@SuperAdminOnly()
@RequirePermission('collection.list.edit')
removeCollection(@Param('id') id: string) {
return this.categories.removeCollection(id);
@@ -1,7 +1,7 @@
import { Body, Controller, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
import { AdminCustomersService, type OmdeScope } from './admin-customers.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { CurrentAdmin, RequirePermission } from '../auth/guards';
import { CurrentAdmin, RequirePermission, SuperAdminOnly } from '../auth/guards';
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
/**
@@ -31,6 +31,7 @@ export class AdminCustomersController {
@Post('banker/list')
@HttpCode(200)
@SuperAdminOnly()
@RequirePermission('persons.list.client.omde')
listBanker(@Body() query: TableQueryDto) {
return this.list(query, 'banker');
@@ -38,6 +39,7 @@ export class AdminCustomersController {
@Post('shopkeeper/list')
@HttpCode(200)
@SuperAdminOnly()
@RequirePermission('persons.list.client.omde')
listShopkeeper(@Body() query: TableQueryDto) {
return this.list(query, 'shopkeeper');
@@ -60,6 +62,7 @@ export class AdminCustomersController {
/** Legacy POST single-customer — the retail customer list. */
@Post('single/list')
@HttpCode(200)
@SuperAdminOnly({ allowBranch: true })
@RequirePermission('persons.list.client.single')
listSingle(@Body() query: TableQueryDto, @CurrentAdmin() admin: AuthenticatedAdmin) {
return this.customers.listSingle(query, admin.branchId ?? undefined);
@@ -68,6 +71,7 @@ export class AdminCustomersController {
/** Legacy `POST single` — singleCustomer.vue, the retail customer form. */
@Post('single')
@HttpCode(200)
@SuperAdminOnly({ allowBranch: true })
@RequirePermission('persons.create')
createSingle(@Body() data: Record<string, unknown>) {
return this.customers.createSingle(data);
@@ -1,7 +1,7 @@
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
import { AdminDocumentsService } from './admin-documents.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { CurrentAdmin, RequirePermission } from '../auth/guards';
import { CurrentAdmin, RequirePermission, SuperAdminOnly } from '../auth/guards';
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
/**
@@ -46,6 +46,7 @@ export class AdminDocumentsController {
// ── wallet settings (views/manage/wallet.vue) ────────────────────
@Get('wallet/config')
@SuperAdminOnly()
@RequirePermission('config.wallet')
getWalletConfig() {
return this.documents.getSetting('wallet.config');
@@ -53,12 +54,14 @@ export class AdminDocumentsController {
@Post('wallet/config')
@HttpCode(200)
@SuperAdminOnly()
@RequirePermission('config.wallet')
setWalletConfig(@Body() body: Record<string, unknown>) {
return this.documents.setSetting('wallet.config', body);
}
@Get('wallet/birthday/config')
@SuperAdminOnly()
@RequirePermission('config.wallet')
getBirthdayConfig() {
return this.documents.getSetting('wallet.birthday');
@@ -66,6 +69,7 @@ export class AdminDocumentsController {
@Post('wallet/birthday/config')
@HttpCode(200)
@SuperAdminOnly()
@RequirePermission('config.wallet')
setBirthdayConfig(@Body() body: Record<string, unknown>) {
return this.documents.setSetting('wallet.birthday', body);
@@ -2,7 +2,7 @@ import { Body, Controller, Get, HttpCode, Param, Post } from '@nestjs/common';
import { AdminOrdersService } from './admin-orders.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { UpdateOrderStatusDto } from './update-status.dto';
import { CurrentAdmin, RequirePermission } from '../auth/guards';
import { CurrentAdmin, RequirePermission, SuperAdminOnly } from '../auth/guards';
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
/**
@@ -20,6 +20,7 @@ export class AdminOrdersController {
/** Legacy POST single-order. POST, not GET, because XTable posts its filters. */
@Post('list')
@HttpCode(200)
@SuperAdminOnly({ allowBranch: true })
@RequirePermission('order.list')
list(@Body() query: TableQueryDto, @CurrentAdmin() admin: AuthenticatedAdmin) {
/**
@@ -66,6 +67,7 @@ export class AdminOrdersController {
/** Legacy POST /order from the admin order builder (views/order/create.vue). */
@Post('create')
@HttpCode(200)
@SuperAdminOnly({ allowBranch: true })
@RequirePermission('order.create')
createFromAdmin(
@Body()
@@ -82,13 +84,17 @@ export class AdminOrdersController {
/** Legacy GET order/type. */
@Get('type-options')
@SuperAdminOnly()
@RequirePermission('order.create')
typeOptions() {
return this.orders.typeOptions();
}
// The three routes below also serve the wholesale and sourcing lists, whose
// staff hold order.omde / order.tamin rather than the super-admin-only
// order.list.
@Get('status-options')
@RequirePermission('order.list')
@RequirePermission('order.list', 'order.omde', 'order.tamin')
statusOptions() {
return this.orders.statusOptions();
}
@@ -96,13 +102,13 @@ export class AdminOrdersController {
/** Legacy POST single/order-sts/edit. */
@Post('status')
@HttpCode(200)
@RequirePermission('order.list')
@RequirePermission('order.list', 'order.omde', 'order.tamin')
setStatus(@Body() dto: UpdateOrderStatusDto, @CurrentAdmin() admin: AuthenticatedAdmin) {
return this.orders.setStatus(dto, admin.id);
}
@Get(':id')
@RequirePermission('order.list')
@RequirePermission('order.list', 'order.omde', 'order.tamin')
findOne(@Param('id') id: string) {
return this.orders.findOne(id);
}
@@ -1,7 +1,9 @@
import { Body, Controller, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
import { AdminProductsService } from './admin-products.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { RequirePermission } from '../auth/guards';
import { CurrentAdmin, RequirePermission, SuperAdminOnly } from '../auth/guards';
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
import { isSuperAdmin } from '../auth/super-admin';
/**
* Legacy endpoints (ProductController):
@@ -51,6 +53,7 @@ export class AdminProductsController {
*/
@Post('variants/create')
@HttpCode(200)
@SuperAdminOnly()
@RequirePermission('order.create')
createVariant(@Body() body: { product_id: string; title: string; grams: number }) {
return this.products.createVariant(body);
@@ -78,6 +81,7 @@ export class AdminProductsController {
@Get('collections')
@SuperAdminOnly()
@RequirePermission('product.list')
collections() {
return this.products.collections();
@@ -100,7 +104,9 @@ export class AdminProductsController {
@Post('create')
@HttpCode(200)
@RequirePermission('product.create')
create(@Body() data: Record<string, unknown>) {
create(@Body() data: Record<string, unknown>, @CurrentAdmin() admin: AuthenticatedAdmin) {
// Trays are super-admin-only; anyone else's product is created without them.
if (!isSuperAdmin(admin.mobile)) delete data.ciny_ids;
return this.products.create(data);
}
@@ -1,7 +1,7 @@
import { Body, Controller, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
import { AdminBranchesService } from '../admin-branches/admin-branches.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { RequirePermission } from '../auth/guards';
import { RequirePermission, SuperAdminOnly } from '../auth/guards';
/**
* Workshops — legacy views/workshop/*, served by WorkshopController:
@@ -14,6 +14,9 @@ import { RequirePermission } from '../auth/guards';
* screen's workshop picker); the CRUD arrived with Batch 11. Both live here so
* there is one owner of /admin/workshops, and the logic sits in
* AdminBranchesService alongside the branch code it mirrors.
*
* Managing workshops is super-admin-only; the list stays open because the
* made-to-order screen picks a workshop from it.
*/
@Controller('admin/workshops')
export class AdminWorkshopsController {
@@ -27,24 +30,28 @@ export class AdminWorkshopsController {
@Post()
@HttpCode(200)
@SuperAdminOnly()
@RequirePermission('workshop.create')
create(@Body() data: Record<string, unknown>) {
return this.branches.createWorkshop(data);
}
@Get('active/:id')
@SuperAdminOnly()
@RequirePermission('workshop.list')
toggleActive(@Param('id') id: string) {
return this.branches.toggleWorkshopActive(id);
}
@Get(':id')
@SuperAdminOnly()
@RequirePermission('workshop.list')
findOne(@Param('id') id: string) {
return this.branches.findWorkshop(id);
}
@Put(':id')
@SuperAdminOnly()
@RequirePermission('workshop.list')
update(@Param('id') id: string, @Body() data: Record<string, unknown>) {
return this.branches.updateWorkshop(id, data);
+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;
}