feat(api): implement email verification

This commit is contained in:
isra el
2025-01-06 06:16:46 +03:00
parent 9409d162ce
commit 53a46bd2c4
6 changed files with 241 additions and 0 deletions

View File

@@ -143,4 +143,23 @@ export class AuthController {
async resetPassword(@Body() input: ResetPasswordInputDTO) {
return await this.authService.resetPassword(input)
}
// send email verification code
@ApiOperation({ summary: 'Send Email Verification Code' })
@HttpCode(HttpStatus.OK)
@ApiBearerAuth()
@UseGuards(AuthGuard)
@Post('/send-email-verification-email')
async sendEmailVerificationEmail(@Request() req) {
return await this.authService.sendEmailVerificationEmail(req.user)
}
@ApiOperation({ summary: 'Verify Email' })
@HttpCode(HttpStatus.OK)
@ApiBearerAuth()
@UseGuards(AuthGuard)
@Post('/verify-email')
async verifyEmail(@Body() input: { userId: string; verificationCode: string }) {
return await this.authService.verifyEmail(input)
}
}

View File

@@ -13,6 +13,7 @@ import {
PasswordResetSchema,
} from './schemas/password-reset.schema'
import { AccessLog, AccessLogSchema } from './schemas/access-log.schema'
import { EmailVerification, EmailVerificationSchema } from './schemas/email-verification.schema'
@Module({
imports: [
@@ -29,6 +30,10 @@ import { AccessLog, AccessLogSchema } from './schemas/access-log.schema'
name: AccessLog.name,
schema: AccessLogSchema,
},
{
name: EmailVerification.name,
schema: EmailVerificationSchema,
},
]),
UsersModule,
PassportModule,

View File

