From adc623abc8f005c06720922301ab5edc14cd6565 Mon Sep 17 00:00:00 2001 From: rayaanoidPrime Date: Mon, 20 May 2024 12:26:52 +0530 Subject: [PATCH] feat(api): Added support for changing email of users --- .../migration.sql | 13 ++ apps/api/src/prisma/schema.prisma | 10 ++ .../src/user/controller/user.controller.ts | 13 ++ apps/api/src/user/service/user.service.ts | 116 +++++++++++- apps/api/src/user/user.e2e.spec.ts | 170 ++++++++++++++++++ 5 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/prisma/migrations/20240519122223_add_user_email_change/migration.sql diff --git a/apps/api/src/prisma/migrations/20240519122223_add_user_email_change/migration.sql b/apps/api/src/prisma/migrations/20240519122223_add_user_email_change/migration.sql new file mode 100644 index 00000000..ec6ecd93 --- /dev/null +++ b/apps/api/src/prisma/migrations/20240519122223_add_user_email_change/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE "UserEmailChange" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "newEmail" TEXT NOT NULL, + "otp" TEXT NOT NULL, + "createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "UserEmailChange_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "UserEmailChange_userId_key" ON "UserEmailChange"("userId"); diff --git a/apps/api/src/prisma/schema.prisma b/apps/api/src/prisma/schema.prisma index 09db8ca7..476177bb 100644 --- a/apps/api/src/prisma/schema.prisma +++ b/apps/api/src/prisma/schema.prisma @@ -518,3 +518,13 @@ model ChangeNotificationSocketMap { @@index([environmentId, socketId]) } + +model UserEmailChange { + id String @id @default(cuid()) + userId String + newEmail String + otp String + createdOn DateTime @default(now()) + + @@unique([userId]) +} \ No newline at end of file diff --git a/apps/api/src/user/controller/user.controller.ts b/apps/api/src/user/controller/user.controller.ts index 5b3c9c98..8acaf621 100644 --- a/apps/api/src/user/controller/user.controller.ts +++ b/apps/api/src/user/controller/user.controller.ts @@ -143,4 +143,17 @@ export class UserController { async createUser(@Body() dto: CreateUserDto) { return await this.userService.createUser(dto) } + + @Post('validate-otp/:userId') + async validateOtp( + @Param('userId') userId: User['id'], + @Query('otp') otp: string + ) { + return await this.userService.validateOtp(userId, otp.trim()) + } + + @Post('resend-otp/:userId') + async resendOtp(@Param('userId') userId: User['id']) { + return await this.userService.resendOtp(userId) + } } diff --git a/apps/api/src/user/service/user.service.ts b/apps/api/src/user/service/user.service.ts index 0b5f00a7..c78d8b0b 100644 --- a/apps/api/src/user/service/user.service.ts +++ b/apps/api/src/user/service/user.service.ts @@ -1,4 +1,11 @@ -import { ConflictException, Inject, Injectable, Logger } from '@nestjs/common' +import { + BadRequestException, + ConflictException, + Inject, + Injectable, + Logger, + UnauthorizedException +} from '@nestjs/common' import { UpdateUserDto } from '../dto/update.user/update.user' import { AuthProvider, User } from '@prisma/client' import { PrismaService } from '../../prisma/prisma.service' @@ -8,10 +15,12 @@ import { MAIL_SERVICE } from '../../mail/services/interface.service' import createUser from '../../common/create-user' +import { randomUUID } from 'crypto' @Injectable() export class UserService { private readonly log = new Logger(UserService.name) + private readonly OTP_EXPIRY = 5 * 60 * 1000 // 5 minutes constructor( private readonly prisma: PrismaService, @@ -32,6 +41,28 @@ export class UserService { profilePictureUrl: dto?.profilePictureUrl, isOnboardingFinished: dto.isOnboardingFinished } + if (dto?.email) { + const userwithEmail = await this.prisma.user.findFirst({ + where: { + email: dto.email + } + }) + + if (userwithEmail) { + throw new ConflictException('User with this email already exists') + } + + const otp = await this.prisma.userEmailChange.create({ + data: { + newEmail: dto.email, + userId: user.id, + otp: randomUUID().slice(0, 6).toUpperCase() + } + }) + + await this.mailService.sendOtp(dto.email, otp.otp) + } + this.log.log(`Updating user ${user.id} with data ${dto}`) const updatedUser = await this.prisma.user.update({ where: { @@ -51,6 +82,29 @@ export class UserService { isActive: dto.isActive, isOnboardingFinished: dto.isOnboardingFinished } + + if (dto.email) { + const userwithEmail = await this.prisma.user.findFirst({ + where: { + email: dto.email + } + }) + + if (userwithEmail) { + throw new ConflictException('User with this email already exists') + } + + const otp = await this.prisma.userEmailChange.create({ + data: { + newEmail: dto.email, + userId: userId, + otp: randomUUID().slice(0, 6).toUpperCase() + } + }) + + await this.mailService.sendOtp(dto.email, otp.otp) + } + this.log.log(`Updating user ${userId} with data ${dto}`) return await this.prisma.user.update({ where: { @@ -60,6 +114,66 @@ export class UserService { }) } + async validateOtp(userId: User['id'], otp: string): Promise { + const user = await this.getUserById(userId) + if (!user) { + throw new BadRequestException(`User ${userId} does not exist`) + } + + const userEmailChange = await this.prisma.userEmailChange.findUnique({ + where: { + otp: otp, + userId: userId, + createdOn: { + gt: new Date(new Date().getTime() - this.OTP_EXPIRY) + } + } + }) + + if (!userEmailChange) { + this.log.log(`OTP expired or invalid`) + throw new UnauthorizedException('Invalid or expired OTP') + } + + await this.prisma.userEmailChange.delete({ + where: { + userId: userId, + otp: otp + } + }) + + this.log.log( + `Changing email to ${userEmailChange.newEmail} for user ${userId}` + ) + return await this.prisma.user.update({ + where: { + id: userId + }, + data: { + email: userEmailChange.newEmail + } + }) + } + + async resendOtp(userId: User['id']) { + const user = await this.getUserById(userId) + if (!user) { + throw new BadRequestException(`User ${userId} does not exist`) + } + + const newOtp = await this.prisma.userEmailChange.update({ + where: { + userId: userId + }, + data: { + otp: randomUUID().slice(0, 6).toUpperCase(), + createdOn: new Date() + } + }) + + await this.mailService.sendOtp(newOtp.newEmail, newOtp.otp) + } + async getUserById(userId: string) { return await this.prisma.user.findUnique({ where: { diff --git a/apps/api/src/user/user.e2e.spec.ts b/apps/api/src/user/user.e2e.spec.ts index 3ccf6121..c24b53d2 100644 --- a/apps/api/src/user/user.e2e.spec.ts +++ b/apps/api/src/user/user.e2e.spec.ts @@ -321,6 +321,176 @@ describe('User Controller Tests', () => { expect(result.statusCode).toEqual(204) }) + it('should send otp when user changes email', async () => { + const result = await app.inject({ + method: 'PUT', + url: `/user`, + headers: { + 'x-e2e-user-email': regularUser.email + }, + payload: { + email: 'newEmail@keyshade.xyz' + } + }) + + expect(result.statusCode).toEqual(200) + expect(JSON.parse(result.body)).toEqual({ + ...regularUser + }) + + const userEmailChange = await prisma.userEmailChange.findMany({ + where: { + userId: regularUser.id, + newEmail: 'newEmail@keyshade.xyz' + } + }) + + expect(userEmailChange.length).toEqual(1) + }) + + it('should send otp when admin changes an user email', async () => { + const result = await app.inject({ + method: 'PUT', + url: `/user/${regularUser.id}`, + headers: { + 'x-e2e-user-email': adminUser.email + }, + payload: { + email: 'newEmail@keyshade.xyz' + } + }) + + expect(result.statusCode).toEqual(200) + expect(JSON.parse(result.body)).toEqual({ + ...regularUser + }) + + const userEmailChange = await prisma.userEmailChange.findMany({ + where: { + userId: regularUser.id, + newEmail: 'newEmail@keyshade.xyz' + } + }) + + expect(userEmailChange.length).toEqual(1) + }) + + it('should give error when new email is used by an existing user', async () => { + const result = await app.inject({ + method: 'PUT', + url: `/user`, + headers: { + 'x-e2e-user-email': regularUser.email + }, + payload: { + email: 'john@keyshade.xyz' + } + }) + + expect(result.statusCode).toEqual(409) + }) + + it('should validate OTP successfully', async () => { + await prisma.userEmailChange.create({ + data: { + newEmail: 'newjohn@keyshade.xyz', + userId: regularUser.id, + otp: '123456' + } + }) + + const result = await app.inject({ + method: 'POST', + url: `/user/validate-otp/${regularUser.id}`, + query: { + otp: '123456' + }, + headers: { + 'x-e2e-user-email': regularUser.email + } + }) + + expect(result.statusCode).toEqual(201) + expect(JSON.parse(result.body)).toEqual({ + ...regularUser, + email: 'newjohn@keyshade.xyz' + }) + + const updatedUser = await prisma.user.findUnique({ + where: { + id: regularUser.id + } + }) + + expect(updatedUser.email).toEqual('newjohn@keyshade.xyz') + }) + + it('should fail to validate expired or invalid OTP', async () => { + await prisma.userEmailChange.create({ + data: { + newEmail: 'newjohn@keyshade.xyz', + userId: regularUser.id, + otp: '123456', + createdOn: new Date(new Date().getTime() - (5 * 60 * 1000 + 1)) // Expired OTP + } + }) + + const result = await app.inject({ + method: 'POST', + url: `/user/validate-otp/${regularUser.id}`, + query: { + otp: '123456' + }, + headers: { + 'x-e2e-user-email': regularUser.email + } + }) + + expect(result.statusCode).toEqual(401) + expect(JSON.parse(result.body)).toEqual({ + message: 'Invalid or expired OTP', + error: 'Unauthorized', + statusCode: 401 + }) + + const nonUpdatedUser = await prisma.user.findUnique({ + where: { + id: regularUser.id + } + }) + + expect(nonUpdatedUser.email).toEqual('john@keyshade.xyz') + }) + + it('should resend OTP successfully', async () => { + await prisma.userEmailChange.create({ + data: { + newEmail: 'newjohn@keyshade.xyz', + userId: regularUser.id, + otp: '123456' + } + }) + + const result = await app.inject({ + method: 'POST', + url: `/user/resend-otp/${regularUser.id}`, + headers: { + 'x-e2e-user-email': regularUser.email + } + }) + + expect(result.statusCode).toEqual(201) + + const updatedEmailChange = await prisma.userEmailChange.findFirst({ + where: { + userId: regularUser.id, + newEmail: 'newjohn@keyshade.xyz' + } + }) + + expect(updatedEmailChange.otp).not.toEqual('123456') + }) + // test('user should be able to delete their own account', async () => { // const result = await app.inject({ // method: 'DELETE',