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 = { ...@@ -31,6 +31,7 @@ export const ERROR_CODE = {
PRIVILEGE_ESCALATION_DENIED: "PRIVILEGE_ESCALATION_DENIED", PRIVILEGE_ESCALATION_DENIED: "PRIVILEGE_ESCALATION_DENIED",
SYSTEM_ROLE_PROTECTED: "SYSTEM_ROLE_PROTECTED", SYSTEM_ROLE_PROTECTED: "SYSTEM_ROLE_PROTECTED",
CANNOT_REMOVE_LAST_SUPER_ADMIN: "CANNOT_REMOVE_LAST_SUPER_ADMIN", CANNOT_REMOVE_LAST_SUPER_ADMIN: "CANNOT_REMOVE_LAST_SUPER_ADMIN",
ROLE_HAS_USERS: "ROLE_HAS_USERS",
RATE_LIMIT_EXCEEDED: "RATE_LIMIT_EXCEEDED", RATE_LIMIT_EXCEEDED: "RATE_LIMIT_EXCEEDED",
} as const; } as const;
......
...@@ -14,6 +14,8 @@ export interface MeDto { ...@@ -14,6 +14,8 @@ export interface MeDto {
fullName: string | null; fullName: string | null;
avatarUrl: string | null; avatarUrl: string | null;
role: string; role: string;
roles?: string[];
permissions?: string[];
isActive: boolean; isActive: boolean;
createdAt: Date; createdAt: Date;
} }
...@@ -27,6 +29,8 @@ export interface LoginResponseDto { ...@@ -27,6 +29,8 @@ export interface LoginResponseDto {
fullName: string | null; fullName: string | null;
avatarUrl?: string | null; avatarUrl?: string | null;
role: string; role: string;
roles?: string[];
permissions?: string[];
}; };
} }
......
...@@ -31,6 +31,7 @@ import { ...@@ -31,6 +31,7 @@ import {
getZonedDateParts, getZonedDateParts,
createUtcDateFromZonedParts, createUtcDateFromZonedParts,
} from "../../common/helpers/schedule-calculator.helper"; } from "../../common/helpers/schedule-calculator.helper";
import { PermissionService } from "../permissions/permission.service";
interface AuthJwtPayload { interface AuthJwtPayload {
id: string; id: string;
...@@ -44,6 +45,7 @@ export class AuthService { ...@@ -44,6 +45,7 @@ export class AuthService {
private readonly mailService = new MailService(); private readonly mailService = new MailService();
private readonly storageService = StorageFactory.getStorageService(); private readonly storageService = StorageFactory.getStorageService();
private readonly crawlJobRepository = new CrawlJobRepository(); private readonly crawlJobRepository = new CrawlJobRepository();
private readonly permissionService = new PermissionService();
private async deliverVerificationEmail( private async deliverVerificationEmail(
user: { id: string; email: string }, user: { id: string; email: string },
...@@ -146,6 +148,9 @@ export class AuthService { ...@@ -146,6 +148,9 @@ export class AuthService {
metadata?.ipAddress, metadata?.ipAddress,
); );
const roles = await this.permissionService.getUserRoles(user.id);
const permissions = await this.permissionService.getUserPermissions(user.id);
return { return {
accessToken, accessToken,
refreshToken, refreshToken,
...@@ -155,6 +160,8 @@ export class AuthService { ...@@ -155,6 +160,8 @@ export class AuthService {
fullName: user.fullName, fullName: user.fullName,
avatarUrl: user.avatarUrl, avatarUrl: user.avatarUrl,
role: user.role, role: user.role,
roles,
permissions,
}, },
}; };
} }
...@@ -166,12 +173,17 @@ export class AuthService { ...@@ -166,12 +173,17 @@ export class AuthService {
throw new AppError("User not found", 404, ERROR_CODE.NOT_FOUND); 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 { return {
id: user.id, id: user.id,
email: user.email, email: user.email,
fullName: user.fullName, fullName: user.fullName,
avatarUrl: user.avatarUrl, avatarUrl: user.avatarUrl,
role: user.role, role: user.role,
roles,
permissions,
isActive: user.isActive, isActive: user.isActive,
createdAt: user.createdAt, createdAt: user.createdAt,
}; };
......
...@@ -200,6 +200,15 @@ export class RoleService { ...@@ -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); await this.repository.delete(id);
if (context?.actorId) { if (context?.actorId) {
......
...@@ -12,6 +12,7 @@ export interface CreateUserDto { ...@@ -12,6 +12,7 @@ export interface CreateUserDto {
} }
export interface UpdateUserDto { export interface UpdateUserDto {
email?: string;
fullName?: string; fullName?: string;
avatarUrl?: string | null; avatarUrl?: string | null;
isActive?: boolean; isActive?: boolean;
...@@ -21,6 +22,15 @@ export interface UpdateUserDto { ...@@ -21,6 +22,15 @@ export interface UpdateUserDto {
maxConcurrentJobsLimit?: number; maxConcurrentJobsLimit?: number;
} }
export interface UserAssignedRoleSummaryDto {
id: string;
name: string;
slug: string;
description: string | null;
isSystem: boolean;
isActive: boolean;
}
export interface UserResponseDto { export interface UserResponseDto {
id: string; id: string;
email: string; email: string;
...@@ -33,6 +43,7 @@ export interface UserResponseDto { ...@@ -33,6 +43,7 @@ export interface UserResponseDto {
maxConcurrentJobsLimit: number; maxConcurrentJobsLimit: number;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
roles?: UserAssignedRoleSummaryDto[];
} }
export interface UserQueryDto { export interface UserQueryDto {
......
...@@ -124,6 +124,7 @@ export class UserRepository { ...@@ -124,6 +124,7 @@ export class UserRepository {
update( update(
id: string, id: string,
data: { data: {
email?: string;
fullName?: string; fullName?: string;
avatarUrl?: string | null; avatarUrl?: string | null;
isActive?: boolean; isActive?: boolean;
......
...@@ -27,7 +27,20 @@ export class UserService { ...@@ -27,7 +27,20 @@ export class UserService {
private readonly roleRepository = new RoleRepository(); private readonly roleRepository = new RoleRepository();
private readonly auditLogService = new AuditLogService(); 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 { return {
id: user.id, id: user.id,
email: user.email, email: user.email,
...@@ -40,6 +53,7 @@ export class UserService { ...@@ -40,6 +53,7 @@ export class UserService {
maxConcurrentJobsLimit: user.maxConcurrentJobsLimit, maxConcurrentJobsLimit: user.maxConcurrentJobsLimit,
createdAt: user.createdAt, createdAt: user.createdAt,
updatedAt: user.updatedAt, updatedAt: user.updatedAt,
...(roles !== undefined ? { roles } : {}),
}; };
} }
...@@ -157,7 +171,19 @@ export class UserService { ...@@ -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, { const user = await this.repository.update(id, {
email: data.email,
fullName: data.fullName, fullName: data.fullName,
isActive: data.isActive, isActive: data.isActive,
role: data.role, role: data.role,
......
...@@ -17,6 +17,7 @@ export const createUserSchema = z.object({ ...@@ -17,6 +17,7 @@ export const createUserSchema = z.object({
}); });
export const updateUserSchema = z.object({ export const updateUserSchema = z.object({
email: z.string().email("Email không đúng định dạng.").optional(),
fullName: z.string().optional(), fullName: z.string().optional(),
avatarUrl: z avatarUrl: z
.string() .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