@@ -15,6 +15,11 @@ import {
import { MailService } from 'src/mail/mail.service'
import { RequestResetPasswordInputDTO, ResetPasswordInputDTO } from './auth.dto'
import { AccessLog } from './schemas/access-log.schema'
import {
EmailVerification,
EmailVerificationDocument,
} from './schemas/email-verification.schema'
@Injectable()
export class AuthService {
constructor(
@@ -24,6 +29,8 @@ export class AuthService {
@InjectModel(PasswordReset.name)
private passwordResetModel: Model<PasswordResetDocument>,
@InjectModel(AccessLog.name) private accessLogModel: Model<AccessLog>,
@InjectModel(EmailVerification.name)
private emailVerificationModel: Model<EmailVerificationDocument>,
private readonly mailService: MailService,
) {}
@@ -79,6 +86,10 @@ export class AuthService {
user.googleId = googleId
}
if (!user.emailVerifiedAt) {
user.emailVerifiedAt = new Date()
}
if (user.name !== name) {
user.name = name
}
@@ -128,6 +139,11 @@ export class AuthService {
from: 'vernu vernu@textbee.dev',
})
this.sendEmailVerificationEmail(user).catch((e) => {
console.log('Failed to send email verification email')
console.log(e)
})
const payload = { email: user.email, sub: user._id }
return {
@@ -226,6 +242,67 @@ export class AuthService {
await userToUpdate.save()
}
async sendEmailVerificationEmail(user: UserDocument) {
const verificationCode = uuidv4()
const expiresAt = new Date(Date.now() + 20 * 60 * 1000) // 20 minutes
const hashedVerificationCode = await bcrypt.hash(verificationCode, 10)
const emailVerification = new this.emailVerificationModel({
user: user._id,
verificationCode: hashedVerificationCode,
expiresAt,
})
await emailVerification.save()
const verificationLink = `${process.env.FRONTEND_URL || 'https://textbee.dev'}/verify-email?userId=${user._id}&verificationCode=${verificationCode}`
await this.mailService.sendEmailFromTemplate({
to: user.email,
subject: 'textbee.dev - Verify Email',
template: 'verify-email',
context: {
name: user.name,
verificationLink,
},
})
return { message: 'Email verification email sent' }
}
async verifyEmail({ userId, verificationCode }) {
const user: UserDocument = await this.usersService.findOne({ _id: userId })
if (!user) {
throw new HttpException({ error: 'User not found' }, HttpStatus.NOT_FOUND)
}
const emailVerification = await this.emailVerificationModel.findOne(
{
user: user._id,
expiresAt: { $gt: new Date() },
},
null,
{ sort: { createdAt: -1 } },
)
if (
!emailVerification ||
!bcrypt.compareSync(verificationCode, emailVerification.verificationCode)
) {
throw new HttpException(
{ error: 'Invalid verification code' },
HttpStatus.BAD_REQUEST,
)
}
if (user.emailVerifiedAt) {
return { message: 'Email already verified' }
}
user.emailVerifiedAt = new Date()
await user.save()
return { message: 'Email verified successfully' }
}
async generateApiKey(currentUser: User) {
const apiKey = uuidv4()
const hashedApiKey = await bcrypt.hash(apiKey, 10)

View File

@@ -0,0 +1,22 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { Document, Types } from 'mongoose'
import { User } from '../../users/schemas/user.schema'
export type EmailVerificationDocument = EmailVerification & Document
@Schema({ timestamps: true })
export class EmailVerification {
_id?: Types.ObjectId
@Prop({ type: Types.ObjectId, ref: User.name })
user: User
@Prop({ type: String })
verificationCode: string // hashed
@Prop({ type: Date })
expiresAt: Date
}
export const EmailVerificationSchema =
SchemaFactory.createForClass(EmailVerification)

View File

@@ -0,0 +1,115 @@
<html></html>
<head>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.container {
background-color: #ffffff;
border-radius: 8px;
padding: 30px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
margin-bottom: 30px;
}
.reset-button {
display: inline-block;
background-color: #007bff;
color: white;
padding: 12px 24px;
text-decoration: none;
border-radius: 4px;
margin: 20px 0;
}
.reset-button:hover {
background-color: #0056b3;
}
.divider {
border-top: 1px solid #e0e0e0;
margin: 20px 0;
}
.community-section {
text-align: center;
}
</style>
</head>
<body>
<div class='container'>
<div class='header'>
<h1>Verify Your Email Address</h1>
</div>
<p>Hello {{name}},</p>
<p>Welcome to TextBee! To complete your registration and verify your email address, please click the button below.</p>
<div style='text-align: center;'>
<a href='{{verificationLink}}' class='reset-button'>Verify Email</a>
</div>
<div class='divider'></div>
<p>If the button above doesn't work, you can copy and paste the following link into your browser:</p>
<p style='word-break: break-all;'>{{verificationLink}}</p>
<div class='divider'></div>
<div class='footer'>
<p>
Thank you,<br />
<strong>TextBee.dev</strong>
</p>
<p style='font-size: 12px; color: #999;'>
If you didn't create an account with TextBee, you can safely ignore this email.
</p>
<div class='divider'></div>
<div class='community-section'>
<p style='font-size: 14px; margin-bottom: 15px;'>Join our community!</p>
<a
href='https://discord.gg/d7vyfBpWbQ'
style='display: inline-block; margin: 0 10px; color: #7289DA; text-decoration: none;'
>
<img
src='https://cdn.prod.website-files.com/6257adef93867e50d84d30e2/636e0a6918e57475a843f59f_icon_clyde_black_RGB.svg'
alt='Discord'
style='width: 20px; vertical-align: middle;'
/>
Join Discord
</a>
<a
href='https://github.com/vernu/textbee'
style='display: inline-block; margin: 0 10px; color: #333; text-decoration: none;'
>
<img
src='https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png'
alt='GitHub'
style='width: 20px; vertical-align: middle;'
/>
Star on GitHub
</a>
<a
href='https://patreon.com/vernu'
style='display: inline-block; margin: 0 10px; color: #FF424D; text-decoration: none;'
>
<img
src='https://c5.patreon.com/external/logo/downloads_logomark_color_on_white.png'
alt='Patreon'
style='width: 20px; vertical-align: middle;'
/>
Support on Patreon
</a>
</div>
</div>
</div>
</body>
</html>

View File

@@ -31,6 +31,9 @@ export class User {
@Prop({ type: Date })
lastLoginAt: Date
@Prop({ type: Date })
emailVerifiedAt: Date
}
export const UserSchema = SchemaFactory.createForClass(User)