Commit 85f64564 authored by ThinhNC's avatar ThinhNC

feat(auth,roles): implement dynamic permission payload, role deletion guard,...

feat(auth,roles): implement dynamic permission payload, role deletion guard, and user management APIs
parent 3a9b2bea
......@@ -31,6 +31,7 @@ export const ERROR_CODE = {
PRIVILEGE_ESCALATION_DENIED: "PRIVILEGE_ESCALATION_DENIED",
SYSTEM_ROLE_PROTECTED: "SYSTEM_ROLE_PROTECTED",
CANNOT_REMOVE_LAST_SUPER_ADMIN: "CANNOT_REMOVE_LAST_SUPER_ADMIN",
ROLE_HAS_USERS: "ROLE_HAS_USERS",
RATE_LIMIT_EXCEEDED: "RATE_LIMIT_EXCEEDED",
} as const;
......
......@@ -14,6 +14,8 @@ export interface MeDto {
fullName: string | null;
avatarUrl: string | null;
role: string;
roles?: string[];
permissions?: string[];
isActive: boolean;
createdAt: Date;
}
......@@ -27,6 +29,8 @@ export interface LoginResponseDto {
fullName: string | null;
avatarUrl?: string | null;
role: string;
roles?: string[];
permissions?: string[];
};
}
......
......@@ -31,6 +31,7 @@ import {
getZonedDateParts,
createUtcDateFromZonedParts,
} from "../../common/helpers/schedule-calculator.helper";
import { PermissionService } from "../permissions/permission.service";
interface AuthJwtPayload {
id: string;
......@@ -44,6 +45,7 @@ export class AuthService {
private readonly mailService = new MailService();
private readonly storageService = StorageFactory.getStorageService();
private readonly crawlJobRepository = new CrawlJobRepository();
private readonly permissionService = new PermissionService();
private async deliverVerificationEmail(
user: { id: string; email: string },
......@@ -146,6 +148,9 @@ export class AuthService {
metadata?.ipAddress,
);
const roles = await this.permissionService.getUserRoles(user.id);
const permissions = await this.permissionService.getUserPermissions(user.id);
return {
accessToken,
refreshToken,
......@@ -155,6 +160,8 @@ export class AuthService {
fullName: user.fullName,
avatarUrl: user.avatarUrl,
role: user.role,
roles,
permissions,
},
};
}
......@@ -166,12 +173,17 @@ export class AuthService {
throw new AppError("User not found", 404, ERROR_CODE.NOT_FOUND);
}
const roles = await this.permissionService.getUserRoles(user.id);
const permissions = await this.permissionService.getUserPermissions(user.id);
return {
id: user.id,
email: user.email,
fullName: user.fullName,
avatarUrl: user.avatarUrl,
role: user.role,
roles,
permissions,
isActive: user.isActive,
createdAt: user.createdAt,
};
......
......@@ -200,6 +200,15 @@ export class RoleService {
);
}
// Protect roles that have assigned users from deletion
if (existing._count && existing._count.userRoles > 0) {
throw new AppError(
"Cannot delete role that has assigned users. Please reassign or remove all users from this role first.",
400,
ERROR_CODE.ROLE_HAS_USERS,
);
}
await this.repository.delete(id);
if (context?.actorId) {
......
......@@ -12,6 +12,7 @@ export interface CreateUserDto {
}
export interface UpdateUserDto {
email?: string;
fullName?: string;
avatarUrl?: string | null;
isActive?: boolean;
......@@ -21,6 +22,15 @@ export interface UpdateUserDto {
maxConcurrentJobsLimit?: number;
}
export interface UserAssignedRoleSummaryDto {
id: string;
name: string;
slug: string;
description: string | null;
isSystem: boolean;
isActive: boolean;
}
export interface UserResponseDto {
id: string;
email: string;
......@@ -33,6 +43,7 @@ export interface UserResponseDto {
maxConcurrentJobsLimit: number;
createdAt: Date;
updatedAt: Date;
roles?: UserAssignedRoleSummaryDto[];
}
export interface UserQueryDto {
......
......@@ -124,6 +124,7 @@ export class UserRepository {
update(
id: string,
data: {
email?: string;
fullName?: string;
avatarUrl?: string | null;
isActive?: boolean;
......
......@@ -27,7 +27,20 @@ export class UserService {
private readonly roleRepository = new RoleRepository();
private readonly auditLogService = new AuditLogService();
private formatUser(user: User): UserResponseDto {
private formatUser(user: any): UserResponseDto {
const roles = Array.isArray(user.userRoles)
? user.userRoles
.filter((ur: any) => ur.role)
.map((ur: any) => ({
id: ur.role.id,
name: ur.role.name,
slug: ur.role.slug,
description: ur.role.description ?? null,
isSystem: ur.role.isSystem,
isActive: ur.role.isActive,
}))
: undefined;
return {
id: user.id,
email: user.email,
......@@ -40,6 +53,7 @@ export class UserService {
maxConcurrentJobsLimit: user.maxConcurrentJobsLimit,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
...(roles !== undefined ? { roles } : {}),
};
}
......@@ -157,7 +171,19 @@ export class UserService {
);
}
if (data.email && data.email !== existingUser.email) {
const duplicate = await this.repository.findByEmail(data.email);
if (duplicate && duplicate.id !== id) {
throw new AppError(
"Email already exists",
409,
ERROR_CODE.DUPLICATE_ENTRY,
);
}
}
const user = await this.repository.update(id, {
email: data.email,
fullName: data.fullName,
isActive: data.isActive,
role: data.role,
......
......@@ -17,6 +17,7 @@ export const createUserSchema = z.object({
});
export const updateUserSchema = z.object({
email: z.string().email("Email không đúng định dạng.").optional(),
fullName: z.string().optional(),
avatarUrl: z
.string()
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment