diff --git a/api/src/auth/auth.controller.ts b/api/src/auth/auth.controller.ts index f85a698..45477eb 100644 --- a/api/src/auth/auth.controller.ts +++ b/api/src/auth/auth.controller.ts @@ -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) + } } diff --git a/api/src/auth/auth.module.ts b/api/src/auth/auth.module.ts index 3595527..ec834ff 100644 --- a/api/src/auth/auth.module.ts +++ b/api/src/auth/auth.module.ts @@ -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, diff --git a/api/src/auth/auth.service.ts b/api/src/auth/auth.service.ts index 97d5e24..6eff576 100644 --- a/api/src/auth/auth.service.ts +++ b/api/src/auth/auth.service.ts @@ -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, @InjectModel(AccessLog.name) private accessLogModel: Model, + @InjectModel(EmailVerification.name) + private emailVerificationModel: Model, 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) diff --git a/api/src/auth/schemas/email-verification.schema.ts b/api/src/auth/schemas/email-verification.schema.ts new file mode 100644 index 0000000..3380fcd --- /dev/null +++ b/api/src/auth/schemas/email-verification.schema.ts @@ -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) diff --git a/api/src/mail/templates/verify-email.hbs b/api/src/mail/templates/verify-email.hbs new file mode 100644 index 0000000..4246ab8 --- /dev/null +++ b/api/src/mail/templates/verify-email.hbs @@ -0,0 +1,115 @@ + + + + + +
+
+

Verify Your Email Address

+
+ +

Hello {{name}},

+ +

Welcome to TextBee! To complete your registration and verify your email address, please click the button below.

+ +
+ Verify Email +
+ +
+ +

If the button above doesn't work, you can copy and paste the following link into your browser:

+

{{verificationLink}}

+ + + +
+ + +
+ + \ No newline at end of file diff --git a/api/src/users/schemas/user.schema.ts b/api/src/users/schemas/user.schema.ts index 56d9ee4..0b767be 100644 --- a/api/src/users/schemas/user.schema.ts +++ b/api/src/users/schemas/user.schema.ts @@ -31,6 +31,9 @@ export class User { @Prop({ type: Date }) lastLoginAt: Date + + @Prop({ type: Date }) + emailVerifiedAt: Date } export const UserSchema = SchemaFactory.createForClass(User)