chore: add verfication email for user profile

This commit is contained in:
mahyargdz
2025-02-18 10:16:05 +03:30
parent ff96fd6c32
commit cad8e15941
15 changed files with 173 additions and 31 deletions
+6 -6
View File
@@ -17,12 +17,12 @@ SMS_API_URL=https://api.sms.ir/v1
SMS_API_KEY="dasdas"
SMS_PATTERN_OTP=82385
SMTP_HOST=
SMTP_PORT=
SMTP_USER=
SMTP_PASS=
MAIL_FROM=
SMTP_HOST=smtp.c1.liara.email
SMTP_PORT=587
SMTP_USER=usernames
SMTP_PASS=password
MAIL_FROM=confirmation@asan-service.com
EMAIL_SECRET="secret key"
REDIS_URI=redis://:@localhost:6379/0
CACHE_TTL=120000
+9
View File
@@ -52,6 +52,8 @@ export const enum AuthMessage {
OTP_ALREADY_SENT = "کد یکبار مصرف قبلا ارسال شده است",
TOO_MANY_REQUESTS = "تعداد درخواست های شما بیش از حد مجاز است",
NOT_ADMIN = "شما دسترسی به بخش ادمین ندارید",
PHONE_SHOULD_BE_11_DIGIT = "شماره تلفن باید ۱۱ رقم باشد",
TIMESTAMP_NOT_EMPTY = "TIMESTAMP_NOT_EMPTY",
}
export const enum UserMessage {
@@ -66,6 +68,12 @@ export const enum UserMessage {
USER_GROUP_CREATED = "گروه کاربری با موفقیت ایجاد شد",
USER_ID_SHOULD_BE_A_UUID = "شناسه کاربر باید یک UUID باشد",
NATIONAL_CODE_EXIST = "کد ملی قبلا ثبت شده است",
PROFILE_PIC_URL = "آدرس عکس پروفایل باید یک آدرس یو آر ال باشد",
PROFILE_PIC_REQUIRED = "عکس پروفایل نمی‌تواند خالی باشد",
VERIFY_EMAIL_LINK_SENT = "لینک تایید ایمیل به ایمیل شما ارسال شد",
EMAIL_VERIFIED = "ایمیل شما تایید شد",
INVALID_EMAIL_TOKEN = "توکن ایمیل نامعتبر است",
EXPIRED_EMAIL_TOKEN = "توکن ایمیل منقضی شده است",
}
export const enum CommonMessage {
@@ -391,6 +399,7 @@ export const enum DiscountMessage {
export const enum EmailMessage {
EMAIL_VERIFICATION = "تایید ایمیل",
EMAIL_SENDING_FAILED = "ارسال ایمیل با خطا مواجه شد",
}
export const enum FinancialMessage {
+1 -1
View File
@@ -20,7 +20,7 @@ export function mailerConfig(): MailerAsyncOptions {
},
template: {
dir: process.cwd() + "/templates",
dir: process.cwd() + "/src/templates",
adapter: new HandlebarsAdapter(),
options: {
strict: true,
@@ -6,6 +6,7 @@ import { AuthMessage } from "../../../common/enums/message.enum";
export class CompleteRegistrationDto {
@IsNotEmpty({ message: AuthMessage.PHONE_NOT_EMPTY })
@IsMobilePhone("fa-IR", {}, { message: AuthMessage.INVALID_PHONE_FORMAT })
@Length(11, 11, { message: AuthMessage.PHONE_SHOULD_BE_11_DIGIT })
@ApiProperty({ description: "phone number", default: "09123456789" })
phone: string;
+2 -1
View File
@@ -1,10 +1,11 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsMobilePhone, IsNotEmpty } from "class-validator";
import { IsMobilePhone, IsNotEmpty, Length } from "class-validator";
import { AuthMessage } from "../../../common/enums/message.enum";
export class RequestOtpDto {
@IsNotEmpty({ message: AuthMessage.PHONE_NOT_EMPTY })
@Length(11, 11, { message: AuthMessage.PHONE_SHOULD_BE_11_DIGIT })
@IsMobilePhone("fa-IR", {}, { message: AuthMessage.INVALID_PHONE_FORMAT })
@ApiProperty({ description: "phone number", default: "09123456789" })
phone: string;
+1
View File
@@ -5,6 +5,7 @@ import { AuthMessage } from "../../../common/enums/message.enum";
export class VerifyOtpDto {
@IsNotEmpty({ message: AuthMessage.PHONE_NOT_EMPTY })
@Length(11, 11, { message: AuthMessage.PHONE_SHOULD_BE_11_DIGIT })
@IsMobilePhone("fa-IR", {}, { message: AuthMessage.INVALID_PHONE_FORMAT })
@ApiProperty({ description: "phone number", default: "09123456789" })
phone: string;
+1
View File
@@ -7,6 +7,7 @@ import { AuthMessage } from "../../../common/enums/message.enum";
export class CreateUserDto extends PartialType(CreateLegalUserDto) {
@IsNotEmpty({ message: AuthMessage.PHONE_NOT_EMPTY })
@IsMobilePhone("fa-IR", {}, { message: AuthMessage.INVALID_PHONE_FORMAT })
@Length(11, 11, { message: AuthMessage.PHONE_SHOULD_BE_11_DIGIT })
@ApiProperty({ description: "phone number", default: "09123456789" })
phone: string;
+11
View File
@@ -0,0 +1,11 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsEmail, IsNotEmpty } from "class-validator";
import { AuthMessage } from "../../../common/enums/message.enum";
export class UpdateEmailDto {
@IsNotEmpty({ message: AuthMessage.EMAIL_NOT_EMPTY })
@IsEmail({}, { message: AuthMessage.INVALID_EMAIL_FORMAT })
@ApiProperty({ description: "Email", example: "ma@gmail.com" })
email: string;
}
+9 -3
View File
@@ -1,5 +1,5 @@
import { ApiProperty, PartialType, PickType } from "@nestjs/swagger";
import { IsNotEmpty, IsOptional, IsString, Length } from "class-validator";
import { ApiPropertyOptional, PartialType, PickType } from "@nestjs/swagger";
import { IsNotEmpty, IsOptional, IsString, IsUrl, Length } from "class-validator";
import { UserMessage } from "../../../common/enums/message.enum";
import { CompleteRegistrationDto } from "../../auth/DTO/complete-register.dto";
@@ -9,6 +9,12 @@ export class UpdateProfileDto extends PartialType(PickType(CompleteRegistrationD
@IsNotEmpty({ message: UserMessage.USERNAME_NOT_EMPTY })
@Length(3, 50, { message: UserMessage.USERNAME_SHOULD_BE_BETWEEN_3_AND_50 })
@IsString({ message: UserMessage.USERNAME_NOT_EMPTY })
@ApiProperty({ description: "User name", example: "mamad24" })
@ApiPropertyOptional({ description: "User name", example: "mamad24" })
userName?: string;
@IsOptional()
@IsNotEmpty({ message: UserMessage.PROFILE_PIC_REQUIRED })
@IsUrl({ protocols: ["http", "https"], require_protocol: true }, { message: UserMessage.PROFILE_PIC_URL })
@ApiPropertyOptional({ description: "Profile picture", example: "https://www.google.com" })
profilePic?: string;
}
@@ -0,0 +1,19 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsNotEmpty, IsString, IsUUID } from "class-validator";
import { AuthMessage, UserMessage } from "../../../common/enums/message.enum";
export class EmailVerifyQueryDto {
@IsNotEmpty({ message: AuthMessage.TOKEN_NOT_EMPTY })
@IsString({ message: AuthMessage.TOKEN_NOT_EMPTY })
@ApiProperty({ description: "Token", example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" })
token: string;
@IsNotEmpty({ message: UserMessage.USER_ID_SHOULD_BE_A_UUID })
@IsUUID(4, { message: UserMessage.USER_ID_SHOULD_BE_A_UUID })
@ApiProperty({ description: "User id", example: "f7b1b3b1-4b7b-4b7b-4b7b-4b7b4b7b4b7b" })
userId: string;
@IsNotEmpty({ message: AuthMessage.TIMESTAMP_NOT_EMPTY })
ts: string;
}
@@ -50,6 +50,9 @@ export class User extends BaseEntity {
@Column({ type: "varchar", length: 100, nullable: true })
profilePic: string;
@Column({ type: "boolean", default: false })
emailVerified: boolean;
//-----------------------------------------
// @ManyToOne(() => Role, { eager: true, onDelete: "RESTRICT", nullable: false })
// role: Role;
+76 -3
View File
@@ -1,4 +1,8 @@
import { createHmac } from "node:crypto";
import { BadRequestException, Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { FastifyReply } from "fastify";
import slugify from "slugify";
import { In, Not, QueryRunner } from "typeorm";
@@ -13,6 +17,7 @@ import {
import { AddressService } from "../../address/providers/address.service";
import { CompleteRegistrationDto } from "../../auth/DTO/complete-register.dto";
import { UserSettingsService } from "../../settings/providers/user-settings.service";
import { EmailService } from "../../utils/providers/email.service";
import { PaginationUtils } from "../../utils/providers/pagination.utils";
import { PasswordService } from "../../utils/providers/password.service";
import { WalletsService } from "../../wallets/providers/wallets.service";
@@ -24,8 +29,10 @@ import { CreateRoleDto } from "../DTO/create-role.dto";
import { SearchAdminQueryDto } from "../DTO/search-admins-query.dto";
import { SearchCustomersDto } from "../DTO/search-customers.dto";
import { SearchRolesQueryDto } from "../DTO/search-roles.dto";
import { UpdateEmailDto } from "../DTO/update-email.dto";
import { UpdateProfileDto } from "../DTO/update-profile.dto";
import { CreateUserGroupDto } from "../DTO/user-group.dto";
import { EmailVerifyQueryDto } from "../DTO/verify-email-query.dto";
import { Role } from "../entities/role.entity";
import { User } from "../entities/user.entity";
import { RoleEnum } from "../enums/role.enum";
@@ -50,6 +57,8 @@ export class UsersService {
private readonly realUserRepository: RealUserRepository,
private readonly legalUserRepository: LegalUserRepository,
private readonly addressService: AddressService,
private readonly emailService: EmailService,
private readonly configService: ConfigService,
) {}
/************************************************************ */
@@ -94,14 +103,15 @@ export class UsersService {
/************************************************************ */
async updateProfile(userId: string, updateProfileDto: UpdateProfileDto) {
if (updateProfileDto.userName) {
const user = await this.userRepository.findOneBy({ id: userId });
if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND);
//
if (updateProfileDto.userName) {
updateProfileDto.userName = slugify(updateProfileDto.userName, { lower: true, trim: true });
const existUserName = await this.userRepository.findOneBy({ userName: updateProfileDto.userName, id: Not(userId) });
if (existUserName) throw new BadRequestException(UserMessage.USERNAME_EXIST);
}
const user = await this.userRepository.findOneBy({ id: userId });
if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND);
await this.userRepository.save({ ...user, ...updateProfileDto });
//
@@ -111,6 +121,54 @@ export class UsersService {
}
/************************************************************ */
async updateEmail(userId: string, updateDto: UpdateEmailDto) {
const { email } = updateDto;
const user = await this.userRepository.findOneBy({ id: userId });
if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND);
//
const existEmail = await this.userRepository.findOneBy({ email, id: Not(userId) });
if (existEmail) throw new BadRequestException(UserMessage.EMAIL_EXIST);
//
const emailLink = await this.createEmailHashLink(user.id);
await this.emailService.sendVerificationEmail(email, emailLink, `${user.firstName} ${user.lastName}`);
await this.userRepository.save({ ...user, email });
return {
message: UserMessage.VERIFY_EMAIL_LINK_SENT,
email,
};
}
/************************************************************ */
//TODO:fix this later
async verifyEmail(queryDto: EmailVerifyQueryDto, rep: FastifyReply) {
const { token, userId, ts } = queryDto;
const user = await this.userRepository.findOneBy({ id: userId });
if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND);
const secret = this.configService.getOrThrow<string>("EMAIL_SECRET");
// Check if timestamp is within allowed time window (e.g., 24 hours)
const timestamp = parseInt(ts, 10);
if (Date.now() - timestamp > 24 * 60 * 60 * 1000) {
throw new BadRequestException(UserMessage.EXPIRED_EMAIL_TOKEN);
}
const payload = `${user.id}:${timestamp}`;
const expectedHash = createHmac("sha256", secret).update(payload).digest("hex");
if (expectedHash !== token) throw new BadRequestException(UserMessage.INVALID_EMAIL_TOKEN);
user.emailVerified = true;
await this.userRepository.save(user);
// return { message: UserMessage.EMAIL_VERIFIED };
return rep.redirect("https://google.com");
}
/************************************************************ */
async findOneWithEmail(email: string): Promise<User | null> {
const user = await this.userRepository.findOneWithEmail(email);
return user;
@@ -446,5 +504,20 @@ export class UsersService {
};
}
private async createEmailHashLink(userId: string) {
const secret = this.configService.getOrThrow<string>("EMAIL_SECRET");
const timestamp = Date.now();
const payload = `${userId}:${timestamp}`;
const hash = createHmac("sha256", secret).update(payload).digest("hex");
const url = new URL(`${this.configService.getOrThrow<string>("SITE_URL")}/users/verify-email`);
url.searchParams.append("token", hash);
url.searchParams.append("userId", userId);
url.searchParams.append("ts", timestamp.toString());
return url.toString();
}
/************************************************************ */
}
+26 -10
View File
@@ -1,5 +1,6 @@
import { Body, Controller, Get, HttpCode, HttpStatus, Patch, Post, Query } from "@nestjs/common";
import { Body, Controller, Get, HttpCode, HttpStatus, Patch, Post, Query, Res } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { FastifyReply } from "fastify";
import { CheckValidityDTO } from "./DTO/check-validity.dto";
import { CreateAdminDto } from "./DTO/create-admin.dto";
@@ -9,9 +10,10 @@ import { CreateRoleDto } from "./DTO/create-role.dto";
import { SearchAdminQueryDto } from "./DTO/search-admins-query.dto";
import { SearchCustomersDto } from "./DTO/search-customers.dto";
import { SearchRolesQueryDto } from "./DTO/search-roles.dto";
import { UpdateEmailDto } from "./DTO/update-email.dto";
import { UpdateProfileDto } from "./DTO/update-profile.dto";
import { CreateUserGroupDto } from "./DTO/user-group.dto";
import { User } from "./entities/user.entity";
import { EmailVerifyQueryDto } from "./DTO/verify-email-query.dto";
import { PermissionEnum } from "./enums/permission.enum";
import { UsersService } from "./providers/users.service";
import { AuthGuards } from "../../common/decorators/auth-guard.decorator";
@@ -28,8 +30,8 @@ export class UsersController {
@AuthGuards()
@ApiOperation({ summary: "Get user profile" })
@Get("me")
getMe(@UserDec() user: User) {
return this.usersService.getMe(user.id);
getMe(@UserDec("id") userId: string) {
return this.usersService.getMe(userId);
}
/************************************************************ */
@@ -37,8 +39,8 @@ export class UsersController {
@AuthGuards()
@ApiOperation({ summary: "Get user financial info" })
@Get("me/financial-info")
getFinancialInfo(@UserDec() user: User) {
return this.usersService.getMyFinancialInfo(user.id);
getFinancialInfo(@UserDec("id") userId: string) {
return this.usersService.getMyFinancialInfo(userId);
}
/************************************************************ */
@@ -46,8 +48,22 @@ export class UsersController {
@AuthGuards()
@ApiOperation({ summary: "Update user profile" })
@Patch("update-profile")
updateProfile(@Body() updateProfileDto: UpdateProfileDto, @UserDec() user: User) {
return this.usersService.updateProfile(user.id, updateProfileDto);
updateProfile(@Body() updateProfileDto: UpdateProfileDto, @UserDec("id") userId: string) {
return this.usersService.updateProfile(userId, updateProfileDto);
}
@AuthGuards()
@ApiOperation({ summary: "Update user email" })
@Patch("update-email")
updateEmail(@Body() updateEmailDto: UpdateEmailDto, @UserDec("id") userId: string) {
return this.usersService.updateEmail(userId, updateEmailDto);
}
@ApiOperation({ summary: "Verify email" })
@HttpCode(HttpStatus.PERMANENT_REDIRECT)
@Get("verify-email")
verifyEmail(@Query() queryDto: EmailVerifyQueryDto, @Res({ passthrough: true }) rep: FastifyReply) {
return this.usersService.verifyEmail(queryDto, rep);
}
/************************************************************ */
@@ -56,8 +72,8 @@ export class UsersController {
@ApiOperation({ summary: "Check validity of user field" })
@HttpCode(HttpStatus.OK)
@Post("check-validity")
checkValidity(@Body() checkValidityDto: CheckValidityDTO, @UserDec() user: User) {
return this.usersService.checkValidity(checkValidityDto, user.id);
checkValidity(@Body() checkValidityDto: CheckValidityDTO, @UserDec("id") userId: string) {
return this.usersService.checkValidity(checkValidityDto, userId);
}
/************************************************************ */
+6 -5
View File
@@ -1,5 +1,6 @@
import { Injectable, Logger } from "@nestjs/common";
import { Injectable, InternalServerErrorException, Logger } from "@nestjs/common";
import { MailerService } from "@nestjs-modules/mailer";
import { SentMessageInfo } from "nodemailer";
import { EmailMessage } from "../../../common/enums/message.enum";
@@ -8,12 +9,12 @@ export class EmailService {
private readonly logger = new Logger(EmailService.name);
constructor(private readonly mailerService: MailerService) {}
async sendEmail(to: string, subject: string, link: string, fullName: string) {
async sendVerificationEmail(to: string, link: string, fullName: string): Promise<SentMessageInfo> {
try {
const emailInfo = await this.mailerService.sendMail({
to: to,
// from: "noreply@nestjs.com",
subject: subject,
subject: EmailMessage.EMAIL_VERIFICATION,
template: "email-verify",
context: {
title: EmailMessage.EMAIL_VERIFICATION,
@@ -24,7 +25,7 @@ export class EmailService {
return emailInfo;
} catch (error) {
this.logger.error("Email sending failed:", error);
return false;
throw new InternalServerErrorException(EmailMessage.EMAIL_SENDING_FAILED);
}
}
}
+2 -2
View File
@@ -22,10 +22,10 @@
<p>به <strong>DanakCorp</strong> خوش آمدید! لطفاً برای تأیید ایمیل خود روی دکمه زیر کلیک کنید:</p>
<a href="{{verificationLink}}" class="btn">تأیید ایمیل</a>
<a href="{{link}}" class="btn">تأیید ایمیل</a>
<p>اگر دکمه بالا کار نمی‌کند، می‌توانید از لینک زیر استفاده کنید:</p>
<p style="word-break: break-all"><a href="{{verificationLink}}" style="color: #4da6ff">{{verificationLink}}</a></p>
<p style="word-break: break-all"><a href="{{link}}" style="color: #4da6ff">{{link}}</a></p>
<p>اگر شما این درخواست را ارسال نکرده‌اید، لطفاً این ایمیل را نادیده بگیرید.</p>