mirror of
https://github.com/vernu/textbee.git
synced 2026-02-20 07:34:00 -05:00
feat(api): implement email verification
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
22
api/src/auth/schemas/email-verification.schema.ts
Normal file
22
api/src/auth/schemas/email-verification.schema.ts
Normal 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)
|
||||
115
api/src/mail/templates/verify-email.hbs
Normal file
115
api/src/mail/templates/verify-email.hbs
Normal 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>
|
||||
@@ -31,6 +31,9 @@ export class User {
|
||||
|
||||
@Prop({ type: Date })
|
||||
lastLoginAt: Date
|
||||
|
||||
@Prop({ type: Date })
|
||||
emailVerifiedAt: Date
|
||||
}
|
||||
|
||||
export const UserSchema = SchemaFactory.createForClass(User)
|
||||
|
||||
Reference in New Issue
Block a user