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:
2026-08-10 15:29:04 +03:30
co-authored by Cursor
parent 4b3a8a1d6c
commit af1e217a68
28 changed files with 532 additions and 75 deletions
@@ -0,0 +1,25 @@
-- Replace unused UserRole.viewer with admin + permissions JSON
CREATE TYPE "UserRole_new" AS ENUM ('super_admin', 'admin');
ALTER TABLE "users" ALTER COLUMN "role" DROP DEFAULT;
ALTER TABLE "users"
ALTER COLUMN "role" TYPE "UserRole_new"
USING (
CASE
WHEN "role"::text = 'viewer' THEN 'admin'::"UserRole_new"
ELSE "role"::text::"UserRole_new"
END
);
DROP TYPE "UserRole";
ALTER TYPE "UserRole_new" RENAME TO "UserRole";
ALTER TABLE "users" ALTER COLUMN "role" SET DEFAULT 'super_admin'::"UserRole";
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "permissions" JSONB NOT NULL DEFAULT '[]';
-- Existing managers keep full access
UPDATE "users" SET "role" = 'super_admin', "permissions" = '[]'::jsonb;
+3 -1
View File
@@ -21,7 +21,7 @@ enum KhanegiUiVariant {
enum UserRole { enum UserRole {
super_admin super_admin
viewer admin
} }
enum SmsLedgerType { enum SmsLedgerType {
@@ -39,6 +39,8 @@ model User {
email String? @unique email String? @unique
passwordHash String @map("password_hash") passwordHash String @map("password_hash")
role UserRole @default(super_admin) role UserRole @default(super_admin)
/// Permission codes for role=admin (ignored when role=super_admin)
permissions Json @default("[]")
isActive Boolean @default(true) @map("is_active") isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
+21 -11
View File
@@ -9,47 +9,57 @@ import {
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; 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 { CurrentUser } from '../auth/current-user.decorator';
import { AdminsService } from './admins.service'; import { AdminsService } from './admins.service';
import { CreateAdminDto, TestSmsDto, UpdateAdminDto } from './dto/admin.dto'; import { CreateAdminDto, TestSmsDto, UpdateAdminDto } from './dto/admin.dto';
@Controller('admins') @Controller('admins')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard, PermissionsGuard)
export class AdminsController { export class AdminsController {
constructor(private readonly adminsService: AdminsService) {} constructor(private readonly adminsService: AdminsService) {}
@Get() @Get()
@RequirePermissions('admins:read')
list() { list() {
return this.adminsService.list(); return this.adminsService.list();
} }
@Post() @Post()
@RequirePermissions('admins:write')
create( create(
@Body() dto: CreateAdminDto, @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') @Patch(':id')
@RequirePermissions('admins:write')
update( update(
@Param('id') id: string, @Param('id') id: string,
@Body() dto: UpdateAdminDto, @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') @Delete(':id')
remove(@Param('id') id: string, @CurrentUser() user: { id: string }) { @RequirePermissions('admins:write')
return this.adminsService.remove(id, user.id); remove(
@Param('id') id: string,
@CurrentUser()
user: { id: string; role?: string; permissions?: unknown },
) {
return this.adminsService.remove(id, user);
} }
@Post(':id/test-sms') @Post(':id/test-sms')
testSms( @RequirePermissions('admins:write')
@Param('id') id: string, testSms(@Param('id') id: string, @Body() dto: TestSmsDto) {
@Body() dto: TestSmsDto,
) {
return this.adminsService.sendTestSms(id, dto.message); return this.adminsService.sendTestSms(id, dto.message);
} }
} }
+2 -1
View File
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationsModule } from '../notifications/notifications.module';
import { AdminsController } from './admins.controller'; import { AdminsController } from './admins.controller';
import { AdminsService } from './admins.service'; import { AdminsService } from './admins.service';
@Module({ @Module({
imports: [NotificationsModule], imports: [NotificationsModule, AuthModule],
controllers: [AdminsController], controllers: [AdminsController],
providers: [AdminsService], providers: [AdminsService],
exports: [AdminsService], exports: [AdminsService],
+112 -18
View File
@@ -1,18 +1,31 @@
import { import {
BadRequestException, BadRequestException,
ConflictException, ConflictException,
ForbiddenException,
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma, UserRole } from '@prisma/client';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service'; import { AuditService } from '../audit/audit.service';
import { AdminNotifyService } from '../notifications/admin-notify.service'; import { AdminNotifyService } from '../notifications/admin-notify.service';
import { CentralSmsService } from '../notifications/central-sms.service'; import { CentralSmsService } from '../notifications/central-sms.service';
import { displayName, normalizeMobile } from '../common/mobile.util'; import { displayName, normalizeMobile } from '../common/mobile.util';
import {
isFullAccessRole,
normalizePermissionList,
parsePermissions,
userHasAllPermissions,
} from '../auth/permissions';
import { CreateAdminDto, UpdateAdminDto } from './dto/admin.dto'; import { CreateAdminDto, UpdateAdminDto } from './dto/admin.dto';
type Actor = {
id: string;
role?: string;
permissions?: unknown;
};
@Injectable() @Injectable()
export class AdminsService { export class AdminsService {
constructor( constructor(
@@ -23,7 +36,8 @@ export class AdminsService {
) {} ) {}
list() { list() {
return this.prisma.user.findMany({ return this.prisma.user
.findMany({
orderBy: [{ isActive: 'desc' }, { createdAt: 'asc' }], orderBy: [{ isActive: 'desc' }, { createdAt: 'asc' }],
select: { select: {
id: true, id: true,
@@ -32,24 +46,36 @@ export class AdminsService {
lastName: true, lastName: true,
email: true, email: true,
role: true, role: true,
permissions: true,
isActive: true, isActive: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
}, },
}).then((rows) => })
.then((rows) =>
rows.map((row) => ({ rows.map((row) => ({
...row, ...row,
permissions: parsePermissions(row.permissions),
isFullAccess: isFullAccessRole(row.role),
name: displayName(row.firstName, row.lastName), name: displayName(row.firstName, row.lastName),
})), })),
); );
} }
async create(dto: CreateAdminDto, actorId?: string) { async create(dto: CreateAdminDto, actor: Actor) {
const mobile = normalizeMobile(dto.mobile); const mobile = normalizeMobile(dto.mobile);
if (!mobile) { if (!mobile) {
throw new BadRequestException('شماره موبایل نامعتبر است'); throw new BadRequestException('شماره موبایل نامعتبر است');
} }
const role = dto.role ?? UserRole.admin;
const permissions =
role === UserRole.super_admin
? []
: normalizePermissionList(dto.permissions ?? []);
this.assertCanAssign(actor, role, permissions);
try { try {
const passwordHash = await bcrypt.hash(dto.password, 10); const passwordHash = await bcrypt.hash(dto.password, 10);
const user = await this.prisma.user.create({ const user = await this.prisma.user.create({
@@ -58,7 +84,8 @@ export class AdminsService {
firstName: dto.firstName.trim(), firstName: dto.firstName.trim(),
lastName: dto.lastName.trim(), lastName: dto.lastName.trim(),
passwordHash, passwordHash,
role: 'super_admin', role,
permissions,
isActive: true, isActive: true,
}, },
}); });
@@ -67,7 +94,7 @@ export class AdminsService {
action: 'admin.create', action: 'admin.create',
entityType: 'user', entityType: 'user',
entityId: user.id, entityId: user.id,
userId: actorId, userId: actor.id,
after: this.publicUser(user) as unknown as Prisma.InputJsonValue, 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 } }); const before = await this.prisma.user.findUnique({ where: { id } });
if (!before) throw new NotFoundException('مدیر یافت نشد'); if (!before) throw new NotFoundException('مدیر یافت نشد');
@@ -96,20 +123,48 @@ export class AdminsService {
await this.ensureNotLastActive(id); 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 { try {
const data: { const data: Prisma.UserUpdateInput = {};
firstName?: string;
lastName?: string;
mobile?: string;
isActive?: boolean;
passwordHash?: string;
} = {};
if (dto.firstName !== undefined) data.firstName = dto.firstName.trim(); if (dto.firstName !== undefined) data.firstName = dto.firstName.trim();
if (dto.lastName !== undefined) data.lastName = dto.lastName.trim(); if (dto.lastName !== undefined) data.lastName = dto.lastName.trim();
if (mobile) data.mobile = mobile; if (mobile) data.mobile = mobile;
if (dto.isActive !== undefined) data.isActive = dto.isActive; if (dto.isActive !== undefined) data.isActive = dto.isActive;
if (dto.password) data.passwordHash = await bcrypt.hash(dto.password, 10); 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({ const user = await this.prisma.user.update({
where: { id }, where: { id },
@@ -120,7 +175,7 @@ export class AdminsService {
action: 'admin.update', action: 'admin.update',
entityType: 'user', entityType: 'user',
entityId: user.id, entityId: user.id,
userId: actorId, userId: actor.id,
before: this.publicUser(before) as unknown as Prisma.InputJsonValue, before: this.publicUser(before) as unknown as Prisma.InputJsonValue,
after: this.publicUser(user) 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 } }); const before = await this.prisma.user.findUnique({ where: { id } });
if (!before) throw new NotFoundException('مدیر یافت نشد'); if (!before) throw new NotFoundException('مدیر یافت نشد');
if (actorId && actorId === id) { if (actor.id === id) {
throw new BadRequestException('نمی‌توانید خودتان را حذف کنید'); throw new BadRequestException('نمی‌توانید خودتان را حذف کنید');
} }
await this.ensureNotLastActive(id); await this.ensureNotLastActive(id);
if (before.role === UserRole.super_admin) {
await this.ensureNotLastSuperAdmin(id);
}
await this.prisma.user.delete({ where: { id } }); await this.prisma.user.delete({ where: { id } });
@@ -150,7 +208,7 @@ export class AdminsService {
action: 'admin.delete', action: 'admin.delete',
entityType: 'user', entityType: 'user',
entityId: id, entityId: id,
userId: actorId, userId: actor.id,
before: this.publicUser(before) as unknown as Prisma.InputJsonValue, before: this.publicUser(before) as unknown as Prisma.InputJsonValue,
}); });
@@ -176,6 +234,26 @@ export class AdminsService {
return { ok: true, mobile: user.mobile }; 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) { private async ensureNotLastActive(id: string) {
const activeCount = await this.prisma.user.count({ const activeCount = await this.prisma.user.count({
where: { isActive: true }, 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: { private publicUser(user: {
id: string; id: string;
mobile: string; mobile: string;
@@ -193,10 +283,12 @@ export class AdminsService {
lastName: string; lastName: string;
email: string | null; email: string | null;
role: string; role: string;
permissions?: unknown;
isActive: boolean; isActive: boolean;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
}) { }) {
const permissions = parsePermissions(user.permissions);
return { return {
id: user.id, id: user.id,
mobile: user.mobile, mobile: user.mobile,
@@ -205,6 +297,8 @@ export class AdminsService {
name: displayName(user.firstName, user.lastName), name: displayName(user.firstName, user.lastName),
email: user.email, email: user.email,
role: user.role, role: user.role,
permissions,
isFullAccess: isFullAccessRole(user.role),
isActive: user.isActive, isActive: user.isActive,
createdAt: user.createdAt, createdAt: user.createdAt,
updatedAt: user.updatedAt, updatedAt: user.updatedAt,
+26
View File
@@ -1,10 +1,16 @@
import { import {
ArrayUnique,
IsArray,
IsBoolean, IsBoolean,
IsEnum,
IsIn,
IsOptional, IsOptional,
IsString, IsString,
MaxLength, MaxLength,
MinLength, MinLength,
} from 'class-validator'; } from 'class-validator';
import { UserRole } from '@prisma/client';
import { PERMISSIONS } from '../../auth/permissions';
export class CreateAdminDto { export class CreateAdminDto {
@IsString() @IsString()
@@ -26,6 +32,16 @@ export class CreateAdminDto {
@MinLength(6) @MinLength(6)
@MaxLength(72) @MaxLength(72)
password!: string; password!: string;
@IsOptional()
@IsEnum(UserRole)
role?: UserRole;
@IsOptional()
@IsArray()
@ArrayUnique()
@IsIn([...PERMISSIONS], { each: true })
permissions?: string[];
} }
export class UpdateAdminDto { export class UpdateAdminDto {
@@ -56,6 +72,16 @@ export class UpdateAdminDto {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
isActive?: boolean; isActive?: boolean;
@IsOptional()
@IsEnum(UserRole)
role?: UserRole;
@IsOptional()
@IsArray()
@ArrayUnique()
@IsIn([...PERMISSIONS], { each: true })
permissions?: string[];
} }
export class TestSmsDto { export class TestSmsDto {
+5 -1
View File
@@ -1,18 +1,22 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common'; import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { PermissionsGuard } from '../auth/permissions.guard';
import { RequirePermissions } from '../auth/permissions.decorator';
import { AuditService } from './audit.service'; import { AuditService } from './audit.service';
@Controller('audit') @Controller('audit')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard, PermissionsGuard)
export class AuditController { export class AuditController {
constructor(private readonly auditService: AuditService) {} constructor(private readonly auditService: AuditService) {}
@Get() @Get()
@RequirePermissions('audit:read')
findAll(@Query('limit') limit?: string) { findAll(@Query('limit') limit?: string) {
return this.auditService.findAll(limit ? Number(limit) : 50); return this.auditService.findAll(limit ? Number(limit) : 50);
} }
@Get(':id') @Get(':id')
@RequirePermissions('audit:read')
findOne(@Param('id') id: string) { findOne(@Param('id') id: string) {
return this.auditService.findOne(id); return this.auditService.findOne(id);
} }
+2
View File
@@ -1,9 +1,11 @@
import { Global, Module } from '@nestjs/common'; import { Global, Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { AuditController } from './audit.controller'; import { AuditController } from './audit.controller';
import { AuditService } from './audit.service'; import { AuditService } from './audit.service';
@Global() @Global()
@Module({ @Module({
imports: [AuthModule],
controllers: [AuditController], controllers: [AuditController],
providers: [AuditService], providers: [AuditService],
exports: [AuditService], exports: [AuditService],
+3 -2
View File
@@ -5,6 +5,7 @@ import { PassportModule } from '@nestjs/passport';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { JwtStrategy } from './jwt.strategy'; import { JwtStrategy } from './jwt.strategy';
import { PermissionsGuard } from './permissions.guard';
@Module({ @Module({
imports: [ imports: [
@@ -22,7 +23,7 @@ import { JwtStrategy } from './jwt.strategy';
}), }),
], ],
controllers: [AuthController], controllers: [AuthController],
providers: [AuthService, JwtStrategy], providers: [AuthService, JwtStrategy, PermissionsGuard],
exports: [AuthService, JwtModule], exports: [AuthService, JwtModule, PermissionsGuard],
}) })
export class AuthModule {} export class AuthModule {}
+8
View File
@@ -4,6 +4,7 @@ import * as bcrypt from 'bcrypt';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { displayName, normalizeMobile } from '../common/mobile.util'; import { displayName, normalizeMobile } from '../common/mobile.util';
import { LoginDto } from './dto/login.dto'; import { LoginDto } from './dto/login.dto';
import { isFullAccessRole, parsePermissions } from './permissions';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
@@ -32,6 +33,7 @@ export class AuthService {
} }
const name = displayName(user.firstName, user.lastName); const name = displayName(user.firstName, user.lastName);
const permissions = parsePermissions(user.permissions);
const payload = { const payload = {
sub: user.id, sub: user.id,
mobile: user.mobile, mobile: user.mobile,
@@ -47,6 +49,8 @@ export class AuthService {
lastName: user.lastName, lastName: user.lastName,
name, name,
role: user.role, role: user.role,
permissions,
isFullAccess: isFullAccessRole(user.role),
}, },
}; };
} }
@@ -61,6 +65,7 @@ export class AuthService {
lastName: true, lastName: true,
email: true, email: true,
role: true, role: true,
permissions: true,
isActive: true, isActive: true,
createdAt: true, createdAt: true,
}, },
@@ -70,8 +75,11 @@ export class AuthService {
throw new UnauthorizedException(); throw new UnauthorizedException();
} }
const permissions = parsePermissions(user.permissions);
return { return {
...user, ...user,
permissions,
isFullAccess: isFullAccessRole(user.role),
name: displayName(user.firstName, user.lastName), name: displayName(user.firstName, user.lastName),
}; };
} }
+1
View File
@@ -33,6 +33,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
firstName: true, firstName: true,
lastName: true, lastName: true,
role: true, role: true,
permissions: true,
isActive: true, isActive: true,
}, },
}); });
+21
View File
@@ -0,0 +1,21 @@
import { SetMetadata } from '@nestjs/common';
import type { PermissionCode } from './permissions';
export const PERMISSIONS_KEY = 'required_permissions';
export type PermissionsMeta = {
codes: PermissionCode[];
mode: 'all' | 'any';
};
export const RequirePermissions = (
...args: Array<PermissionCode | { mode?: 'all' | 'any' }>
) => {
const codes: PermissionCode[] = [];
let mode: 'all' | 'any' = 'all';
for (const arg of args) {
if (typeof arg === 'string') codes.push(arg);
else if (arg && typeof arg === 'object' && arg.mode) mode = arg.mode;
}
return SetMetadata(PERMISSIONS_KEY, { codes, mode } satisfies PermissionsMeta);
};
+46
View File
@@ -0,0 +1,46 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import {
PERMISSIONS_KEY,
type PermissionsMeta,
} from './permissions.decorator';
import {
userHasAllPermissions,
userHasAnyPermission,
} from './permissions';
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const meta = this.reflector.getAllAndOverride<PermissionsMeta | undefined>(
PERMISSIONS_KEY,
[context.getHandler(), context.getClass()],
);
if (!meta?.codes?.length) return true;
const request = context.switchToHttp().getRequest();
const user = request.user as
| { role?: string; permissions?: unknown }
| undefined;
if (!user) {
throw new ForbiddenException('دسترسی غیرمجاز');
}
const ok =
meta.mode === 'any'
? userHasAnyPermission(user, meta.codes)
: userHasAllPermissions(user, meta.codes);
if (!ok) {
throw new ForbiddenException('شما به این بخش دسترسی ندارید');
}
return true;
}
}
+102
View File
@@ -0,0 +1,102 @@
/** Permission codes for SuperAdmin panel RBAC */
export const PERMISSIONS = [
'sms:read',
'sms:write',
'servers:read',
'servers:write',
'plugins:read',
'plugins:write',
'support:read',
'support:write',
'hamkar:read',
'hamkar:write',
'khanegi:read',
'khanegi:write',
'admins:read',
'admins:write',
'audit:read',
'settings:write',
] as const;
export type PermissionCode = (typeof PERMISSIONS)[number];
export const PERMISSION_SET = new Set<string>(PERMISSIONS);
export type PermissionModule = {
key: string;
label: string;
read?: PermissionCode;
write?: PermissionCode;
};
export const PERMISSION_MODULES: PermissionModule[] = [
{ key: 'sms', label: 'پیامک', read: 'sms:read', write: 'sms:write' },
{ key: 'servers', label: 'سرور', read: 'servers:read', write: 'servers:write' },
{ key: 'plugins', label: 'افزونه', read: 'plugins:read', write: 'plugins:write' },
{ key: 'support', label: 'پشتیبانی', read: 'support:read', write: 'support:write' },
{ key: 'hamkar', label: 'همکار', read: 'hamkar:read', write: 'hamkar:write' },
{ key: 'khanegi', label: 'خانگی', read: 'khanegi:read', write: 'khanegi:write' },
{ key: 'admins', label: 'مدیران', read: 'admins:read', write: 'admins:write' },
{ key: 'audit', label: 'لاگ', read: 'audit:read' },
{ key: 'settings', label: 'تنظیمات حساس', write: 'settings:write' },
];
export function parsePermissions(raw: unknown): PermissionCode[] {
if (!Array.isArray(raw)) return [];
const out: PermissionCode[] = [];
for (const item of raw) {
const code = String(item || '').trim();
if (PERMISSION_SET.has(code)) out.push(code as PermissionCode);
}
return [...new Set(out)];
}
/** write implies matching read for the same module */
export function normalizePermissionList(codes: string[]): PermissionCode[] {
const set = new Set<PermissionCode>();
for (const code of parsePermissions(codes)) {
set.add(code);
if (code.endsWith(':write')) {
const read = code.replace(/:write$/, ':read');
if (PERMISSION_SET.has(read)) set.add(read as PermissionCode);
}
}
return [...set];
}
export function isFullAccessRole(role: string | null | undefined) {
return role === 'super_admin';
}
export function userHasPermission(
user: { role?: string | null; permissions?: unknown },
code: PermissionCode | string,
): boolean {
if (isFullAccessRole(user.role)) return true;
const list = parsePermissions(user.permissions);
return list.includes(code as PermissionCode);
}
export function userHasAnyPermission(
user: { role?: string | null; permissions?: unknown },
codes: Array<PermissionCode | string>,
): boolean {
if (isFullAccessRole(user.role)) return true;
return codes.some((code) => userHasPermission(user, code));
}
export function userHasAllPermissions(
user: { role?: string | null; permissions?: unknown },
codes: Array<PermissionCode | string>,
): boolean {
if (isFullAccessRole(user.role)) return true;
return codes.every((code) => userHasPermission(user, code));
}
export function productReadPermission(product: string): PermissionCode {
return product === 'hamkar' ? 'hamkar:read' : 'khanegi:read';
}
export function productWritePermission(product: string): PermissionCode {
return product === 'hamkar' ? 'hamkar:write' : 'khanegi:write';
}
@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { AdminNotifyService } from './admin-notify.service'; import { AdminNotifyService } from './admin-notify.service';
import { CentralSmsService } from './central-sms.service'; import { CentralSmsService } from './central-sms.service';
import { RuntimeSettingsController } from './runtime-settings.controller'; import { RuntimeSettingsController } from './runtime-settings.controller';
import { RuntimeSettingsService } from './runtime-settings.service'; import { RuntimeSettingsService } from './runtime-settings.service';
@Module({ @Module({
imports: [AuthModule],
controllers: [RuntimeSettingsController], controllers: [RuntimeSettingsController],
providers: [CentralSmsService, AdminNotifyService, RuntimeSettingsService], providers: [CentralSmsService, AdminNotifyService, RuntimeSettingsService],
exports: [CentralSmsService, AdminNotifyService, RuntimeSettingsService], exports: [CentralSmsService, AdminNotifyService, RuntimeSettingsService],
@@ -1,19 +1,23 @@
import { Body, Controller, Get, Patch, UseGuards } from '@nestjs/common'; import { Body, Controller, Get, Patch, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { PermissionsGuard } from '../auth/permissions.guard';
import { RequirePermissions } from '../auth/permissions.decorator';
import { UpdateAdminSmsDto } from './dto/update-admin-sms.dto'; import { UpdateAdminSmsDto } from './dto/update-admin-sms.dto';
import { RuntimeSettingsService } from './runtime-settings.service'; import { RuntimeSettingsService } from './runtime-settings.service';
@Controller('settings') @Controller('settings')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard, PermissionsGuard)
export class RuntimeSettingsController { export class RuntimeSettingsController {
constructor(private readonly settings: RuntimeSettingsService) {} constructor(private readonly settings: RuntimeSettingsService) {}
@Get('admin-sms') @Get('admin-sms')
@RequirePermissions('settings:write')
getAdminSms() { getAdminSms() {
return this.settings.getPublic(); return this.settings.getPublic();
} }
@Patch('admin-sms') @Patch('admin-sms')
@RequirePermissions('settings:write')
setAdminSms(@Body() dto: UpdateAdminSmsDto) { setAdminSms(@Body() dto: UpdateAdminSmsDto) {
this.settings.setAdminSmsEnabled(dto.enabled); this.settings.setAdminSmsEnabled(dto.enabled);
return this.settings.getPublic(); return this.settings.getPublic();
+11 -1
View File
@@ -10,6 +10,8 @@ import {
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; 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 { CurrentUser } from '../auth/current-user.decorator';
import { import {
CreatePluginDto, CreatePluginDto,
@@ -19,26 +21,30 @@ import {
import { PluginsService } from './plugins.service'; import { PluginsService } from './plugins.service';
@Controller('plugins') @Controller('plugins')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard, PermissionsGuard)
export class PluginsController { export class PluginsController {
constructor(private readonly pluginsService: PluginsService) {} constructor(private readonly pluginsService: PluginsService) {}
@Get('tenants') @Get('tenants')
@RequirePermissions('plugins:read')
listTenants() { listTenants() {
return this.pluginsService.listTenants(); return this.pluginsService.listTenants();
} }
@Get('expiring') @Get('expiring')
@RequirePermissions('plugins:read')
expiring(@Query('limit') limit?: string) { expiring(@Query('limit') limit?: string) {
return this.pluginsService.getExpiringSoon(Number(limit) || 5); return this.pluginsService.getExpiringSoon(Number(limit) || 5);
} }
@Get('tenants/:tenantId') @Get('tenants/:tenantId')
@RequirePermissions('plugins:read')
getTenantPlugins(@Param('tenantId') tenantId: string) { getTenantPlugins(@Param('tenantId') tenantId: string) {
return this.pluginsService.getTenantPlugins(tenantId); return this.pluginsService.getTenantPlugins(tenantId);
} }
@Post('tenants/:tenantId') @Post('tenants/:tenantId')
@RequirePermissions('plugins:write')
create( create(
@Param('tenantId') tenantId: string, @Param('tenantId') tenantId: string,
@Body() dto: CreatePluginDto, @Body() dto: CreatePluginDto,
@@ -48,11 +54,13 @@ export class PluginsController {
} }
@Get('items/:id') @Get('items/:id')
@RequirePermissions('plugins:read')
getOne(@Param('id') id: string) { getOne(@Param('id') id: string) {
return this.pluginsService.getPlugin(id); return this.pluginsService.getPlugin(id);
} }
@Patch('items/:id') @Patch('items/:id')
@RequirePermissions('plugins:write')
update( update(
@Param('id') id: string, @Param('id') id: string,
@Body() dto: UpdatePluginDto, @Body() dto: UpdatePluginDto,
@@ -62,6 +70,7 @@ export class PluginsController {
} }
@Post('items/:id/renew') @Post('items/:id/renew')
@RequirePermissions('plugins:write')
renew( renew(
@Param('id') id: string, @Param('id') id: string,
@Body() dto: RenewPluginDto, @Body() dto: RenewPluginDto,
@@ -71,6 +80,7 @@ export class PluginsController {
} }
@Delete('items/:id') @Delete('items/:id')
@RequirePermissions('plugins:write')
remove(@Param('id') id: string, @CurrentUser() user: { id: string }) { remove(@Param('id') id: string, @CurrentUser() user: { id: string }) {
return this.pluginsService.remove(id, user.id); return this.pluginsService.remove(id, user.id);
} }
+2 -1
View File
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationsModule } from '../notifications/notifications.module';
import { PluginsController } from './plugins.controller'; import { PluginsController } from './plugins.controller';
import { PluginsService } from './plugins.service'; import { PluginsService } from './plugins.service';
@Module({ @Module({
imports: [NotificationsModule], imports: [NotificationsModule, AuthModule],
controllers: [PluginsController], controllers: [PluginsController],
providers: [PluginsService], providers: [PluginsService],
exports: [PluginsService], exports: [PluginsService],
+14 -1
View File
@@ -10,36 +10,43 @@ import {
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; 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 { CurrentUser } from '../auth/current-user.decorator';
import { RenewServerDto, UpdateServerAccountDto } from './dto/server-account.dto'; import { RenewServerDto, UpdateServerAccountDto } from './dto/server-account.dto';
import { ServersService } from './servers.service'; import { ServersService } from './servers.service';
@Controller('servers') @Controller('servers')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard, PermissionsGuard)
export class ServersController { export class ServersController {
constructor(private readonly serversService: ServersService) {} constructor(private readonly serversService: ServersService) {}
@Get('tenants') @Get('tenants')
@RequirePermissions('servers:read')
listTenants() { listTenants() {
return this.serversService.listTenants(); return this.serversService.listTenants();
} }
@Get('expiring') @Get('expiring')
@RequirePermissions('servers:read')
expiring(@Query('limit') limit?: string) { expiring(@Query('limit') limit?: string) {
return this.serversService.getExpiringSoon(Number(limit) || 5); return this.serversService.getExpiringSoon(Number(limit) || 5);
} }
@Post('check-alerts') @Post('check-alerts')
@RequirePermissions('servers:write')
checkAlerts() { checkAlerts() {
return this.serversService.runExpiryAlerts().then(() => ({ ok: true })); return this.serversService.runExpiryAlerts().then(() => ({ ok: true }));
} }
@Get('tenants/:id') @Get('tenants/:id')
@RequirePermissions('servers:read')
getTenantServers(@Param('id') id: string) { getTenantServers(@Param('id') id: string) {
return this.serversService.getTenantServers(id); return this.serversService.getTenantServers(id);
} }
@Post('tenants/:id/servers') @Post('tenants/:id/servers')
@RequirePermissions('servers:write')
createServer( createServer(
@Param('id') id: string, @Param('id') id: string,
@Body() dto: UpdateServerAccountDto, @Body() dto: UpdateServerAccountDto,
@@ -50,6 +57,7 @@ export class ServersController {
/** @deprecated prefer PATCH /servers/accounts/:accountId */ /** @deprecated prefer PATCH /servers/accounts/:accountId */
@Patch('tenants/:id') @Patch('tenants/:id')
@RequirePermissions('servers:write')
updateAccountLegacy( updateAccountLegacy(
@Param('id') id: string, @Param('id') id: string,
@Body() dto: UpdateServerAccountDto, @Body() dto: UpdateServerAccountDto,
@@ -60,6 +68,7 @@ export class ServersController {
/** @deprecated prefer POST /servers/accounts/:accountId/renew */ /** @deprecated prefer POST /servers/accounts/:accountId/renew */
@Post('tenants/:id/renew') @Post('tenants/:id/renew')
@RequirePermissions('servers:write')
renewLegacy( renewLegacy(
@Param('id') id: string, @Param('id') id: string,
@Body() dto: RenewServerDto, @Body() dto: RenewServerDto,
@@ -69,11 +78,13 @@ export class ServersController {
} }
@Get('accounts/:accountId') @Get('accounts/:accountId')
@RequirePermissions('servers:read')
getServerAccount(@Param('accountId') accountId: string) { getServerAccount(@Param('accountId') accountId: string) {
return this.serversService.getServerAccount(accountId); return this.serversService.getServerAccount(accountId);
} }
@Patch('accounts/:accountId') @Patch('accounts/:accountId')
@RequirePermissions('servers:write')
updateServerAccount( updateServerAccount(
@Param('accountId') accountId: string, @Param('accountId') accountId: string,
@Body() dto: UpdateServerAccountDto, @Body() dto: UpdateServerAccountDto,
@@ -83,6 +94,7 @@ export class ServersController {
} }
@Delete('accounts/:accountId') @Delete('accounts/:accountId')
@RequirePermissions('servers:write')
deleteServerAccount( deleteServerAccount(
@Param('accountId') accountId: string, @Param('accountId') accountId: string,
@CurrentUser() user: { id: string }, @CurrentUser() user: { id: string },
@@ -91,6 +103,7 @@ export class ServersController {
} }
@Post('accounts/:accountId/renew') @Post('accounts/:accountId/renew')
@RequirePermissions('servers:write')
renewAccount( renewAccount(
@Param('accountId') accountId: string, @Param('accountId') accountId: string,
@Body() dto: RenewServerDto, @Body() dto: RenewServerDto,
+2 -1
View File
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationsModule } from '../notifications/notifications.module';
import { ServersController } from './servers.controller'; import { ServersController } from './servers.controller';
import { ServersService } from './servers.service'; import { ServersService } from './servers.service';
@Module({ @Module({
imports: [NotificationsModule], imports: [NotificationsModule, AuthModule],
controllers: [ServersController], controllers: [ServersController],
providers: [ServersService], providers: [ServersService],
exports: [ServersService], exports: [ServersService],
@@ -1,19 +1,23 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; 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 { CurrentUser } from '../auth/current-user.decorator';
import { SettingsProxyService } from './settings-proxy.service'; import { SettingsProxyService } from './settings-proxy.service';
@Controller('tenants/:tenantId/settings') @Controller('tenants/:tenantId/settings')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard, PermissionsGuard)
export class SettingsProxyController { export class SettingsProxyController {
constructor(private readonly settingsProxy: SettingsProxyService) {} constructor(private readonly settingsProxy: SettingsProxyService) {}
@Get() @Get()
@RequirePermissions('hamkar:read', 'khanegi:read', { mode: 'any' })
getSettings(@Param('tenantId') tenantId: string) { getSettings(@Param('tenantId') tenantId: string) {
return this.settingsProxy.getSettings(tenantId); return this.settingsProxy.getSettings(tenantId);
} }
@Post() @Post()
@RequirePermissions('hamkar:write', 'khanegi:write', { mode: 'any' })
updateSettings( updateSettings(
@Param('tenantId') tenantId: string, @Param('tenantId') tenantId: string,
@Body() body: Record<string, unknown>, @Body() body: Record<string, unknown>,
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { SettingsProxyController } from './settings-proxy.controller'; import { SettingsProxyController } from './settings-proxy.controller';
import { SettingsProxyService } from './settings-proxy.service'; import { SettingsProxyService } from './settings-proxy.service';
@Module({ @Module({
imports: [AuthModule],
controllers: [SettingsProxyController], controllers: [SettingsProxyController],
providers: [SettingsProxyService], providers: [SettingsProxyService],
}) })
+16 -1
View File
@@ -10,6 +10,8 @@ import {
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; 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 { CurrentUser } from '../auth/current-user.decorator';
import { import {
ChargeSmsWalletDto, ChargeSmsWalletDto,
@@ -20,31 +22,36 @@ import {
import { SmsService } from './sms.service'; import { SmsService } from './sms.service';
@Controller('sms') @Controller('sms')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard, PermissionsGuard)
export class SmsController { export class SmsController {
constructor(private readonly smsService: SmsService) {} constructor(private readonly smsService: SmsService) {}
@Get('tenants') @Get('tenants')
@RequirePermissions('sms:read')
listTenants() { listTenants() {
return this.smsService.listTenants(); return this.smsService.listTenants();
} }
@Get('lowest') @Get('lowest')
@RequirePermissions('sms:read')
lowest(@Query('limit') limit?: string) { lowest(@Query('limit') limit?: string) {
return this.smsService.getLowestBalances(Number(limit) || 5); return this.smsService.getLowestBalances(Number(limit) || 5);
} }
@Get('tenants/:id') @Get('tenants/:id')
@RequirePermissions('sms:read')
getAccount(@Param('id') id: string) { getAccount(@Param('id') id: string) {
return this.smsService.getAccount(id); return this.smsService.getAccount(id);
} }
@Get('tenants/:id/charge-info') @Get('tenants/:id/charge-info')
@RequirePermissions('sms:read')
getChargeInfo(@Param('id') id: string) { getChargeInfo(@Param('id') id: string) {
return this.smsService.getChargeInfo(id); return this.smsService.getChargeInfo(id);
} }
@Put('tenants/:id/charge-info') @Put('tenants/:id/charge-info')
@RequirePermissions('sms:write')
updateChargeInfo( updateChargeInfo(
@Param('id') id: string, @Param('id') id: string,
@Body() dto: UpdateSmsChargeInfoDto, @Body() dto: UpdateSmsChargeInfoDto,
@@ -54,6 +61,7 @@ export class SmsController {
} }
@Get('tenants/:id/report') @Get('tenants/:id/report')
@RequirePermissions('sms:read')
report( report(
@Param('id') id: string, @Param('id') id: string,
@Query('fromDate') fromDate?: string, @Query('fromDate') fromDate?: string,
@@ -63,6 +71,7 @@ export class SmsController {
} }
@Get('tenants/:id/report/charges') @Get('tenants/:id/report/charges')
@RequirePermissions('sms:read')
chargeReport( chargeReport(
@Param('id') id: string, @Param('id') id: string,
@Query('fromDate') fromDate?: string, @Query('fromDate') fromDate?: string,
@@ -72,16 +81,19 @@ export class SmsController {
} }
@Post('tenants/:id/logs/table') @Post('tenants/:id/logs/table')
@RequirePermissions('sms:read')
logsTable(@Param('id') id: string, @Body() dto: SmsTableQueryDto) { logsTable(@Param('id') id: string, @Body() dto: SmsTableQueryDto) {
return this.smsService.getLogsTable(id, dto); return this.smsService.getLogsTable(id, dto);
} }
@Post('tenants/:id/ledger/table') @Post('tenants/:id/ledger/table')
@RequirePermissions('sms:read')
ledgerTable(@Param('id') id: string, @Body() dto: SmsTableQueryDto) { ledgerTable(@Param('id') id: string, @Body() dto: SmsTableQueryDto) {
return this.smsService.getLedgerTable(id, dto); return this.smsService.getLedgerTable(id, dto);
} }
@Patch('tenants/:id') @Patch('tenants/:id')
@RequirePermissions('sms:write')
updateAccount( updateAccount(
@Param('id') id: string, @Param('id') id: string,
@Body() dto: UpdateSmsAccountDto, @Body() dto: UpdateSmsAccountDto,
@@ -91,6 +103,7 @@ export class SmsController {
} }
@Post('tenants/:id/charge') @Post('tenants/:id/charge')
@RequirePermissions('sms:write')
charge( charge(
@Param('id') id: string, @Param('id') id: string,
@Body() dto: ChargeSmsWalletDto, @Body() dto: ChargeSmsWalletDto,
@@ -100,11 +113,13 @@ export class SmsController {
} }
@Post('tenants/:id/block') @Post('tenants/:id/block')
@RequirePermissions('sms:write')
block(@Param('id') id: string, @CurrentUser() user: { id: string }) { block(@Param('id') id: string, @CurrentUser() user: { id: string }) {
return this.smsService.setBlocked(id, true, user.id); return this.smsService.setBlocked(id, true, user.id);
} }
@Post('tenants/:id/unblock') @Post('tenants/:id/unblock')
@RequirePermissions('sms:write')
unblock(@Param('id') id: string, @CurrentUser() user: { id: string }) { unblock(@Param('id') id: string, @CurrentUser() user: { id: string }) {
return this.smsService.setBlocked(id, false, user.id); return this.smsService.setBlocked(id, false, user.id);
} }
+2 -1
View File
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationsModule } from '../notifications/notifications.module';
import { RemoteSmsAuthGuard } from '../auth/remote-sms-auth.guard'; import { RemoteSmsAuthGuard } from '../auth/remote-sms-auth.guard';
import { RemoteSmsController } from './remote-sms.controller'; import { RemoteSmsController } from './remote-sms.controller';
@@ -6,7 +7,7 @@ import { SmsController } from './sms.controller';
import { SmsService } from './sms.service'; import { SmsService } from './sms.service';
@Module({ @Module({
imports: [NotificationsModule], imports: [NotificationsModule, AuthModule],
controllers: [SmsController, RemoteSmsController], controllers: [SmsController, RemoteSmsController],
providers: [SmsService, RemoteSmsAuthGuard], providers: [SmsService, RemoteSmsAuthGuard],
exports: [SmsService], exports: [SmsService],
+11 -1
View File
@@ -10,6 +10,8 @@ import {
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; 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 { CurrentUser } from '../auth/current-user.decorator';
import { import {
CreateSupportDto, CreateSupportDto,
@@ -19,26 +21,30 @@ import {
import { SupportService } from './support.service'; import { SupportService } from './support.service';
@Controller('support') @Controller('support')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard, PermissionsGuard)
export class SupportController { export class SupportController {
constructor(private readonly supportService: SupportService) {} constructor(private readonly supportService: SupportService) {}
@Get('tenants') @Get('tenants')
@RequirePermissions('support:read')
listTenants() { listTenants() {
return this.supportService.listTenants(); return this.supportService.listTenants();
} }
@Get('expiring') @Get('expiring')
@RequirePermissions('support:read')
expiring(@Query('limit') limit?: string) { expiring(@Query('limit') limit?: string) {
return this.supportService.getExpiringSoon(Number(limit) || 5); return this.supportService.getExpiringSoon(Number(limit) || 5);
} }
@Get('tenants/:tenantId') @Get('tenants/:tenantId')
@RequirePermissions('support:read')
getTenantSupports(@Param('tenantId') tenantId: string) { getTenantSupports(@Param('tenantId') tenantId: string) {
return this.supportService.getTenantSupports(tenantId); return this.supportService.getTenantSupports(tenantId);
} }
@Post('tenants/:tenantId') @Post('tenants/:tenantId')
@RequirePermissions('support:write')
create( create(
@Param('tenantId') tenantId: string, @Param('tenantId') tenantId: string,
@Body() dto: CreateSupportDto, @Body() dto: CreateSupportDto,
@@ -48,11 +54,13 @@ export class SupportController {
} }
@Get('items/:id') @Get('items/:id')
@RequirePermissions('support:read')
getOne(@Param('id') id: string) { getOne(@Param('id') id: string) {
return this.supportService.getSupport(id); return this.supportService.getSupport(id);
} }
@Patch('items/:id') @Patch('items/:id')
@RequirePermissions('support:write')
update( update(
@Param('id') id: string, @Param('id') id: string,
@Body() dto: UpdateSupportDto, @Body() dto: UpdateSupportDto,
@@ -62,6 +70,7 @@ export class SupportController {
} }
@Post('items/:id/renew') @Post('items/:id/renew')
@RequirePermissions('support:write')
renew( renew(
@Param('id') id: string, @Param('id') id: string,
@Body() dto: RenewSupportDto, @Body() dto: RenewSupportDto,
@@ -71,6 +80,7 @@ export class SupportController {
} }
@Delete('items/:id') @Delete('items/:id')
@RequirePermissions('support:write')
remove(@Param('id') id: string, @CurrentUser() user: { id: string }) { remove(@Param('id') id: string, @CurrentUser() user: { id: string }) {
return this.supportService.remove(id, user.id); return this.supportService.remove(id, user.id);
} }
+2 -1
View File
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationsModule } from '../notifications/notifications.module';
import { SupportController } from './support.controller'; import { SupportController } from './support.controller';
import { SupportService } from './support.service'; import { SupportService } from './support.service';
@Module({ @Module({
imports: [NotificationsModule], imports: [NotificationsModule, AuthModule],
controllers: [SupportController], controllers: [SupportController],
providers: [SupportService], providers: [SupportService],
exports: [SupportService], exports: [SupportService],
+61 -12
View File
@@ -3,6 +3,7 @@ import {
Body, Body,
Controller, Controller,
Delete, Delete,
ForbiddenException,
Get, Get,
Param, Param,
Patch, Patch,
@@ -18,7 +19,14 @@ import { ProductType } from '@prisma/client';
import { diskStorage } from 'multer'; import { diskStorage } from 'multer';
import { extname } from 'path'; import { extname } from 'path';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; 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 { CurrentUser } from '../auth/current-user.decorator';
import {
productReadPermission,
productWritePermission,
userHasPermission,
} from '../auth/permissions';
import { import {
AVATARS_DIR, AVATARS_DIR,
ensureUploadsDirs, ensureUploadsDirs,
@@ -30,8 +38,10 @@ import { TenantsService } from './tenants.service';
const IMAGE_MIME = /^image\/(jpeg|png|gif|webp|svg\+xml)$/i; const IMAGE_MIME = /^image\/(jpeg|png|gif|webp|svg\+xml)$/i;
type AuthUser = { id: string; role?: string; permissions?: unknown };
@Controller('tenants') @Controller('tenants')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard, PermissionsGuard)
export class TenantsController { export class TenantsController {
constructor( constructor(
private readonly tenantsService: TenantsService, private readonly tenantsService: TenantsService,
@@ -39,11 +49,23 @@ export class TenantsController {
) {} ) {}
@Get() @Get()
findAll(@Query('product') product?: ProductType) { @RequirePermissions('hamkar:read', 'khanegi:read', { mode: 'any' })
async findAll(
@Query('product') product?: ProductType,
@CurrentUser() user?: AuthUser,
) {
if (product) {
this.assertProductAccess(user, product, 'read');
return this.tenantsService.findAll(product); return this.tenantsService.findAll(product);
} }
const rows = await this.tenantsService.findAll();
return rows.filter((row) =>
userHasPermission(user || {}, productReadPermission(row.product)),
);
}
@Post('upload-avatar') @Post('upload-avatar')
@RequirePermissions('hamkar:write', 'khanegi:write', { mode: 'any' })
@UseInterceptors( @UseInterceptors(
FileInterceptor('file', { FileInterceptor('file', {
storage: diskStorage({ storage: diskStorage({
@@ -60,7 +82,12 @@ export class TenantsController {
limits: { fileSize: 2 * 1024 * 1024 }, limits: { fileSize: 2 * 1024 * 1024 },
fileFilter: (_req, file, cb) => { fileFilter: (_req, file, cb) => {
if (!IMAGE_MIME.test(file.mimetype)) { if (!IMAGE_MIME.test(file.mimetype)) {
cb(new BadRequestException('فقط تصویر (jpg/png/gif/webp/svg) مجاز است') as any, false); cb(
new BadRequestException(
'فقط تصویر (jpg/png/gif/webp/svg) مجاز است',
) as any,
false,
);
return; return;
} }
cb(null, true); cb(null, true);
@@ -76,29 +103,51 @@ export class TenantsController {
} }
@Get(':id') @Get(':id')
findOne(@Param('id') id: string) { @RequirePermissions('hamkar:read', 'khanegi:read', { mode: 'any' })
return this.tenantsService.findOne(id); async findOne(@Param('id') id: string, @CurrentUser() user: AuthUser) {
const tenant = await this.tenantsService.findOne(id);
this.assertProductAccess(user, tenant.product, 'read');
return tenant;
} }
@Post() @Post()
create( @RequirePermissions('hamkar:write', 'khanegi:write', { mode: 'any' })
@Body() dto: CreateTenantDto, create(@Body() dto: CreateTenantDto, @CurrentUser() user: AuthUser) {
@CurrentUser() user: { id: string }, this.assertProductAccess(user, dto.product, 'write');
) {
return this.tenantsService.create(dto, user.id); return this.tenantsService.create(dto, user.id);
} }
@Patch(':id') @Patch(':id')
update( @RequirePermissions('hamkar:write', 'khanegi:write', { mode: 'any' })
async update(
@Param('id') id: string, @Param('id') id: string,
@Body() dto: UpdateTenantDto, @Body() dto: UpdateTenantDto,
@CurrentUser() user: { id: string }, @CurrentUser() user: AuthUser,
) { ) {
const tenant = await this.tenantsService.findOne(id);
this.assertProductAccess(user, tenant.product, 'write');
return this.tenantsService.update(id, dto, user.id); return this.tenantsService.update(id, dto, user.id);
} }
@Delete(':id') @Delete(':id')
remove(@Param('id') id: string, @CurrentUser() user: { id: string }) { @RequirePermissions('hamkar:write', 'khanegi:write', { mode: 'any' })
async remove(@Param('id') id: string, @CurrentUser() user: AuthUser) {
const tenant = await this.tenantsService.findOne(id);
this.assertProductAccess(user, tenant.product, 'write');
return this.tenantsService.remove(id, user.id); return this.tenantsService.remove(id, user.id);
} }
private assertProductAccess(
user: AuthUser | undefined,
product: string,
mode: 'read' | 'write',
) {
const code =
mode === 'read'
? productReadPermission(product)
: productWritePermission(product);
if (!userHasPermission(user || {}, code)) {
throw new ForbiddenException('شما به این محصول دسترسی ندارید');
}
}
} }
+2 -1
View File
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationsModule } from '../notifications/notifications.module';
import { TenantsController } from './tenants.controller'; import { TenantsController } from './tenants.controller';
import { TenantsService } from './tenants.service'; import { TenantsService } from './tenants.service';
@Module({ @Module({
imports: [NotificationsModule], imports: [NotificationsModule, AuthModule],
controllers: [TenantsController], controllers: [TenantsController],
providers: [TenantsService], providers: [TenantsService],
exports: [TenantsService], exports: [TenantsService],