feat: complete admin panel and backend admin api migration

This commit is contained in:
s.esmaeili
2026-09-13 21:02:25 +03:30
parent 03119cd655
commit 267fc7b7d1
239 changed files with 25431 additions and 935 deletions
+204
View File
@@ -0,0 +1,204 @@
/**
* Every permission id (`rid`) the admin panel actually enforces.
*
* Regenerated mechanically from legacy (see scratchpad/convert_tree.js) as the
* union of two enforcement points:
* - 58 gated by a route's `meta.role` (router/admin.js, router/branch.js)
* - 100 gated inside views via `$hasPermission('...')`
* = 144 distinct rids (the two sets overlap)
*
* An earlier revision of this file listed only the 58 route-gated ones, which
* left the seeded super-admin unable to use the in-view actions. Corrected.
*
* These are LEAVES as written. Ancestors ("order" from "order.list", "persons"
* from "persons.list.client.omde", ...) are NOT stored — they are derived at
* runtime by expandWithParents(), exactly as legacy's checkParent() did.
*/
export const ALL_RIDS = [
'branch.create',
'branch.list',
'buy.order.list',
'cities.create',
'cities.list',
'cities.list.remove',
'cities.list.view',
'cities.list.view.edit',
'collection.create',
'collection.list',
'collection.list.edit',
'collection.list.remove',
'comment',
'comment.remove',
'comment.state',
'comment.view',
'comment.view.edit',
'config.report',
'config.statics',
'config.statics.brand',
'config.statics.brand.create',
'config.statics.brand.edit',
'config.statics.brand.remove',
'config.statics.tags',
'config.statics.tags.create',
'config.statics.tags.edit',
'config.statics.tags.remove',
'config.wallet',
'custom.config.statics.slider',
'custom.config.statics.tags.banner.ids',
'dashboard.omde',
'dashboard.tak',
'document.create',
'document.list',
'document.list.confirm',
'document.list.deny',
'document.list.edit',
'document.list.get',
'document.list.remove',
'invoice.create',
'invoice.list',
'newsletters',
'notify.create',
'notify.me',
'offers.autopromotion',
'offers.create',
'offers.create.tak',
'offers.list',
'offers.list.edit',
'offers.list.remove',
'offers.list.tak.edit',
'offers.list.tak.remove',
'offersIgnore',
'order.create',
'order.list',
'order.list.pdf',
'order.list.sts',
'order.list.sts.remove',
'order.list.sts.submit',
'order.list.view',
'order.list.view.edit',
'order.makeOrder',
'order.makeOrder.get',
'order.makeOrder.remove',
'order.makeOrder.state',
'order.makeOrder.state.remove',
'order.makeOrder.state.submit',
'order.makeOrder.view',
'order.makeOrder.view.edit',
'order.omde',
'order.omde.get',
'order.omde.remove',
'order.omde.state',
'order.omde.state.remove',
'order.omde.state.submit',
'order.omde.view',
'order.omde.view.edit',
'order.omde.view.remove',
'order.tamin',
'order.tamin.state',
'order.tamin.state.remove',
'order.tamin.state.submit',
'order.tamin.view',
'order.tamin.view.edit',
'persons.create',
'persons.create.client',
'persons.create.customer.tak',
'persons.create.seller',
'persons.list.client.omde',
'persons.list.client.omde.able',
'persons.list.client.omde.able.order',
'persons.list.client.omde.edit',
'persons.list.client.omde.unable',
'persons.list.client.omde.unable.order',
'persons.list.client.omde.view',
'persons.list.client.omde.view.documents',
'persons.list.client.omde.view.orders',
'persons.list.client.single',
'persons.list.client.single.edit',
'persons.list.client.single.view',
'persons.list.sellers',
'persons.list.sellers.able',
'persons.list.sellers.able.order',
'persons.list.sellers.edit',
'persons.list.sellers.unable',
'persons.list.sellers.unable.order',
'persons.list.sellers.view',
'persons.list.sellers.view.documents',
'persons.list.sellers.view.orders',
'product.create',
'product.list',
'product.list.edit',
'product.list.group.edit',
'product.list.remove',
'product.list.variants',
'product.list.variants.add',
'product.list.variants.edit',
'product.list.variants.remove',
'product.list.view',
'product.percent',
'report.order.item',
'report.product',
'sms.create',
'sms.create.all',
'sms.create.multiple',
'sms.create.omde',
'sms.create.reagent',
'sms.create.single',
'sms.list',
'statics',
'user.create',
'user.list',
'user.list.create',
'user.list.edit',
'user.list.remove',
'user.permission',
'user.permission.create',
'user.permission.edit',
'wallet',
'wallet.charge',
'wallet.list',
'wallet.percent',
'workshop.create',
'workshop.list',
] as const;
export type Rid = (typeof ALL_RIDS)[number];
/**
* Port of henza_admin/src/store/users/index.js getParents() — returns every
* ancestor of a dotted rid, longest first.
*
* getParents(['a','b','c']) -> ['a.b', 'a']
*/
function getParents(keys: string[]): string[] {
const work = [...keys];
const parents: string[] = [];
while (work.length > 1) {
work.pop();
parents.push(work.join('.'));
}
return parents;
}
/**
* Port of henza_admin/src/store/users/index.js checkParent().
*
* Granting "persons.list.client.omde" implicitly grants "persons.list.client",
* "persons.list" and "persons", so parent menu nodes stay visible. The legacy
* version mutated a module-scoped `rid` variable; this one is pure, which changes
* nothing observable but makes it safe to call concurrently on the server.
*
* Note this only walks UP. A rid never implies its children — which is why the
* legacy nav item guarded by "wallet.list" stays hidden for a user granted only
* "wallet". That mismatch is a legacy bug, recorded in docs/ADMIN-PORT-PLAN.md,
* and is preserved here rather than silently repaired.
*/
export function expandWithParents(values: string[]): string[] {
let rid: string[] = [...values];
for (const x of values) {
const keys = x.split('.');
if (keys.length > 1) {
rid = [...new Set([...rid, ...getParents(keys)])];
}
}
return rid;
}
@@ -0,0 +1,31 @@
import { Body, Controller, Get, HttpCode, Post } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { CurrentAdmin, Public } from './guards';
import type { AuthenticatedAdmin } from './jwt.strategy';
/**
* Legacy endpoints (henza_server routes/admin.php):
* POST admin-login -> AdminController::login
* GET users/show -> UserController::showData
*
* Exposed here under the clean /api/admin/auth prefix; the admin panel's mapping
* layer translates the legacy paths onto these.
*/
@Controller('admin/auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Public()
@Post('login')
@HttpCode(200)
login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
/** Legacy `users/show` — the router guard's permission refresh. */
@Get('me')
me(@CurrentAdmin() admin: AuthenticatedAdmin) {
return this.authService.showData(admin.id);
}
}
+34
View File
@@ -0,0 +1,34 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { PrismaModule } from '../../prisma/prisma.module';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './jwt.strategy';
import { JwtAuthGuard, PermissionsGuard } from './guards';
@Module({
imports: [
PrismaModule,
PassportModule,
JwtModule.register({
secret: process.env.JWT_SECRET ?? 'dev-only-insecure-secret',
// jsonwebtoken types expiresIn as a template-literal union, which a plain
// `string` from process.env does not satisfy; the cast is the documented escape.
signOptions: {
expiresIn: (process.env.JWT_EXPIRES_IN ?? '12h') as `${number}h`,
},
}),
],
controllers: [AuthController],
providers: [
AuthService,
JwtStrategy,
// Global: authenticate first, then check the rid. Routes opt out with @Public().
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: PermissionsGuard },
],
exports: [AuthService],
})
export class AuthModule {}
+89
View File
@@ -0,0 +1,89 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../../prisma/prisma.service';
import type { LoginDto } from './dto/login.dto';
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
) {}
/**
* Port of legacy AdminController::login (henza_server).
*
* Legacy issued a random 60-char string as `api_token` and persisted it on the
* user row; this issues a signed JWT instead. The response keeps the legacy
* shape the admin panel's auth store reads:
* - `api_token` -> stored in localStorage under token/branch_token
* - `roles` -> [{ rid }], mapped to rids by the frontend then expanded
* through checkParent()
*
* Deliberate deviation from legacy: a bad phone and a bad password both return
* 401 here. Legacy returned 404 (firstOrFail) for an unknown phone and 422 for
* a wrong password, which let anyone enumerate valid admin phone numbers.
*/
async login(dto: LoginDto) {
const user = await this.prisma.adminUser.findUnique({
where: { mobile: dto.phone },
include: { userType: { include: { permissions: true } } },
});
if (!user || !user.isActive) {
throw new UnauthorizedException('نام کاربری یا رمز عبور اشتباه است');
}
const passwordOk = await bcrypt.compare(dto.password, user.passwordHash);
if (!passwordOk) {
throw new UnauthorizedException('نام کاربری یا رمز عبور اشتباه است');
}
const apiToken = await this.jwt.signAsync({ sub: user.id, mobile: user.mobile });
return {
id: user.id,
name: user.fullName,
fullName: user.fullName,
mobile: user.mobile,
role: user.role,
ut: user.userTypeId,
branch_id: user.branchId,
api_token: apiToken,
roles: (user.userType?.permissions ?? []).map((p) => ({ rid: p.rid })),
};
}
/**
* Port of legacy UserController::showData, which the router guard calls on every
* cold load to refresh the permission list:
*
* $user['permission'] = Permission::where('ut',$user->ut)->select('rid')->get()->pluck('rid');
*
* Returns raw (unexpanded) rids — the frontend runs checkParent() on them, as in
* legacy. The guard's own checks use the pre-expanded list from JwtStrategy.
*/
async showData(adminId: string) {
const user = await this.prisma.adminUser.findUnique({
where: { id: adminId },
include: { userType: { include: { permissions: true } } },
});
if (!user) {
throw new UnauthorizedException();
}
return {
id: user.id,
name: user.fullName,
fullName: user.fullName,
mobile: user.mobile,
role: user.role,
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),
};
}
}
@@ -0,0 +1,18 @@
import { IsNotEmpty, IsString } from 'class-validator';
/**
* Mirrors the legacy contract in AdminController::login:
* $this->validate($request, ['phone' => 'required', 'password' => 'required']);
*
* The field is named `phone` (not `mobile`) even though it is matched against
* `users.mobile` — kept as-is so the ported login form posts an unchanged body.
*/
export class LoginDto {
@IsString()
@IsNotEmpty()
phone!: string;
@IsString()
@IsNotEmpty()
password!: string;
}
+71
View File
@@ -0,0 +1,71 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
SetMetadata,
createParamDecorator,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';
import type { AuthenticatedAdmin } from './jwt.strategy';
/** 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). */
export const REQUIRED_RID_KEY = 'requiredRid';
export const RequirePermission = (rid: string) => SetMetadata(REQUIRED_RID_KEY, rid);
/** Injects the authenticated admin: `@CurrentAdmin() admin: AuthenticatedAdmin`. */
export const CurrentAdmin = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): AuthenticatedAdmin =>
ctx.switchToHttp().getRequest().user,
);
/**
* JWT guard that honours @Public().
*
* Registered globally in AuthModule, so every route is authenticated by default
* and opting out is explicit. The legacy app had the inverse default — its router
* guard skipped a hardcoded `noneAuthUrl` list — which meant any route added
* without a `meta.role` was silently unprotected.
*/
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private readonly reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
return super.canActivate(context);
}
}
/** Enforces @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 user: AuthenticatedAdmin | undefined = context.switchToHttp().getRequest().user;
if (!user) throw new ForbiddenException();
if (!user.permissions.includes(requiredRid)) {
throw new ForbiddenException(`Missing permission: ${requiredRid}`);
}
return true;
}
}
+63
View File
@@ -0,0 +1,63 @@
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;
}
/**
* 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> {
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),
};
}
}