Commit d8221337 authored by ThinhNC's avatar ThinhNC

feat(auth): implement user self-deactivation with email verification

parent 1bf2ac6c
......@@ -20,6 +20,8 @@ export const AUDIT_ACTIONS = {
CREATE_WEBHOOK_CONFIG: "CREATE_WEBHOOK_CONFIG",
DELETE_WEBHOOK_CONFIG: "DELETE_WEBHOOK_CONFIG",
REDELIVER_WEBHOOK: "REDELIVER_WEBHOOK",
REQUEST_DEACTIVATE_ACCOUNT: "REQUEST_DEACTIVATE_ACCOUNT",
CONFIRM_DEACTIVATE_ACCOUNT: "CONFIRM_DEACTIVATE_ACCOUNT",
} as const;
export type AuditAction = (typeof AUDIT_ACTIONS)[keyof typeof AUDIT_ACTIONS];
......@@ -472,6 +472,88 @@ export const swaggerPaths: Record<string, any> = {
},
},
},
"/auth/deactivate/request": {
post: {
tags: ["Auth"],
summary: "Yêu cầu vô hiệu hóa tài khoản",
description:
"Gửi email chứa liên kết/mã xác nhận vô hiệu hóa tài khoản. Yêu cầu người dùng đang đăng nhập và phải nhập đúng mật khẩu hiện tại.",
requestBody: {
required: true,
content: {
"application/json": {
schema: { $ref: "#/components/schemas/RequestDeactivationRequest" },
},
},
},
responses: {
200: {
description:
"Yêu cầu vô hiệu hóa đã được tiếp nhận và email đã được gửi",
content: {
"application/json": {
schema: {
type: "object",
properties: {
success: { type: "boolean", example: true },
message: {
type: "string",
example:
"Email xác nhận vô hiệu hóa tài khoản đã được gửi. Vui lòng kiểm tra hộp thư của bạn.",
},
},
},
},
},
},
400: {
description:
"Dữ liệu không hợp lệ hoặc tài khoản là Quản trị viên duy nhất",
},
401: { description: "Chưa xác thực hoặc mật khẩu không chính xác" },
},
},
},
"/auth/deactivate/confirm": {
post: {
tags: ["Auth"],
summary: "Xác nhận vô hiệu hóa tài khoản",
description:
"Sử dụng token được gửi qua email để hoàn tất vô hiệu hóa tài khoản. Khi hoàn tất, tài khoản bị vô hiệu hóa, toàn bộ refresh tokens, API keys và lịch crawl bị thu hồi.",
requestBody: {
required: true,
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ConfirmDeactivationRequest" },
},
},
},
responses: {
200: {
description: "Vô hiệu hóa tài khoản thành công",
content: {
"application/json": {
schema: {
type: "object",
properties: {
success: { type: "boolean", example: true },
message: {
type: "string",
example:
"Tài khoản của bạn đã được vô hiệu hóa thành công.",
},
},
},
},
},
},
400: {
description:
"Mã xác nhận không hợp lệ, đã hết hạn hoặc tài khoản đã bị vô hiệu hóa",
},
},
},
},
"/users": {
get: {
tags: ["Users"],
......
......@@ -645,6 +645,97 @@
}
}
},
"/auth/deactivate/request": {
"post": {
"description": "Gửi email chứa liên kết/mã xác nhận vô hiệu hóa tài khoản. Yêu cầu người dùng đang đăng nhập và phải nhập đúng mật khẩu hiện tại.",
"responses": {
"200": {
"description": "Yêu cầu vô hiệu hóa đã được tiếp nhận và email đã được gửi",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"example": true
},
"message": {
"type": "string",
"example": "Email xác nhận vô hiệu hóa tài khoản đã được gửi. Vui lòng kiểm tra hộp thư của bạn."
}
}
}
}
}
},
"400": {
"description": "Dữ liệu không hợp lệ hoặc tài khoản là Quản trị viên duy nhất"
},
"401": {
"description": "Chưa xác thực hoặc mật khẩu không chính xác"
}
},
"tags": [
"Auth"
],
"summary": "Yêu cầu vô hiệu hóa tài khoản",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RequestDeactivationRequest"
}
}
}
}
}
},
"/auth/deactivate/confirm": {
"post": {
"description": "Sử dụng token được gửi qua email để hoàn tất vô hiệu hóa tài khoản. Khi hoàn tất, tài khoản bị vô hiệu hóa, toàn bộ refresh tokens, API keys và lịch crawl bị thu hồi.",
"responses": {
"200": {
"description": "Vô hiệu hóa tài khoản thành công",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"example": true
},
"message": {
"type": "string",
"example": "Tài khoản của bạn đã được vô hiệu hóa thành công."
}
}
}
}
}
},
"400": {
"description": "Mã xác nhận không hợp lệ, đã hết hạn hoặc tài khoản đã bị vô hiệu hóa"
}
},
"tags": [
"Auth"
],
"summary": "Xác nhận vô hiệu hóa tài khoản",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ConfirmDeactivationRequest"
}
}
}
}
}
},
"/users": {
"get": {
"description": "Lấy danh sách phân trang người dùng trong hệ thống. Chỉ có ADMIN mới có quyền truy cập.",
......@@ -3829,6 +3920,32 @@
}
}
},
"RequestDeactivationRequest": {
"type": "object",
"required": [
"password"
],
"properties": {
"password": {
"type": "string",
"example": "Password123!",
"description": "Mật khẩu hiện tại của người dùng để xác nhận danh tính"
}
}
},
"ConfirmDeactivationRequest": {
"type": "object",
"required": [
"token"
],
"properties": {
"token": {
"type": "string",
"example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"description": "Mã xác nhận vô hiệu hóa được gửi qua email"
}
}
},
"RefreshRequest": {
"type": "object",
"required": [
......
......@@ -257,6 +257,28 @@ const rawSchemas = {
email: { type: "string", format: "email", example: "user@example.com" },
},
},
RequestDeactivationRequest: {
type: "object",
required: ["password"],
properties: {
password: {
type: "string",
example: "Password123!",
description: "Mật khẩu hiện tại của người dùng để xác nhận danh tính",
},
},
},
ConfirmDeactivationRequest: {
type: "object",
required: ["token"],
properties: {
token: {
type: "string",
example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
description: "Mã xác nhận vô hiệu hóa được gửi qua email",
},
},
},
RefreshRequest: {
type: "object",
required: ["refreshToken"],
......
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";
import { AuthService } from "../auth.service";
import { AuthController } from "../auth.controller";
import { jwtConfig } from "../../../config/jwt.config";
import { ROLES } from "../../../common/constants/role.constant";
import { ERROR_CODE } from "../../../common/errors/error-code";
import { AUDIT_ACTIONS } from "../../../common/constants/audit-action.constant";
import { Request, Response } from "express";
describe("AuthService - Account Self-Deactivation", () => {
const originalNodeEnv = process.env.NODE_ENV;
beforeAll(() => {
process.env.NODE_ENV = "production";
});
afterAll(() => {
process.env.NODE_ENV = originalNodeEnv;
});
const rawPassword = "UserPassword123!";
const passwordHash = bcrypt.hashSync(rawPassword, 10);
const activeUser = {
id: "user-uuid-1",
email: "user@example.com",
passwordHash,
fullName: "Nguyễn Văn Test",
role: ROLES.CRAWLER_USER,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
};
const adminUser = {
id: "admin-uuid-1",
email: "admin@example.com",
passwordHash,
fullName: "System Admin",
role: ROLES.ADMIN,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
};
describe("requestDeactivation", () => {
it("throws 404 NOT_FOUND if user is not found or already inactive", async () => {
const service = new AuthService();
const repository = {
findById: jest.fn().mockResolvedValue(null),
};
(service as unknown as { repository: typeof repository }).repository =
repository;
await expect(
service.requestDeactivation("non-existent-id", {
password: rawPassword,
}),
).rejects.toMatchObject({
statusCode: 404,
code: ERROR_CODE.NOT_FOUND,
});
});
it("throws 400 VALIDATION_ERROR if user is the sole active ADMIN", async () => {
const service = new AuthService();
const repository = {
findById: jest.fn().mockResolvedValue(adminUser),
countActiveAdmins: jest.fn().mockResolvedValue(1),
};
(service as unknown as { repository: typeof repository }).repository =
repository;
await expect(
service.requestDeactivation(adminUser.id, { password: rawPassword }),
).rejects.toMatchObject({
statusCode: 400,
code: ERROR_CODE.VALIDATION_ERROR,
message: expect.stringContaining("Quản trị viên duy nhất"),
});
expect(repository.countActiveAdmins).toHaveBeenCalledTimes(1);
});
it("throws 401 INVALID_CREDENTIALS if password does not match", async () => {
const service = new AuthService();
const repository = {
findById: jest.fn().mockResolvedValue(activeUser),
countActiveAdmins: jest.fn(),
};
(service as unknown as { repository: typeof repository }).repository =
repository;
await expect(
service.requestDeactivation(activeUser.id, {
password: "WrongPassword!",
}),
).rejects.toMatchObject({
statusCode: 401,
code: ERROR_CODE.INVALID_CREDENTIALS,
});
});
it("successfully sends deactivation email and does NOT leak token in return", async () => {
const service = new AuthService();
const repository = {
findById: jest.fn().mockResolvedValue(activeUser),
countActiveAdmins: jest.fn(),
};
const mailService = {
sendDeactivationEmail: jest.fn().mockResolvedValue(undefined),
};
const mutableService = service as unknown as {
repository: typeof repository;
mailService: typeof mailService;
};
mutableService.repository = repository;
mutableService.mailService = mailService;
const result = await service.requestDeactivation(activeUser.id, {
password: rawPassword,
});
expect(result).toEqual({ success: true });
expect(result).not.toHaveProperty("token");
expect(result).not.toHaveProperty("deactivationToken");
expect(mailService.sendDeactivationEmail).toHaveBeenCalledTimes(1);
const [calledEmail, calledToken] =
mailService.sendDeactivationEmail.mock.calls[0];
expect(calledEmail).toBe(activeUser.email);
// Verify the generated token has purpose: deactivate-account and is signed with user's passwordHash
const decoded = jwt.verify(
calledToken,
`${jwtConfig.accessSecret}:deactivate:${activeUser.passwordHash}`,
) as { id: string; email: string; purpose: string };
expect(decoded.id).toBe(activeUser.id);
expect(decoded.email).toBe(activeUser.email);
expect(decoded.purpose).toBe("deactivate-account");
});
it("allows ADMIN to request deactivation if multiple active admins exist", async () => {
const service = new AuthService();
const repository = {
findById: jest.fn().mockResolvedValue(adminUser),
countActiveAdmins: jest.fn().mockResolvedValue(2),
};
const mailService = {
sendDeactivationEmail: jest.fn().mockResolvedValue(undefined),
};
const mutableService = service as unknown as {
repository: typeof repository;
mailService: typeof mailService;
};
mutableService.repository = repository;
mutableService.mailService = mailService;
const result = await service.requestDeactivation(adminUser.id, {
password: rawPassword,
});
expect(result).toEqual({ success: true });
expect(mailService.sendDeactivationEmail).toHaveBeenCalledTimes(1);
});
it("throws 503 MAIL_DELIVERY_FAILED if mailService throws an error", async () => {
const service = new AuthService();
const repository = {
findById: jest.fn().mockResolvedValue(activeUser),
};
const mailService = {
sendDeactivationEmail: jest
.fn()
.mockRejectedValue(new Error("SMTP down")),
};
const mutableService = service as unknown as {
repository: typeof repository;
mailService: typeof mailService;
};
mutableService.repository = repository;
mutableService.mailService = mailService;
await expect(
service.requestDeactivation(activeUser.id, { password: rawPassword }),
).rejects.toMatchObject({
statusCode: 503,
code: ERROR_CODE.MAIL_DELIVERY_FAILED,
});
});
});
describe("confirmDeactivation", () => {
it("throws 400 TOKEN_INVALID if token is not valid JWT format", async () => {
const service = new AuthService();
await expect(
service.confirmDeactivation({ token: "invalid-token-string" }),
).rejects.toMatchObject({
statusCode: 400,
code: ERROR_CODE.TOKEN_INVALID,
});
});
it("throws 400 TOKEN_INVALID if token purpose is not deactivate-account", async () => {
const wrongPurposeToken = jwt.sign(
{
id: activeUser.id,
email: activeUser.email,
purpose: "email-verification",
},
`${jwtConfig.accessSecret}:deactivate:${activeUser.passwordHash}`,
{ expiresIn: "15m" },
);
const service = new AuthService();
await expect(
service.confirmDeactivation({ token: wrongPurposeToken }),
).rejects.toMatchObject({
statusCode: 400,
code: ERROR_CODE.TOKEN_INVALID,
});
});
it("throws 400 USER_INACTIVE if user is not found or already inactive", async () => {
const validToken = jwt.sign(
{
id: "unknown-id",
email: "none@example.com",
purpose: "deactivate-account",
},
`${jwtConfig.accessSecret}:deactivate:anyhash`,
{ expiresIn: "15m" },
);
const service = new AuthService();
const repository = {
findById: jest.fn().mockResolvedValue(null),
};
(service as unknown as { repository: typeof repository }).repository =
repository;
await expect(
service.confirmDeactivation({ token: validToken }),
).rejects.toMatchObject({
statusCode: 400,
code: ERROR_CODE.USER_INACTIVE,
});
});
it("throws 400 TOKEN_EXPIRED if deactivation token has expired", async () => {
const expiredToken = jwt.sign(
{
id: activeUser.id,
email: activeUser.email,
purpose: "deactivate-account",
},
`${jwtConfig.accessSecret}:deactivate:${activeUser.passwordHash}`,
{ expiresIn: "-1s" },
);
const service = new AuthService();
const repository = {
findById: jest.fn().mockResolvedValue(activeUser),
};
(service as unknown as { repository: typeof repository }).repository =
repository;
await expect(
service.confirmDeactivation({ token: expiredToken }),
).rejects.toMatchObject({
statusCode: 400,
code: ERROR_CODE.TOKEN_EXPIRED,
});
});
it("throws 400 TOKEN_INVALID if token signature was tampered or used wrong passwordHash", async () => {
const tamperedToken = jwt.sign(
{
id: activeUser.id,
email: activeUser.email,
purpose: "deactivate-account",
},
`wrong-secret-key`,
{ expiresIn: "15m" },
);
const service = new AuthService();
const repository = {
findById: jest.fn().mockResolvedValue(activeUser),
};
(service as unknown as { repository: typeof repository }).repository =
repository;
await expect(
service.confirmDeactivation({ token: tamperedToken }),
).rejects.toMatchObject({
statusCode: 400,
code: ERROR_CODE.TOKEN_INVALID,
});
});
it("throws 400 VALIDATION_ERROR if user is the sole ADMIN at confirmation time", async () => {
const adminToken = jwt.sign(
{
id: adminUser.id,
email: adminUser.email,
purpose: "deactivate-account",
},
`${jwtConfig.accessSecret}:deactivate:${adminUser.passwordHash}`,
{ expiresIn: "15m" },
);
const service = new AuthService();
const repository = {
findById: jest.fn().mockResolvedValue(adminUser),
countActiveAdmins: jest.fn().mockResolvedValue(1),
deactivateUser: jest.fn(),
};
(service as unknown as { repository: typeof repository }).repository =
repository;
await expect(
service.confirmDeactivation({ token: adminToken }),
).rejects.toMatchObject({
statusCode: 400,
code: ERROR_CODE.VALIDATION_ERROR,
});
expect(repository.deactivateUser).not.toHaveBeenCalled();
});
it("successfully confirms deactivation and invokes repository.deactivateUser", async () => {
const validToken = jwt.sign(
{
id: activeUser.id,
email: activeUser.email,
purpose: "deactivate-account",
},
`${jwtConfig.accessSecret}:deactivate:${activeUser.passwordHash}`,
{ expiresIn: "15m" },
);
const service = new AuthService();
const repository = {
findById: jest.fn().mockResolvedValue(activeUser),
countActiveAdmins: jest.fn(),
deactivateUser: jest.fn().mockResolvedValue(undefined),
};
(service as unknown as { repository: typeof repository }).repository =
repository;
const result = await service.confirmDeactivation({ token: validToken });
expect(result).toEqual({
success: true,
userId: activeUser.id,
email: activeUser.email,
});
expect(repository.deactivateUser).toHaveBeenCalledWith(activeUser.id);
expect(repository.deactivateUser).toHaveBeenCalledTimes(1);
});
});
});
describe("AuthController - Account Self-Deactivation", () => {
let controller: AuthController;
let mockService: {
requestDeactivation: jest.Mock;
confirmDeactivation: jest.Mock;
};
let mockAuditLogService: {
log: jest.Mock;
};
beforeEach(() => {
controller = new AuthController();
mockService = {
requestDeactivation: jest.fn(),
confirmDeactivation: jest.fn(),
};
mockAuditLogService = {
log: jest.fn().mockResolvedValue(undefined),
};
(controller as unknown as { service: typeof mockService }).service =
mockService;
(
controller as unknown as {
auditLogService: typeof mockAuditLogService;
}
).auditLogService = mockAuditLogService;
});
it("handles requestDeactivation, logs audit event, and returns 200 response", async () => {
mockService.requestDeactivation.mockResolvedValue({ success: true });
const req = {
user: { id: "user-123", email: "user@example.com" },
body: { password: "Password123!" },
ip: "127.0.0.1",
headers: { "user-agent": "Jest-Test-Agent" },
} as unknown as Request;
const res = {
json: jest.fn(),
} as unknown as Response;
const next = jest.fn();
await controller.requestDeactivation(req, res, next);
expect(mockService.requestDeactivation).toHaveBeenCalledWith("user-123", {
password: "Password123!",
});
expect(mockAuditLogService.log).toHaveBeenCalledWith({
userId: "user-123",
action: AUDIT_ACTIONS.REQUEST_DEACTIVATE_ACCOUNT,
ipAddress: "127.0.0.1",
userAgent: "Jest-Test-Agent",
details: { email: "user@example.com" },
});
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
success: true,
message: expect.any(String),
}),
);
expect(next).not.toHaveBeenCalled();
});
it("handles confirmDeactivation, logs audit event, clears cookies, and returns 200 response", async () => {
mockService.confirmDeactivation.mockResolvedValue({
success: true,
userId: "user-123",
email: "user@example.com",
});
const req = {
body: { token: "sample-valid-token" },
ip: "127.0.0.1",
headers: { "user-agent": "Jest-Test-Agent" },
} as unknown as Request;
const res = {
clearCookie: jest.fn(),
json: jest.fn(),
} as unknown as Response;
const next = jest.fn();
await controller.confirmDeactivation(req, res, next);
expect(mockService.confirmDeactivation).toHaveBeenCalledWith({
token: "sample-valid-token",
});
expect(mockAuditLogService.log).toHaveBeenCalledWith({
userId: "user-123",
action: AUDIT_ACTIONS.CONFIRM_DEACTIVATE_ACCOUNT,
ipAddress: "127.0.0.1",
userAgent: "Jest-Test-Agent",
details: { email: "user@example.com" },
});
expect(res.clearCookie).toHaveBeenCalledWith("accessToken");
expect(res.clearCookie).toHaveBeenCalledWith("refreshToken");
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
success: true,
message: expect.any(String),
}),
);
expect(next).not.toHaveBeenCalled();
});
it("passes errors to next() middleware on failure", async () => {
const testError = new Error("Something went wrong");
mockService.requestDeactivation.mockRejectedValue(testError);
const req = {
user: { id: "user-123", email: "user@example.com" },
body: { password: "Password123!" },
ip: "127.0.0.1",
headers: { "user-agent": "Jest-Test-Agent" },
} as unknown as Request;
const res = { json: jest.fn() } as unknown as Response;
const next = jest.fn();
await controller.requestDeactivation(req, res, next);
expect(next).toHaveBeenCalledWith(testError);
});
});
......@@ -10,6 +10,8 @@ import {
VerifyEmailDto,
ChangePasswordDto,
ResendVerificationDto,
RequestDeactivationDto,
ConfirmDeactivationDto,
} from "./auth.dto";
import { AuditLogService } from "../audit-logs/audit-log.service";
import { AUDIT_ACTIONS } from "../../common/constants/audit-action.constant";
......@@ -374,4 +376,60 @@ export class AuthController {
next(error);
}
};
requestDeactivation = async (
req: Request,
res: Response,
next: NextFunction,
) => {
try {
const requestDto: RequestDeactivationDto = req.body;
await this.service.requestDeactivation(req.user.id, requestDto);
await this.auditLogService.log({
userId: req.user.id,
action: AUDIT_ACTIONS.REQUEST_DEACTIVATE_ACCOUNT,
ipAddress: req.ip,
userAgent: req.headers["user-agent"] as string,
details: { email: req.user.email },
});
res.json({
success: true,
message:
"Email xác nhận vô hiệu hóa tài khoản đã được gửi. Vui lòng kiểm tra hộp thư của bạn.",
});
} catch (error) {
next(error);
}
};
confirmDeactivation = async (
req: Request,
res: Response,
next: NextFunction,
) => {
try {
const confirmDto: ConfirmDeactivationDto = req.body;
const result = await this.service.confirmDeactivation(confirmDto);
await this.auditLogService.log({
userId: result.userId,
action: AUDIT_ACTIONS.CONFIRM_DEACTIVATE_ACCOUNT,
ipAddress: req.ip,
userAgent: req.headers["user-agent"] as string,
details: { email: result.email },
});
res.clearCookie("accessToken");
res.clearCookie("refreshToken");
res.json({
success: true,
message: "Tài khoản của bạn đã được vô hiệu hóa thành công.",
});
} catch (error) {
next(error);
}
};
}
......@@ -79,3 +79,11 @@ export interface VerifyEmailDto {
export interface ResendVerificationDto {
email: string;
}
export interface RequestDeactivationDto {
password: string;
}
export interface ConfirmDeactivationDto {
token: string;
}
import { prisma } from "../../database/prisma.client";
import { ROLES } from "../../common/constants/role.constant";
export class AuthRepository {
findByEmail(email: string) {
......@@ -24,7 +25,7 @@ export class AuthRepository {
email: data.email,
passwordHash: data.passwordHash,
fullName: data.fullName,
role: "CRAWLER_USER",
role: ROLES.CRAWLER_USER,
isActive: data.isActive ?? true,
},
});
......@@ -86,4 +87,41 @@ export class AuthRepository {
where: { userId },
});
}
async countActiveAdmins(): Promise<number> {
return prisma.user.count({
where: {
role: ROLES.ADMIN,
isActive: true,
deletedAt: null,
},
});
}
async deactivateUser(userId: string): Promise<void> {
await prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: userId },
data: {
isActive: false,
deletedAt: new Date(),
deletedBy: userId,
},
});
await tx.refreshToken.deleteMany({
where: { userId },
});
await tx.apiKey.updateMany({
where: { userId, isActive: true },
data: { isActive: false },
});
await tx.crawlSchedule.updateMany({
where: { userId, isActive: true },
data: { isActive: false },
});
});
}
}
......@@ -17,6 +17,8 @@ import {
verifyEmailSchema,
resendVerificationSchema,
changePasswordSchema,
requestDeactivationSchema,
confirmDeactivationSchema,
} from "./auth.validation";
const router = Router();
......@@ -100,4 +102,23 @@ router.post("/verify-email", validate(verifyEmailSchema), (req, res, next) => {
controller.verifyEmail(req, res, next);
});
router.post(
"/deactivate/request",
authMiddleware,
authRateLimiter,
validate(requestDeactivationSchema),
(req, res, next) => {
controller.requestDeactivation(req, res, next);
},
);
router.post(
"/deactivate/confirm",
authRateLimiter,
validate(confirmDeactivationSchema),
(req, res, next) => {
controller.confirmDeactivation(req, res, next);
},
);
export default router;
......@@ -15,10 +15,13 @@ import {
ForgotPasswordDto,
ResetPasswordDto,
ChangePasswordDto,
RequestDeactivationDto,
ConfirmDeactivationDto,
} from "./auth.dto";
import { MailService } from "../mail/mail.service";
import { CrawlJobRepository } from "../crawl-jobs/crawl-job.repository";
import { JOB_STATUS } from "../../common/constants/job-status.constant";
import { ROLES } from "../../common/constants/role.constant";
import { DEFAULT_TIMEZONE } from "../../common/constants/timezone.constant";
import {
getZonedDateParts,
......@@ -643,4 +646,155 @@ export class AuthService {
return { success: true, userId: user.id };
}
async requestDeactivation(
userId: string,
data: RequestDeactivationDto,
): Promise<{ success: boolean }> {
const user = await this.repository.findById(userId);
if (!user || !user.isActive) {
throw new AppError(
"Người dùng không tồn tại hoặc tài khoản đã bị vô hiệu hóa.",
404,
ERROR_CODE.NOT_FOUND,
);
}
if (user.role === ROLES.ADMIN) {
const activeAdmins = await this.repository.countActiveAdmins();
if (activeAdmins <= 1) {
throw new AppError(
"Không thể vô hiệu hóa tài khoản Quản trị viên duy nhất trong hệ thống.",
400,
ERROR_CODE.VALIDATION_ERROR,
);
}
}
const isPasswordValid = await bcrypt.compare(
data.password,
user.passwordHash,
);
if (!isPasswordValid) {
throw new AppError(
"Mật khẩu xác nhận không chính xác.",
401,
ERROR_CODE.INVALID_CREDENTIALS,
);
}
const deactivationToken = jwt.sign(
{
id: user.id,
email: user.email,
purpose: "deactivate-account",
},
`${jwtConfig.accessSecret}:deactivate:${user.passwordHash}`,
{ expiresIn: "15m" },
);
try {
await this.mailService.sendDeactivationEmail(
user.email,
deactivationToken,
);
} catch (error: unknown) {
const mailError = error as { code?: string; responseCode?: number };
console.error(
`[Mail] Deactivation delivery failed: ${mailError.code ?? "UNKNOWN"}${mailError.responseCode ? ` (SMTP ${mailError.responseCode})` : ""}`,
);
throw new AppError(
"Không thể gửi email xác nhận vô hiệu hóa. Vui lòng thử lại sau.",
503,
ERROR_CODE.MAIL_DELIVERY_FAILED,
);
}
if (process.env.NODE_ENV !== "production") {
const { mailConfig } = await import("../../config/mail.config");
console.log(
`[DEV ONLY] Deactivation Link: ${mailConfig.frontendUrl}/deactivate-account?token=${deactivationToken}`,
);
}
return { success: true };
}
async confirmDeactivation(
data: ConfirmDeactivationDto,
): Promise<{ success: boolean; userId: string; email: string }> {
const { token } = data;
let untrustedPayload: AuthJwtPayload | null = null;
try {
untrustedPayload = jwt.decode(token) as AuthJwtPayload | null;
} catch {
throw new AppError(
"Mã xác nhận không hợp lệ.",
400,
ERROR_CODE.TOKEN_INVALID,
);
}
if (
!untrustedPayload ||
!untrustedPayload.id ||
untrustedPayload.purpose !== "deactivate-account"
) {
throw new AppError(
"Mã xác nhận không hợp lệ.",
400,
ERROR_CODE.TOKEN_INVALID,
);
}
const user = await this.repository.findById(untrustedPayload.id);
if (!user || !user.isActive) {
throw new AppError(
"Người dùng không tồn tại hoặc tài khoản đã bị vô hiệu hóa.",
400,
ERROR_CODE.USER_INACTIVE,
);
}
try {
jwt.verify(
token,
`${jwtConfig.accessSecret}:deactivate:${user.passwordHash}`,
);
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
throw new AppError(
"Mã xác nhận vô hiệu hóa đã hết hạn.",
400,
ERROR_CODE.TOKEN_EXPIRED,
);
}
throw new AppError(
"Mã xác nhận không hợp lệ.",
400,
ERROR_CODE.TOKEN_INVALID,
);
}
if (user.role === ROLES.ADMIN) {
const activeAdmins = await this.repository.countActiveAdmins();
if (activeAdmins <= 1) {
throw new AppError(
"Không thể vô hiệu hóa tài khoản Quản trị viên duy nhất trong hệ thống.",
400,
ERROR_CODE.VALIDATION_ERROR,
);
}
}
await this.repository.deactivateUser(user.id);
return {
success: true,
userId: user.id,
email: user.email,
};
}
}
......@@ -95,3 +95,11 @@ export const resendVerificationSchema = z.object({
export const verifyEmailSchema = z.object({
token: z.string().min(1, "Thiếu mã xác thực email."),
});
export const requestDeactivationSchema = z.object({
password: z.string().min(1, "Vui lòng nhập mật khẩu xác nhận."),
});
export const confirmDeactivationSchema = z.object({
token: z.string().min(1, "Thiếu mã xác nhận vô hiệu hóa."),
});
......@@ -83,4 +83,41 @@ export class MailService {
await this.transporter.sendMail(mailOptions);
}
async sendDeactivationEmail(email: string, token: string): Promise<void> {
const deactivateUrl = `${mailConfig.frontendUrl}/deactivate-account?token=${token}`;
const mailOptions = {
from: mailConfig.from,
to: email,
subject: "Confirm Account Deactivation - Data Crawler",
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e0e0e0; border-radius: 8px;">
<h2 style="color: #d9534f; text-align: center;">Confirm Account Deactivation</h2>
<p style="color: #555555; font-size: 16px; line-height: 1.5;">
We received a request to deactivate your Data Crawler account. Deactivating your account will immediately stop all active crawl schedules and revoke your API keys and active sessions.
</p>
<p style="color: #555555; font-size: 16px; line-height: 1.5;">
If you wish to proceed with deactivation, please click the confirmation button below:
</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${deactivateUrl}" style="background-color: #d9534f; color: #ffffff; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold; display: inline-block;">
Confirm Deactivation
</a>
</div>
<p style="color: #777777; font-size: 14px; line-height: 1.5;">
This link is valid for 15 minutes. If you did not request to deactivate your account, please ignore this email and change your password immediately.
</p>
<hr style="border: 0; border-top: 1px solid #eeeeee; margin: 20px 0;">
<p style="color: #999999; font-size: 12px; text-align: center;">
If you're having trouble clicking the button, copy and paste the URL below into your web browser:
<br>
<a href="${deactivateUrl}" style="color: #d9534f; word-break: break-all;">${deactivateUrl}</a>
</p>
</div>
`,
};
await this.transporter.sendMail(mailOptions);
}
}
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