feat: complete admin panel and backend admin api migration
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user