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 {
super_admin
viewer
admin
}
enum SmsLedgerType {
@@ -39,6 +39,8 @@ model User {
email String? @unique
passwordHash String @map("password_hash")
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")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
+21 -11
View File
@@ -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);
}
}
+2 -1
View File
@@ -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
View File
@@ -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,
+26
View File
@@ -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 {
+5 -1
View File
@@ -1,18 +1,22 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { PermissionsGuard } from '../auth/permissions.guard';
import { RequirePermissions } from '../auth/permissions.decorator';
import { AuditService } from './audit.service';
@Controller('audit')
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class AuditController {
constructor(private readonly auditService: AuditService) {}
@Get()
@RequirePermissions('audit:read')
findAll(@Query('limit') limit?: string) {
return this.auditService.findAll(limit ? Number(limit) : 50);
}
@Get(':id')
@RequirePermissions('audit:read')
findOne(@Param('id') id: string) {
return this.auditService.findOne(id);
}
+2
View File
@@ -1,9 +1,11 @@
import { Global, Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { AuditController } from './audit.controller';
import { AuditService } from './audit.service';
@Global()
@Module({
imports: [AuthModule],
controllers: [AuditController],
providers: [AuditService],
exports: [AuditService],
+3 -2
View File
@@ -5,6 +5,7 @@ import { PassportModule } from '@nestjs/passport';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './jwt.strategy';
import { PermissionsGuard } from './permissions.guard';
@Module({
imports: [
@@ -22,7 +23,7 @@ import { JwtStrategy } from './jwt.strategy';
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService, JwtModule],
providers: [AuthService, JwtStrategy, PermissionsGuard],
exports: [AuthService, JwtModule, PermissionsGuard],
})
export class AuthModule {}
+8
View File
@@ -4,6 +4,7 @@ import * as bcrypt from 'bcrypt';
import { PrismaService } from '../prisma/prisma.service';
import { displayName, normalizeMobile } from '../common/mobile.util';
import { LoginDto } from './dto/login.dto';
import { isFullAccessRole, parsePermissions } from './permissions';
@Injectable()
export class AuthService {
@@ -32,6 +33,7 @@ export class AuthService {
}
const name = displayName(user.firstName, user.lastName);
const permissions = parsePermissions(user.permissions);
const payload = {
sub: user.id,
mobile: user.mobile,
@@ -47,6 +49,8 @@ export class AuthService {
lastName: user.lastName,
name,
role: user.role,
permissions,
isFullAccess: isFullAccessRole(user.role),
},
};
}
@@ -61,6 +65,7 @@ export class AuthService {
lastName: true,
email: true,
role: true,
permissions: true,
isActive: true,
createdAt: true,
},
@@ -70,8 +75,11 @@ export class AuthService {
throw new UnauthorizedException();
}
const permissions = parsePermissions(user.permissions);
return {
...user,
permissions,
isFullAccess: isFullAccessRole(user.role),
name: displayName(user.firstName, user.lastName),
};
}
+1
View File
@@ -33,6 +33,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
firstName: true,
lastName: true,
role: true,
permissions: 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 { AuthModule } from '../auth/auth.module';
import { AdminNotifyService } from './admin-notify.service';
import { CentralSmsService } from './central-sms.service';
import { RuntimeSettingsController } from './runtime-settings.controller';
import { RuntimeSettingsService } from './runtime-settings.service';
@Module({
imports: [AuthModule],
controllers: [RuntimeSettingsController],
providers: [CentralSmsService, AdminNotifyService, RuntimeSettingsService],
exports: [CentralSmsService, AdminNotifyService, RuntimeSettingsService],
@@ -1,19 +1,23 @@
import { Body, Controller, Get, Patch, UseGuards } from '@nestjs/common';
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 { RuntimeSettingsService } from './runtime-settings.service';
@Controller('settings')
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class RuntimeSettingsController {
constructor(private readonly settings: RuntimeSettingsService) {}
@Get('admin-sms')
@RequirePermissions('settings:write')
getAdminSms() {
return this.settings.getPublic();
}
@Patch('admin-sms')
@RequirePermissions('settings:write')
setAdminSms(@Body() dto: UpdateAdminSmsDto) {
this.settings.setAdminSmsEnabled(dto.enabled);
return this.settings.getPublic();
+11 -1
View File
@@ -10,6 +10,8 @@ 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 {
CreatePluginDto,
@@ -19,26 +21,30 @@ import {
import { PluginsService } from './plugins.service';
@Controller('plugins')
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class PluginsController {
constructor(private readonly pluginsService: PluginsService) {}
@Get('tenants')
@RequirePermissions('plugins:read')
listTenants() {
return this.pluginsService.listTenants();
}
@Get('expiring')
@RequirePermissions('plugins:read')
expiring(@Query('limit') limit?: string) {
return this.pluginsService.getExpiringSoon(Number(limit) || 5);
}
@Get('tenants/:tenantId')
@RequirePermissions('plugins:read')
getTenantPlugins(@Param('tenantId') tenantId: string) {
return this.pluginsService.getTenantPlugins(tenantId);
}
@Post('tenants/:tenantId')
@RequirePermissions('plugins:write')
create(
@Param('tenantId') tenantId: string,
@Body() dto: CreatePluginDto,
@@ -48,11 +54,13 @@ export class PluginsController {
}
@Get('items/:id')
@RequirePermissions('plugins:read')
getOne(@Param('id') id: string) {
return this.pluginsService.getPlugin(id);
}
@Patch('items/:id')
@RequirePermissions('plugins:write')
update(
@Param('id') id: string,
@Body() dto: UpdatePluginDto,
@@ -62,6 +70,7 @@ export class PluginsController {
}
@Post('items/:id/renew')
@RequirePermissions('plugins:write')
renew(
@Param('id') id: string,
@Body() dto: RenewPluginDto,
@@ -71,6 +80,7 @@ export class PluginsController {
}
@Delete('items/:id')
@RequirePermissions('plugins:write')
remove(@Param('id') id: string, @CurrentUser() user: { id: string }) {
return this.pluginsService.remove(id, user.id);
}
+2 -1
View File
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { PluginsController } from './plugins.controller';
import { PluginsService } from './plugins.service';
@Module({
imports: [NotificationsModule],
imports: [NotificationsModule, AuthModule],
controllers: [PluginsController],
providers: [PluginsService],
exports: [PluginsService],
+14 -1
View File
@@ -10,36 +10,43 @@ 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 { RenewServerDto, UpdateServerAccountDto } from './dto/server-account.dto';
import { ServersService } from './servers.service';
@Controller('servers')
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class ServersController {
constructor(private readonly serversService: ServersService) {}
@Get('tenants')
@RequirePermissions('servers:read')
listTenants() {
return this.serversService.listTenants();
}
@Get('expiring')
@RequirePermissions('servers:read')
expiring(@Query('limit') limit?: string) {
return this.serversService.getExpiringSoon(Number(limit) || 5);
}
@Post('check-alerts')
@RequirePermissions('servers:write')
checkAlerts() {
return this.serversService.runExpiryAlerts().then(() => ({ ok: true }));
}
@Get('tenants/:id')
@RequirePermissions('servers:read')
getTenantServers(@Param('id') id: string) {
return this.serversService.getTenantServers(id);
}
@Post('tenants/:id/servers')
@RequirePermissions('servers:write')
createServer(
@Param('id') id: string,
@Body() dto: UpdateServerAccountDto,
@@ -50,6 +57,7 @@ export class ServersController {
/** @deprecated prefer PATCH /servers/accounts/:accountId */
@Patch('tenants/:id')
@RequirePermissions('servers:write')
updateAccountLegacy(
@Param('id') id: string,
@Body() dto: UpdateServerAccountDto,
@@ -60,6 +68,7 @@ export class ServersController {
/** @deprecated prefer POST /servers/accounts/:accountId/renew */
@Post('tenants/:id/renew')
@RequirePermissions('servers:write')
renewLegacy(
@Param('id') id: string,
@Body() dto: RenewServerDto,
@@ -69,11 +78,13 @@ export class ServersController {
}
@Get('accounts/:accountId')
@RequirePermissions('servers:read')
getServerAccount(@Param('accountId') accountId: string) {
return this.serversService.getServerAccount(accountId);
}
@Patch('accounts/:accountId')
@RequirePermissions('servers:write')
updateServerAccount(
@Param('accountId') accountId: string,
@Body() dto: UpdateServerAccountDto,
@@ -83,6 +94,7 @@ export class ServersController {
}
@Delete('accounts/:accountId')
@RequirePermissions('servers:write')
deleteServerAccount(
@Param('accountId') accountId: string,
@CurrentUser() user: { id: string },
@@ -91,6 +103,7 @@ export class ServersController {
}
@Post('accounts/:accountId/renew')
@RequirePermissions('servers:write')
renewAccount(
@Param('accountId') accountId: string,
@Body() dto: RenewServerDto,
+2 -1
View File
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { ServersController } from './servers.controller';
import { ServersService } from './servers.service';
@Module({
imports: [NotificationsModule],
imports: [NotificationsModule, AuthModule],
controllers: [ServersController],
providers: [ServersService],
exports: [ServersService],
@@ -1,19 +1,23 @@
import { Body, Controller, Get, Param, Post, 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 { SettingsProxyService } from './settings-proxy.service';
@Controller('tenants/:tenantId/settings')
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class SettingsProxyController {
constructor(private readonly settingsProxy: SettingsProxyService) {}
@Get()
@RequirePermissions('hamkar:read', 'khanegi:read', { mode: 'any' })
getSettings(@Param('tenantId') tenantId: string) {
return this.settingsProxy.getSettings(tenantId);
}
@Post()
@RequirePermissions('hamkar:write', 'khanegi:write', { mode: 'any' })
updateSettings(
@Param('tenantId') tenantId: string,
@Body() body: Record<string, unknown>,
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { SettingsProxyController } from './settings-proxy.controller';
import { SettingsProxyService } from './settings-proxy.service';
@Module({
imports: [AuthModule],
controllers: [SettingsProxyController],
providers: [SettingsProxyService],
})
+16 -1
View File
@@ -10,6 +10,8 @@ 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 {
ChargeSmsWalletDto,
@@ -20,31 +22,36 @@ import {
import { SmsService } from './sms.service';
@Controller('sms')
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class SmsController {
constructor(private readonly smsService: SmsService) {}
@Get('tenants')
@RequirePermissions('sms:read')
listTenants() {
return this.smsService.listTenants();
}
@Get('lowest')
@RequirePermissions('sms:read')
lowest(@Query('limit') limit?: string) {
return this.smsService.getLowestBalances(Number(limit) || 5);
}
@Get('tenants/:id')
@RequirePermissions('sms:read')
getAccount(@Param('id') id: string) {
return this.smsService.getAccount(id);
}
@Get('tenants/:id/charge-info')
@RequirePermissions('sms:read')
getChargeInfo(@Param('id') id: string) {
return this.smsService.getChargeInfo(id);
}
@Put('tenants/:id/charge-info')
@RequirePermissions('sms:write')
updateChargeInfo(
@Param('id') id: string,
@Body() dto: UpdateSmsChargeInfoDto,
@@ -54,6 +61,7 @@ export class SmsController {
}
@Get('tenants/:id/report')
@RequirePermissions('sms:read')
report(
@Param('id') id: string,
@Query('fromDate') fromDate?: string,
@@ -63,6 +71,7 @@ export class SmsController {
}
@Get('tenants/:id/report/charges')
@RequirePermissions('sms:read')
chargeReport(
@Param('id') id: string,
@Query('fromDate') fromDate?: string,
@@ -72,16 +81,19 @@ export class SmsController {
}
@Post('tenants/:id/logs/table')
@RequirePermissions('sms:read')
logsTable(@Param('id') id: string, @Body() dto: SmsTableQueryDto) {
return this.smsService.getLogsTable(id, dto);
}
@Post('tenants/:id/ledger/table')
@RequirePermissions('sms:read')
ledgerTable(@Param('id') id: string, @Body() dto: SmsTableQueryDto) {
return this.smsService.getLedgerTable(id, dto);
}
@Patch('tenants/:id')
@RequirePermissions('sms:write')
updateAccount(
@Param('id') id: string,
@Body() dto: UpdateSmsAccountDto,
@@ -91,6 +103,7 @@ export class SmsController {
}
@Post('tenants/:id/charge')
@RequirePermissions('sms:write')
charge(
@Param('id') id: string,
@Body() dto: ChargeSmsWalletDto,
@@ -100,11 +113,13 @@ export class SmsController {
}
@Post('tenants/:id/block')
@RequirePermissions('sms:write')
block(@Param('id') id: string, @CurrentUser() user: { id: string }) {
return this.smsService.setBlocked(id, true, user.id);
}
@Post('tenants/:id/unblock')
@RequirePermissions('sms:write')
unblock(@Param('id') id: string, @CurrentUser() user: { id: string }) {
return this.smsService.setBlocked(id, false, user.id);
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { RemoteSmsAuthGuard } from '../auth/remote-sms-auth.guard';
import { RemoteSmsController } from './remote-sms.controller';
@@ -6,7 +7,7 @@ import { SmsController } from './sms.controller';
import { SmsService } from './sms.service';
@Module({
imports: [NotificationsModule],
imports: [NotificationsModule, AuthModule],
controllers: [SmsController, RemoteSmsController],
providers: [SmsService, RemoteSmsAuthGuard],
exports: [SmsService],
+11 -1
View File
@@ -10,6 +10,8 @@ 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 {
CreateSupportDto,
@@ -19,26 +21,30 @@ import {
import { SupportService } from './support.service';
@Controller('support')
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class SupportController {
constructor(private readonly supportService: SupportService) {}
@Get('tenants')
@RequirePermissions('support:read')
listTenants() {
return this.supportService.listTenants();
}
@Get('expiring')
@RequirePermissions('support:read')
expiring(@Query('limit') limit?: string) {
return this.supportService.getExpiringSoon(Number(limit) || 5);
}
@Get('tenants/:tenantId')
@RequirePermissions('support:read')
getTenantSupports(@Param('tenantId') tenantId: string) {
return this.supportService.getTenantSupports(tenantId);
}
@Post('tenants/:tenantId')
@RequirePermissions('support:write')
create(
@Param('tenantId') tenantId: string,
@Body() dto: CreateSupportDto,
@@ -48,11 +54,13 @@ export class SupportController {
}
@Get('items/:id')
@RequirePermissions('support:read')
getOne(@Param('id') id: string) {
return this.supportService.getSupport(id);
}
@Patch('items/:id')
@RequirePermissions('support:write')
update(
@Param('id') id: string,
@Body() dto: UpdateSupportDto,
@@ -62,6 +70,7 @@ export class SupportController {
}
@Post('items/:id/renew')
@RequirePermissions('support:write')
renew(
@Param('id') id: string,
@Body() dto: RenewSupportDto,
@@ -71,6 +80,7 @@ export class SupportController {
}
@Delete('items/:id')
@RequirePermissions('support:write')
remove(@Param('id') id: string, @CurrentUser() user: { id: string }) {
return this.supportService.remove(id, user.id);
}
+2 -1
View File
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { SupportController } from './support.controller';
import { SupportService } from './support.service';
@Module({
imports: [NotificationsModule],
imports: [NotificationsModule, AuthModule],
controllers: [SupportController],
providers: [SupportService],
exports: [SupportService],
+62 -13
View File
@@ -3,6 +3,7 @@ import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
Param,
Patch,
@@ -18,7 +19,14 @@ import { ProductType } from '@prisma/client';
import { diskStorage } from 'multer';
import { extname } from 'path';
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 {
productReadPermission,
productWritePermission,
userHasPermission,
} from '../auth/permissions';
import {
AVATARS_DIR,
ensureUploadsDirs,
@@ -30,8 +38,10 @@ import { TenantsService } from './tenants.service';
const IMAGE_MIME = /^image\/(jpeg|png|gif|webp|svg\+xml)$/i;
type AuthUser = { id: string; role?: string; permissions?: unknown };
@Controller('tenants')
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class TenantsController {
constructor(
private readonly tenantsService: TenantsService,
@@ -39,11 +49,23 @@ export class TenantsController {
) {}
@Get()
findAll(@Query('product') product?: ProductType) {
return this.tenantsService.findAll(product);
@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);
}
const rows = await this.tenantsService.findAll();
return rows.filter((row) =>
userHasPermission(user || {}, productReadPermission(row.product)),
);
}
@Post('upload-avatar')
@RequirePermissions('hamkar:write', 'khanegi:write', { mode: 'any' })
@UseInterceptors(
FileInterceptor('file', {
storage: diskStorage({
@@ -60,7 +82,12 @@ export class TenantsController {
limits: { fileSize: 2 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
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;
}
cb(null, true);
@@ -76,29 +103,51 @@ export class TenantsController {
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.tenantsService.findOne(id);
@RequirePermissions('hamkar:read', 'khanegi:read', { mode: 'any' })
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()
create(
@Body() dto: CreateTenantDto,
@CurrentUser() user: { id: string },
) {
@RequirePermissions('hamkar:write', 'khanegi:write', { mode: 'any' })
create(@Body() dto: CreateTenantDto, @CurrentUser() user: AuthUser) {
this.assertProductAccess(user, dto.product, 'write');
return this.tenantsService.create(dto, user.id);
}
@Patch(':id')
update(
@RequirePermissions('hamkar:write', 'khanegi:write', { mode: 'any' })
async update(
@Param('id') id: string,
@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);
}
@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);
}
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 { AuthModule } from '../auth/auth.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { TenantsController } from './tenants.controller';
import { TenantsService } from './tenants.service';
@Module({
imports: [NotificationsModule],
imports: [NotificationsModule, AuthModule],
controllers: [TenantsController],
providers: [TenantsService],
exports: [TenantsService],