Skip to content

Commit

Permalink
feat(api): Added support for changing email of users (#233)
Browse files Browse the repository at this point in the history
  • Loading branch information
rayaanoidPrime committed May 22, 2024
1 parent f16162a commit 5ea9a10
Show file tree
Hide file tree
Showing 10 changed files with 482 additions and 26 deletions.
21 changes: 2 additions & 19 deletions apps/api/src/auth/service/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ import {
import { PrismaService } from '../../prisma/prisma.service'
import createUser from '../../common/create-user'
import { AuthProvider } from '@prisma/client'
import generateOtp from '../../common/generate-otp'

@Injectable()
export class AuthService {
private readonly OTP_EXPIRY = 5 * 60 * 1000 // 5 minutes
private readonly logger: LoggerService

constructor(
Expand All @@ -39,24 +39,7 @@ export class AuthService {

const user = await this.createUserIfNotExists(email, AuthProvider.EMAIL_OTP)

const otp = await this.prisma.otp.upsert({
where: {
userId: user.id
},
update: {
code: BigInt(`0x${crypto.randomUUID().replace(/-/g, '')}`).toString().substring(0, 6),
expiresAt: new Date(new Date().getTime() + this.OTP_EXPIRY)
},
create: {
code: BigInt(`0x${crypto.randomUUID().replace(/-/g, '')}`).toString().substring(0, 6),
expiresAt: new Date(new Date().getTime() + this.OTP_EXPIRY),
user: {
connect: {
email
}
}
}
})
const otp = await generateOtp(email, user.id, this.prisma)

await this.mailService.sendOtp(email, otp.code)
this.logger.log(`Login code sent to ${email}`)
Expand Down
34 changes: 34 additions & 0 deletions apps/api/src/common/generate-otp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Otp, PrismaClient, User } from '@prisma/client'

const OTP_EXPIRY = 5 * 60 * 1000 // 5 minutes

export default async function generateOtp(
email: User['email'],
userId: User['id'],
prisma: PrismaClient
): Promise<Otp> {
const otp = await prisma.otp.upsert({
where: {
userId: userId
},
update: {
code: BigInt(`0x${crypto.randomUUID().replace(/-/g, '')}`)
.toString()
.substring(0, 6),
expiresAt: new Date(new Date().getTime() + OTP_EXPIRY)
},
create: {
code: BigInt(`0x${crypto.randomUUID().replace(/-/g, '')}`)
.toString()
.substring(0, 6),
expiresAt: new Date(new Date().getTime() + OTP_EXPIRY),
user: {
connect: {
email
}
}
}
})

return otp
}
2 changes: 2 additions & 0 deletions apps/api/src/mail/services/interface.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ export const MAIL_SERVICE = 'MAIL_SERVICE'
export interface IMailService {
sendOtp(email: string, otp: string): Promise<void>

sendEmailChangedOtp(email: string, otp: string): Promise<void>

workspaceInvitationMailForUsers(
email: string,
workspace: string,
Expand Down
23 changes: 22 additions & 1 deletion apps/api/src/mail/services/mail.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,28 @@ export class MailService implements IMailService {
`
await this.sendEmail(email, subject, body)
}

async sendEmailChangedOtp(email: string, otp: string): Promise<void> {
const subject = 'Your OTP for Email Change'
const body = `<!DOCTYPE html>
<html>
<head>
<title>OTP Verification</title>
</head>
<body>
<h1>Are you trying to change your email?</h1>
<p>Hello there!</p>
<p>We have sent you this email to verify your new email.</p>
<p>Your One Time Password (OTP) is: <strong>${otp}</strong></p>
<p>This OTP will expire in <strong>5 minutes</strong>.</p>
<p>Please enter this OTP in the application to verify your new email.</p>
<p>Thank you.</p>
<p>Best Regards,</p>
<p>keyshade Team</p>
</body>
</html>
`
await this.sendEmail(email, subject, body)
}
async accountLoginEmail(email: string): Promise<void> {
const subject = 'LogIn Invitation Accepted'
const body = `<!DOCTYPE html>
Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/mail/services/mock.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,8 @@ export class MockMailService implements IMailService {
async feedbackEmail(email: string, feedback: string): Promise<void> {
this.log.log(`Feedback is : ${feedback}, for email : ${email}`)
}

async sendEmailChangedOtp(email: string, otp: string): Promise<void> {
this.log.log(`Email change OTP for email ${email} is ${otp}`)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
Warnings:
- The required column `id` was added to the `Otp` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.
*/
-- AlterTable
ALTER TABLE "Otp" ADD COLUMN "id" TEXT NOT NULL,
ADD CONSTRAINT "Otp_pkey" PRIMARY KEY ("id");

-- CreateTable
CREATE TABLE "UserEmailChange" (
"id" TEXT NOT NULL,
"otpId" TEXT NOT NULL,
"newEmail" TEXT NOT NULL,

CONSTRAINT "UserEmailChange_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "UserEmailChange_otpId_key" ON "UserEmailChange"("otpId");

-- AddForeignKey
ALTER TABLE "UserEmailChange" ADD CONSTRAINT "UserEmailChange_otpId_fkey" FOREIGN KEY ("otpId") REFERENCES "Otp"("id") ON DELETE CASCADE ON UPDATE CASCADE;
19 changes: 14 additions & 5 deletions apps/api/src/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -452,11 +452,13 @@ model ApiKey {
}

model Otp {
code String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
userId String @unique
createdAt DateTime @default(now())
expiresAt DateTime
id String @id @default(cuid())
code String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
userId String @unique
createdAt DateTime @default(now())
expiresAt DateTime
emailChange UserEmailChange?
@@unique([userId, code], name: "userCode")
@@index([expiresAt], name: "expiresAt")
Expand Down Expand Up @@ -518,3 +520,10 @@ model ChangeNotificationSocketMap {
@@index([environmentId, socketId])
}

model UserEmailChange {
id String @id @default(cuid())
otp Otp @relation(fields: [otpId], references: [id], onDelete: Cascade, onUpdate: Cascade)
otpId String @unique
newEmail String
}
13 changes: 13 additions & 0 deletions apps/api/src/user/controller/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,4 +143,17 @@ export class UserController {
async createUser(@Body() dto: CreateUserDto) {
return await this.userService.createUser(dto)
}

@Post('validate-email-change-otp')
async validateEmailChangeOtp(
@CurrentUser() user: User,
@Query('otp') otp: string
) {
return await this.userService.validateEmailChangeOtp(user, otp.trim())
}

@Post('resend-email-change-otp')
async resendEmailChangeOtp(@CurrentUser() user: User) {
return await this.userService.resendEmailChangeOtp(user)
}
}
141 changes: 140 additions & 1 deletion apps/api/src/user/service/user.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { ConflictException, Inject, Injectable, Logger } from '@nestjs/common'
import {
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'
Expand All @@ -8,6 +14,7 @@ import {
MAIL_SERVICE
} from '../../mail/services/interface.service'
import createUser from '../../common/create-user'
import generateOtp from '../../common/generate-otp'

@Injectable()
export class UserService {
Expand All @@ -32,6 +39,36 @@ export class UserService {
profilePictureUrl: dto?.profilePictureUrl,
isOnboardingFinished: dto.isOnboardingFinished
}
if (dto?.email) {
const userExists =
(await this.prisma.user.count({
where: {
email: dto.email
}
})) > 0

if (userExists) {
throw new ConflictException('User with this email already exists')
}

const otp = await generateOtp(user.email, user.id, this.prisma)

await this.prisma.userEmailChange.upsert({
where: {
otpId: otp.id
},
update: {
newEmail: dto.email
},
create: {
newEmail: dto.email,
otpId: otp.id
}
})

await this.mailService.sendEmailChangedOtp(dto.email, otp.code)
}

this.log.log(`Updating user ${user.id} with data ${dto}`)
const updatedUser = await this.prisma.user.update({
where: {
Expand All @@ -51,6 +88,31 @@ export class UserService {
isActive: dto.isActive,
isOnboardingFinished: dto.isOnboardingFinished
}

if (dto.email) {
const userExists =
(await this.prisma.user.count({
where: {
email: dto.email
}
})) > 0

if (userExists) {
throw new ConflictException('User with this email already exists')
}

//directly updating email when admin triggered
await this.prisma.user.update({
where: {
id: userId
},
data: {
email: dto.email,
authProvider: AuthProvider.EMAIL_OTP
}
})
}

this.log.log(`Updating user ${userId} with data ${dto}`)
return await this.prisma.user.update({
where: {
Expand All @@ -60,6 +122,83 @@ export class UserService {
})
}

async validateEmailChangeOtp(user: User, otpCode: string): Promise<User> {
const otp = await this.prisma.otp.findUnique({
where: {
userId: user.id,
code: otpCode
}
})

if (!otp || otp.expiresAt < new Date()) {
this.log.log(`OTP expired or invalid`)
throw new UnauthorizedException('Invalid or expired OTP')
}
const userEmailChange = await this.prisma.userEmailChange.findUnique({
where: {
otpId: otp.id
}
})

const deleteEmailChangeRecord = this.prisma.userEmailChange.delete({
where: {
otpId: otp.id
}
})

const deleteOtp = this.prisma.otp.delete({
where: {
userId: user.id,
code: otpCode
}
})

const updateUserOp = this.prisma.user.update({
where: {
id: user.id
},
data: {
email: userEmailChange.newEmail,
authProvider: AuthProvider.EMAIL_OTP
}
})

this.log.log(
`Changing email to ${userEmailChange.newEmail} for user ${user.id}`
)
const results = await this.prisma.$transaction([
deleteEmailChangeRecord,
deleteOtp,
updateUserOp
])

return results[2]
}

async resendEmailChangeOtp(user: User) {
const oldOtp = await this.prisma.otp.findUnique({
where: {
userId: user.id
},
include: {
emailChange: true
}
})

if (!oldOtp?.emailChange) {
throw new ConflictException(
`No previous OTP for email change exists for user ${user.id}`
)
}

const newOtp = await generateOtp(user.email, user.id, this.prisma)

await this.mailService.sendEmailChangedOtp(
oldOtp.emailChange.newEmail,
newOtp.code
)
}

async getUserById(userId: string) {
return await this.prisma.user.findUnique({
where: {
Expand Down
Loading

0 comments on commit 5ea9a10

Please sign in to comment.