feat(auth): add module read/write admin RBAC
Introduce permission catalog, PermissionsGuard, JWT/me payload with role+permissions, and gate all module controllers. Admins CRUD accepts role/permissions with safety rules; migrate existing users to super_admin. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -9,47 +9,57 @@ import {
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { PermissionsGuard } from '../auth/permissions.guard';
|
||||
import { RequirePermissions } from '../auth/permissions.decorator';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { AdminsService } from './admins.service';
|
||||
import { CreateAdminDto, TestSmsDto, UpdateAdminDto } from './dto/admin.dto';
|
||||
|
||||
@Controller('admins')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, PermissionsGuard)
|
||||
export class AdminsController {
|
||||
constructor(private readonly adminsService: AdminsService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('admins:read')
|
||||
list() {
|
||||
return this.adminsService.list();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermissions('admins:write')
|
||||
create(
|
||||
@Body() dto: CreateAdminDto,
|
||||
@CurrentUser() user: { id: string },
|
||||
@CurrentUser()
|
||||
user: { id: string; role?: string; permissions?: unknown },
|
||||
) {
|
||||
return this.adminsService.create(dto, user.id);
|
||||
return this.adminsService.create(dto, user);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermissions('admins:write')
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateAdminDto,
|
||||
@CurrentUser() user: { id: string },
|
||||
@CurrentUser()
|
||||
user: { id: string; role?: string; permissions?: unknown },
|
||||
) {
|
||||
return this.adminsService.update(id, dto, user.id);
|
||||
return this.adminsService.update(id, dto, user);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
||||
return this.adminsService.remove(id, user.id);
|
||||
@RequirePermissions('admins:write')
|
||||
remove(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser()
|
||||
user: { id: string; role?: string; permissions?: unknown },
|
||||
) {
|
||||
return this.adminsService.remove(id, user);
|
||||
}
|
||||
|
||||
@Post(':id/test-sms')
|
||||
testSms(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: TestSmsDto,
|
||||
) {
|
||||
@RequirePermissions('admins:write')
|
||||
testSms(@Param('id') id: string, @Body() dto: TestSmsDto) {
|
||||
return this.adminsService.sendTestSms(id, dto.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { AdminsController } from './admins.controller';
|
||||
import { AdminsService } from './admins.service';
|
||||
|
||||
@Module({
|
||||
imports: [NotificationsModule],
|
||||
imports: [NotificationsModule, AuthModule],
|
||||
controllers: [AdminsController],
|
||||
providers: [AdminsService],
|
||||
exports: [AdminsService],
|
||||
|
||||
+129
-35
@@ -1,18 +1,31 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Prisma, UserRole } from '@prisma/client';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { AdminNotifyService } from '../notifications/admin-notify.service';
|
||||
import { CentralSmsService } from '../notifications/central-sms.service';
|
||||
import { displayName, normalizeMobile } from '../common/mobile.util';
|
||||
import {
|
||||
isFullAccessRole,
|
||||
normalizePermissionList,
|
||||
parsePermissions,
|
||||
userHasAllPermissions,
|
||||
} from '../auth/permissions';
|
||||
import { CreateAdminDto, UpdateAdminDto } from './dto/admin.dto';
|
||||
|
||||
type Actor = {
|
||||
id: string;
|
||||
role?: string;
|
||||
permissions?: unknown;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AdminsService {
|
||||
constructor(
|
||||
@@ -23,33 +36,46 @@ export class AdminsService {
|
||||
) {}
|
||||
|
||||
list() {
|
||||
return this.prisma.user.findMany({
|
||||
orderBy: [{ isActive: 'desc' }, { createdAt: 'asc' }],
|
||||
select: {
|
||||
id: true,
|
||||
mobile: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
}).then((rows) =>
|
||||
rows.map((row) => ({
|
||||
...row,
|
||||
name: displayName(row.firstName, row.lastName),
|
||||
})),
|
||||
);
|
||||
return this.prisma.user
|
||||
.findMany({
|
||||
orderBy: [{ isActive: 'desc' }, { createdAt: 'asc' }],
|
||||
select: {
|
||||
id: true,
|
||||
mobile: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
role: true,
|
||||
permissions: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
})
|
||||
.then((rows) =>
|
||||
rows.map((row) => ({
|
||||
...row,
|
||||
permissions: parsePermissions(row.permissions),
|
||||
isFullAccess: isFullAccessRole(row.role),
|
||||
name: displayName(row.firstName, row.lastName),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
async create(dto: CreateAdminDto, actorId?: string) {
|
||||
async create(dto: CreateAdminDto, actor: Actor) {
|
||||
const mobile = normalizeMobile(dto.mobile);
|
||||
if (!mobile) {
|
||||
throw new BadRequestException('شماره موبایل نامعتبر است');
|
||||
}
|
||||
|
||||
const role = dto.role ?? UserRole.admin;
|
||||
const permissions =
|
||||
role === UserRole.super_admin
|
||||
? []
|
||||
: normalizePermissionList(dto.permissions ?? []);
|
||||
|
||||
this.assertCanAssign(actor, role, permissions);
|
||||
|
||||
try {
|
||||
const passwordHash = await bcrypt.hash(dto.password, 10);
|
||||
const user = await this.prisma.user.create({
|
||||
@@ -58,7 +84,8 @@ export class AdminsService {
|
||||
firstName: dto.firstName.trim(),
|
||||
lastName: dto.lastName.trim(),
|
||||
passwordHash,
|
||||
role: 'super_admin',
|
||||
role,
|
||||
permissions,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
@@ -67,7 +94,7 @@ export class AdminsService {
|
||||
action: 'admin.create',
|
||||
entityType: 'user',
|
||||
entityId: user.id,
|
||||
userId: actorId,
|
||||
userId: actor.id,
|
||||
after: this.publicUser(user) as unknown as Prisma.InputJsonValue,
|
||||
});
|
||||
|
||||
@@ -82,7 +109,7 @@ export class AdminsService {
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateAdminDto, actorId?: string) {
|
||||
async update(id: string, dto: UpdateAdminDto, actor: Actor) {
|
||||
const before = await this.prisma.user.findUnique({ where: { id } });
|
||||
if (!before) throw new NotFoundException('مدیر یافت نشد');
|
||||
|
||||
@@ -96,20 +123,48 @@ export class AdminsService {
|
||||
await this.ensureNotLastActive(id);
|
||||
}
|
||||
|
||||
const nextRole = dto.role ?? before.role;
|
||||
const nextPermissions =
|
||||
dto.permissions !== undefined
|
||||
? nextRole === UserRole.super_admin
|
||||
? []
|
||||
: normalizePermissionList(dto.permissions)
|
||||
: nextRole === UserRole.super_admin
|
||||
? []
|
||||
: parsePermissions(before.permissions);
|
||||
|
||||
if (dto.role !== undefined || dto.permissions !== undefined) {
|
||||
const roleChanged = nextRole !== before.role;
|
||||
const beforePerms = parsePermissions(before.permissions);
|
||||
const permsChanged =
|
||||
nextRole === UserRole.super_admin
|
||||
? before.role !== UserRole.super_admin
|
||||
: JSON.stringify([...beforePerms].sort()) !==
|
||||
JSON.stringify([...nextPermissions].sort());
|
||||
|
||||
if (roleChanged || permsChanged) {
|
||||
this.assertCanAssign(actor, nextRole, nextPermissions);
|
||||
}
|
||||
if (
|
||||
before.role === UserRole.super_admin &&
|
||||
nextRole !== UserRole.super_admin
|
||||
) {
|
||||
await this.ensureNotLastSuperAdmin(id);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const data: {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
mobile?: string;
|
||||
isActive?: boolean;
|
||||
passwordHash?: string;
|
||||
} = {};
|
||||
const data: Prisma.UserUpdateInput = {};
|
||||
|
||||
if (dto.firstName !== undefined) data.firstName = dto.firstName.trim();
|
||||
if (dto.lastName !== undefined) data.lastName = dto.lastName.trim();
|
||||
if (mobile) data.mobile = mobile;
|
||||
if (dto.isActive !== undefined) data.isActive = dto.isActive;
|
||||
if (dto.password) data.passwordHash = await bcrypt.hash(dto.password, 10);
|
||||
if (dto.role !== undefined) data.role = nextRole;
|
||||
if (dto.role !== undefined || dto.permissions !== undefined) {
|
||||
data.permissions = nextPermissions;
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.update({
|
||||
where: { id },
|
||||
@@ -120,7 +175,7 @@ export class AdminsService {
|
||||
action: 'admin.update',
|
||||
entityType: 'user',
|
||||
entityId: user.id,
|
||||
userId: actorId,
|
||||
userId: actor.id,
|
||||
before: this.publicUser(before) as unknown as Prisma.InputJsonValue,
|
||||
after: this.publicUser(user) as unknown as Prisma.InputJsonValue,
|
||||
});
|
||||
@@ -136,13 +191,16 @@ export class AdminsService {
|
||||
}
|
||||
}
|
||||
|
||||
async remove(id: string, actorId?: string) {
|
||||
async remove(id: string, actor: Actor) {
|
||||
const before = await this.prisma.user.findUnique({ where: { id } });
|
||||
if (!before) throw new NotFoundException('مدیر یافت نشد');
|
||||
if (actorId && actorId === id) {
|
||||
if (actor.id === id) {
|
||||
throw new BadRequestException('نمیتوانید خودتان را حذف کنید');
|
||||
}
|
||||
await this.ensureNotLastActive(id);
|
||||
if (before.role === UserRole.super_admin) {
|
||||
await this.ensureNotLastSuperAdmin(id);
|
||||
}
|
||||
|
||||
await this.prisma.user.delete({ where: { id } });
|
||||
|
||||
@@ -150,7 +208,7 @@ export class AdminsService {
|
||||
action: 'admin.delete',
|
||||
entityType: 'user',
|
||||
entityId: id,
|
||||
userId: actorId,
|
||||
userId: actor.id,
|
||||
before: this.publicUser(before) as unknown as Prisma.InputJsonValue,
|
||||
});
|
||||
|
||||
@@ -176,6 +234,26 @@ export class AdminsService {
|
||||
return { ok: true, mobile: user.mobile };
|
||||
}
|
||||
|
||||
private assertCanAssign(
|
||||
actor: Actor,
|
||||
role: UserRole | string,
|
||||
permissions: string[],
|
||||
) {
|
||||
if (isFullAccessRole(actor.role)) return;
|
||||
|
||||
if (role === UserRole.super_admin) {
|
||||
throw new ForbiddenException(
|
||||
'فقط مدیر کامل میتواند دسترسی کامل بدهد',
|
||||
);
|
||||
}
|
||||
|
||||
if (!userHasAllPermissions(actor, permissions)) {
|
||||
throw new ForbiddenException(
|
||||
'نمیتوانید مجوزی بالاتر از دسترسی خودتان بدهید',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureNotLastActive(id: string) {
|
||||
const activeCount = await this.prisma.user.count({
|
||||
where: { isActive: true },
|
||||
@@ -186,6 +264,18 @@ export class AdminsService {
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureNotLastSuperAdmin(id: string) {
|
||||
const count = await this.prisma.user.count({
|
||||
where: { role: UserRole.super_admin, isActive: true },
|
||||
});
|
||||
const target = await this.prisma.user.findUnique({ where: { id } });
|
||||
if (target?.role === UserRole.super_admin && count <= 1) {
|
||||
throw new BadRequestException(
|
||||
'حداقل یک مدیر با دسترسی کامل باید باقی بماند',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private publicUser(user: {
|
||||
id: string;
|
||||
mobile: string;
|
||||
@@ -193,10 +283,12 @@ export class AdminsService {
|
||||
lastName: string;
|
||||
email: string | null;
|
||||
role: string;
|
||||
permissions?: unknown;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
const permissions = parsePermissions(user.permissions);
|
||||
return {
|
||||
id: user.id,
|
||||
mobile: user.mobile,
|
||||
@@ -205,6 +297,8 @@ export class AdminsService {
|
||||
name: displayName(user.firstName, user.lastName),
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
permissions,
|
||||
isFullAccess: isFullAccessRole(user.role),
|
||||
isActive: user.isActive,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import {
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { PERMISSIONS } from '../../auth/permissions';
|
||||
|
||||
export class CreateAdminDto {
|
||||
@IsString()
|
||||
@@ -26,6 +32,16 @@ export class CreateAdminDto {
|
||||
@MinLength(6)
|
||||
@MaxLength(72)
|
||||
password!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(UserRole)
|
||||
role?: UserRole;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayUnique()
|
||||
@IsIn([...PERMISSIONS], { each: true })
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
export class UpdateAdminDto {
|
||||
@@ -56,6 +72,16 @@ export class UpdateAdminDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(UserRole)
|
||||
role?: UserRole;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayUnique()
|
||||
@IsIn([...PERMISSIONS], { each: true })
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
export class TestSmsDto {
|
||||
|
||||
Reference in New Issue
Block a user