From 2c4cc1a94baf60e5fec73e98f97738acf8d34698 Mon Sep 17 00:00:00 2001 From: phertyameen Date: Mon, 29 Jun 2026 16:00:39 +0100 Subject: [PATCH] feat: add email verification, password reset, profile CRUD, admin user mgmt BE-12 Add email verification flow (#737) BE-13 Add forgot/reset password flow (#738) BE-14 Add user profile update/delete endpoints (#739) BE-15 Add admin user management endpoints (#740) --- backend/src/admin/admin.controller.ts | 39 +++++++++++++- backend/src/admin/admin.service.ts | 30 ++++++++++- backend/src/admin/dto/update-user-role.dto.ts | 8 +++ backend/src/auth/auth.controller.ts | 24 +++++++++ backend/src/auth/auth.service.ts | 52 +++++++++++++++++++ backend/src/auth/dto/forgot-password.dto.ts | 6 +++ backend/src/auth/dto/reset-password.dto.ts | 13 +++++ backend/src/auth/dto/send-verification.dto.ts | 6 +++ backend/src/auth/dto/verify-email.dto.ts | 9 ++++ backend/src/users/dto/update-user.dto.ts | 11 ++++ backend/src/users/users.controller.ts | 19 ++++++- 11 files changed, 213 insertions(+), 4 deletions(-) create mode 100644 backend/src/admin/dto/update-user-role.dto.ts create mode 100644 backend/src/auth/dto/forgot-password.dto.ts create mode 100644 backend/src/auth/dto/reset-password.dto.ts create mode 100644 backend/src/auth/dto/send-verification.dto.ts create mode 100644 backend/src/auth/dto/verify-email.dto.ts create mode 100644 backend/src/users/dto/update-user.dto.ts diff --git a/backend/src/admin/admin.controller.ts b/backend/src/admin/admin.controller.ts index e40f167..aa89f19 100644 --- a/backend/src/admin/admin.controller.ts +++ b/backend/src/admin/admin.controller.ts @@ -1,8 +1,18 @@ -import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { + Controller, + Get, + Patch, + Delete, + Param, + Body, + Query, + UseGuards, +} from '@nestjs/common'; import { AdminService } from './admin.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; import { Roles } from '../auth/guards/roles.decorator'; +import { UpdateUserRoleDto } from './dto/update-user-role.dto'; @Controller('admin') @UseGuards(JwtAuthGuard, RolesGuard) @@ -29,4 +39,31 @@ export class AdminController { async getQueueStats() { return this.adminService.getQueueStats(); } + + @Get('users') + async listUsers( + @Query('page') page?: number, + @Query('limit') limit?: number, + ) { + return this.adminService.listUsers(page, limit); + } + + @Get('users/:id') + async getUser(@Param('id') id: string) { + return this.adminService.getUser(id); + } + + @Patch('users/:id/role') + async changeUserRole( + @Param('id') id: string, + @Body() dto: UpdateUserRoleDto, + ) { + return this.adminService.changeUserRole(id, dto.role); + } + + @Delete('users/:id') + async deleteUser(@Param('id') id: string) { + await this.adminService.deleteUser(id); + return { message: 'User deleted successfully' }; + } } diff --git a/backend/src/admin/admin.service.ts b/backend/src/admin/admin.service.ts index 917e5a8..580008f 100644 --- a/backend/src/admin/admin.service.ts +++ b/backend/src/admin/admin.service.ts @@ -1,7 +1,7 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, Like } from 'typeorm'; -import { User } from '../users/entities/user.entity'; +import { User, UserRole } from '../users/entities/user.entity'; import { Document, DocumentStatus } from '../documents/entities/document.entity'; import { QueueService } from '../queue/queue.service'; @@ -62,4 +62,30 @@ export class AdminService { async getQueueStats() { return this.queueService.getQueueStats(); } + + async listUsers(page = 1, limit = 20) { + const [data, total] = await this.userRepository.findAndCount({ + order: { createdAt: 'DESC' }, + skip: (page - 1) * limit, + take: limit, + }); + return { data, total, page, limit, totalPages: Math.ceil(total / limit) }; + } + + async getUser(id: string) { + const user = await this.userRepository.findOne({ where: { id } }); + if (!user) throw new NotFoundException('User not found'); + return user; + } + + async changeUserRole(id: string, role: UserRole) { + const user = await this.getUser(id); + user.role = role; + return this.userRepository.save(user); + } + + async deleteUser(id: string) { + const user = await this.getUser(id); + await this.userRepository.softDelete(user.id); + } } diff --git a/backend/src/admin/dto/update-user-role.dto.ts b/backend/src/admin/dto/update-user-role.dto.ts new file mode 100644 index 0000000..dc02b60 --- /dev/null +++ b/backend/src/admin/dto/update-user-role.dto.ts @@ -0,0 +1,8 @@ +import { IsEnum, IsNotEmpty } from 'class-validator'; +import { UserRole } from '../../users/entities/user.entity'; + +export class UpdateUserRoleDto { + @IsNotEmpty() + @IsEnum(UserRole) + role: UserRole; +} diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index 7754af3..31efab9 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -19,6 +19,10 @@ import { AuthService } from './auth.service'; import { RegisterAuthDto } from './dto/register-auth.dto'; import { LoginAuthDto } from './dto/login-auth.dto'; import { RefreshAuthDto } from './dto/refresh-auth.dto'; +import { SendVerificationDto } from './dto/send-verification.dto'; +import { VerifyEmailDto } from './dto/verify-email.dto'; +import { ForgotPasswordDto } from './dto/forgot-password.dto'; +import { ResetPasswordDto } from './dto/reset-password.dto'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; @Controller('auth') @@ -43,6 +47,26 @@ export class AuthController { return this.authService.refreshToken(dto); } + @Post('send-verification') + sendVerification(@Body() dto: SendVerificationDto) { + return this.authService.sendVerification(dto.email); + } + + @Post('verify-email') + verifyEmail(@Body() dto: VerifyEmailDto) { + return this.authService.verifyEmail(dto.email, dto.token); + } + + @Post('forgot-password') + forgotPassword(@Body() dto: ForgotPasswordDto) { + return this.authService.forgotPassword(dto.email); + } + + @Post('reset-password') + resetPassword(@Body() dto: ResetPasswordDto) { + return this.authService.resetPassword(dto.email, dto.token, dto.newPassword); + } + @Post('logout') @UseGuards(JwtAuthGuard) async logout( diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index ecb7a75..8dea403 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -4,6 +4,7 @@ Logger, UnauthorizedException, BadRequestException, + NotFoundException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; @@ -146,6 +147,57 @@ export class AuthService { return { message: 'Successfully logged out' }; } + private readonly verificationTokens = new Map(); + private readonly resetTokens = new Map(); + + async sendVerification(email: string) { + const user = await this.usersService.findByEmail(email); + if (!user) throw new NotFoundException('User not found'); + if (user.isVerified) throw new BadRequestException('Email already verified'); + + const token = crypto.randomBytes(32).toString('hex'); + this.verificationTokens.set(email, { token, expires: new Date(Date.now() + 3600_000) }); + + this.logger.log(`Verification token for ${email}: ${token}`); + return { message: 'Verification email sent' }; + } + + async verifyEmail(email: string, token: string) { + const stored = this.verificationTokens.get(email); + if (!stored || stored.token !== token) throw new BadRequestException('Invalid token'); + if (stored.expires < new Date()) throw new BadRequestException('Token expired'); + + const user = await this.usersService.findByEmail(email); + if (!user) throw new NotFoundException('User not found'); + await this.usersService.update(user.id, { isVerified: true } as any); + this.verificationTokens.delete(email); + return { message: 'Email verified successfully' }; + } + + async forgotPassword(email: string) { + const user = await this.usersService.findByEmail(email); + if (!user) throw new NotFoundException('User not found'); + + const token = crypto.randomBytes(32).toString('hex'); + this.resetTokens.set(email, { token, expires: new Date(Date.now() + 3600_000) }); + + this.logger.log(`Password reset token for ${email}: ${token}`); + return { message: 'Password reset email sent' }; + } + + async resetPassword(email: string, token: string, newPassword: string) { + const stored = this.resetTokens.get(email); + if (!stored || stored.token !== token) throw new BadRequestException('Invalid token'); + if (stored.expires < new Date()) throw new BadRequestException('Token expired'); + + const user = await this.usersService.findByEmail(email); + if (!user) throw new NotFoundException('User not found'); + const passwordHash = await bcrypt.hash(newPassword, 12); + await this.usersService.update(user.id, { passwordHash } as any); + this.resetTokens.delete(email); + return { message: 'Password reset successfully' }; + } + private hashToken(token: string): string { return crypto.createHash('sha256').update(token).digest('hex'); } diff --git a/backend/src/auth/dto/forgot-password.dto.ts b/backend/src/auth/dto/forgot-password.dto.ts new file mode 100644 index 0000000..bbedf08 --- /dev/null +++ b/backend/src/auth/dto/forgot-password.dto.ts @@ -0,0 +1,6 @@ +import { IsEmail } from 'class-validator'; + +export class ForgotPasswordDto { + @IsEmail() + email: string; +} diff --git a/backend/src/auth/dto/reset-password.dto.ts b/backend/src/auth/dto/reset-password.dto.ts new file mode 100644 index 0000000..b74db99 --- /dev/null +++ b/backend/src/auth/dto/reset-password.dto.ts @@ -0,0 +1,13 @@ +import { IsEmail, IsNotEmpty, MinLength } from 'class-validator'; + +export class ResetPasswordDto { + @IsEmail() + email: string; + + @IsNotEmpty() + token: string; + + @IsNotEmpty() + @MinLength(6) + newPassword: string; +} diff --git a/backend/src/auth/dto/send-verification.dto.ts b/backend/src/auth/dto/send-verification.dto.ts new file mode 100644 index 0000000..62d04c3 --- /dev/null +++ b/backend/src/auth/dto/send-verification.dto.ts @@ -0,0 +1,6 @@ +import { IsEmail } from 'class-validator'; + +export class SendVerificationDto { + @IsEmail() + email: string; +} diff --git a/backend/src/auth/dto/verify-email.dto.ts b/backend/src/auth/dto/verify-email.dto.ts new file mode 100644 index 0000000..4e189bc --- /dev/null +++ b/backend/src/auth/dto/verify-email.dto.ts @@ -0,0 +1,9 @@ +import { IsEmail, IsNotEmpty } from 'class-validator'; + +export class VerifyEmailDto { + @IsEmail() + email: string; + + @IsNotEmpty() + token: string; +} diff --git a/backend/src/users/dto/update-user.dto.ts b/backend/src/users/dto/update-user.dto.ts new file mode 100644 index 0000000..78e9a96 --- /dev/null +++ b/backend/src/users/dto/update-user.dto.ts @@ -0,0 +1,11 @@ +import { IsOptional, IsString } from 'class-validator'; + +export class UpdateUserDto { + @IsOptional() + @IsString() + fullName?: string; + + @IsOptional() + @IsString() + preferredLanguage?: string; +} diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts index 5b9a82c..9114e8e 100644 --- a/backend/src/users/users.controller.ts +++ b/backend/src/users/users.controller.ts @@ -1,8 +1,9 @@ -import { Controller, Get, Delete, Req, UseGuards } from '@nestjs/common'; +import { Controller, Get, Patch, Delete, Body, Req, UseGuards } from '@nestjs/common'; import { UsersService } from './users.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { Request } from 'express'; import { User } from './entities/user.entity'; +import { UpdateUserDto } from './dto/update-user.dto'; @Controller('users') export class UsersController { @@ -26,4 +27,20 @@ export class UsersController { await this.usersService.eraseUserData(req.user!.id); return { message: 'Your personal data has been erased.' }; } + + @Patch('me') + @UseGuards(JwtAuthGuard) + async updateProfile( + @Req() req: Request & { user?: User }, + @Body() dto: UpdateUserDto, + ) { + return this.usersService.update(req.user!.id, dto); + } + + @Delete('me') + @UseGuards(JwtAuthGuard) + async deleteAccount(@Req() req: Request & { user?: User }) { + await this.usersService.softDelete(req.user!.id); + return { message: 'Account deleted successfully' }; + } }