From d49199de2f5e6cf669fae69518e5bc8aaa7ccfcd Mon Sep 17 00:00:00 2001 From: Purvang V Date: Wed, 17 Jun 2026 14:39:31 +0530 Subject: [PATCH 1/4] feature: implement email verification and OTP resend flow with Brevo integration --- apps/control-panel-app/src/constants/error.ts | 2 + .../src/constants/success.ts | 1 + .../src/modules/auth/auth.controller.ts | 8 + .../src/modules/auth/auth.module.ts | 2 + .../src/modules/auth/auth.service.ts | 183 +++++++++++++----- .../src/modules/auth/dto/verify-otp.dto.ts | 6 +- .../src/modules/email/email.service.ts | 49 +++++ console-app/src/api/axios.ts | 1 + console-app/src/app/router/index.tsx | 4 + .../components/shared/form-errors-summary.tsx | 2 +- console-app/src/features/auth/api/index.ts | 11 ++ .../features/auth/components/auth-card.tsx | 5 +- .../features/auth/components/auth-form.tsx | 5 +- .../features/auth/components/otp-input.tsx | 83 ++++++++ .../src/features/auth/constants/index.ts | 14 ++ console-app/src/features/auth/hooks/index.ts | 11 ++ console-app/src/features/auth/types/index.ts | 10 + console-app/src/index.css | 43 ++++ .../src/pages/forgot-password-page.tsx | 19 +- .../src/pages/forgot-password-verify-page.tsx | 136 +++++++++++++ console-app/src/pages/login-page.tsx | 11 +- console-app/src/pages/register-page.tsx | 7 +- console-app/src/pages/reset-password-page.tsx | 8 +- console-app/src/pages/verify-email-page.tsx | 140 ++++++++++++++ package.json | 1 + 25 files changed, 691 insertions(+), 71 deletions(-) create mode 100644 apps/control-panel-app/src/modules/email/email.service.ts create mode 100644 console-app/src/features/auth/components/otp-input.tsx create mode 100644 console-app/src/pages/forgot-password-verify-page.tsx create mode 100644 console-app/src/pages/verify-email-page.tsx diff --git a/apps/control-panel-app/src/constants/error.ts b/apps/control-panel-app/src/constants/error.ts index 1e7e66a..53eb70b 100644 --- a/apps/control-panel-app/src/constants/error.ts +++ b/apps/control-panel-app/src/constants/error.ts @@ -56,6 +56,8 @@ export const ERROR_MESSAGES = { OTP_EXPIRED: "OTP expired", OTP_NOT_VERIFIED: "OTP not verified", MAX_OTP_ATTEMPTS: "OTP attempts exhausted, please try again later", + EMAIL_NOT_VERIFIED: "Email not verified", + OTP_EXPIRED_OR_INVALID: "OTP expired or invalid", }, PROFILE: { diff --git a/apps/control-panel-app/src/constants/success.ts b/apps/control-panel-app/src/constants/success.ts index b28ff60..3b83edd 100644 --- a/apps/control-panel-app/src/constants/success.ts +++ b/apps/control-panel-app/src/constants/success.ts @@ -32,6 +32,7 @@ export const SUCCESS_MESSAGES = { OTP_SENT: "OTP sent successfully", OTP_VERIFIED: "OTP verified successfully", PASSWORD_RESET: "Password updated successfully", + OTP_RESENT: "OTP resent successfully", }, PROFILE: { diff --git a/apps/control-panel-app/src/modules/auth/auth.controller.ts b/apps/control-panel-app/src/modules/auth/auth.controller.ts index 773667d..84b388a 100644 --- a/apps/control-panel-app/src/modules/auth/auth.controller.ts +++ b/apps/control-panel-app/src/modules/auth/auth.controller.ts @@ -121,6 +121,14 @@ export class AuthController { return this.authService.forgotPassword(forgotPasswordDto); } + /** + * Resend registration OTP + */ + @Post("resend-otp") + async resendOtp(@Body() forgotPasswordDto: ForgotPasswordDto) { + return this.authService.resendOtp(forgotPasswordDto.email); + } + /** * Verify OTP */ diff --git a/apps/control-panel-app/src/modules/auth/auth.module.ts b/apps/control-panel-app/src/modules/auth/auth.module.ts index 417255d..b4e64b1 100644 --- a/apps/control-panel-app/src/modules/auth/auth.module.ts +++ b/apps/control-panel-app/src/modules/auth/auth.module.ts @@ -15,6 +15,7 @@ import { UserCodeEntity } from "./entities/user-codes.entity"; import { UsersModule } from "../users/users.module"; import { AuthCookieService } from "./services/auth-cookie.service"; import { AuthSessionLookupService } from "./services/auth-session-lookup.service"; +import { EmailService } from "../email/email.service"; @Module({ imports: [ @@ -43,6 +44,7 @@ import { AuthSessionLookupService } from "./services/auth-session-lookup.service AuthService, AuthCookieService, AuthSessionLookupService, + EmailService, JwtStrategy, RefreshJwtStrategy, ], diff --git a/apps/control-panel-app/src/modules/auth/auth.service.ts b/apps/control-panel-app/src/modules/auth/auth.service.ts index d8a9b88..64803fe 100644 --- a/apps/control-panel-app/src/modules/auth/auth.service.ts +++ b/apps/control-panel-app/src/modules/auth/auth.service.ts @@ -5,7 +5,7 @@ import { NotFoundException, } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { DataSource, IsNull, Repository } from "typeorm"; +import { DataSource, Repository } from "typeorm"; import { JwtService } from "@nestjs/jwt"; import dayjs from "dayjs"; import * as bcrypt from "bcrypt"; @@ -31,6 +31,7 @@ import { SALT_ROUNDS } from "@control-panel/constants/env.constant"; import { isJwtToken } from "./utils/cookie-extractor.util"; import { hashToken } from "./utils/token-hash.util"; import { AuthSessionLookupService } from "./services/auth-session-lookup.service"; +import { EmailService } from "../email/email.service"; export interface AuthTokens { accessToken: string; @@ -53,6 +54,7 @@ export class AuthService { private readonly jwtService: JwtService, private readonly configService: ConfigService, private readonly authSessionLookupService: AuthSessionLookupService, + private readonly emailService: EmailService, ) {} private resolveRefreshExpiresIn(): StringValue { @@ -70,6 +72,80 @@ export class AuthService { return dayjs().add(expiresInMs, "millisecond").unix(); } + private getOtpExpiresAt(): number { + const expiresIn = this.configService.getOrThrow("OTP_EXPIRES_IN"); + const expiresInMs = ms(expiresIn); + if (typeof expiresInMs !== "number") { + throw new Error(`Invalid OTP expiry: ${expiresIn}`); + } + return dayjs().add(expiresInMs, "millisecond").unix(); + } + + private async replaceOtp(userId: string, codeType: CODE_TYPE): Promise { + await this.userCodeRepository.update( + { + userId, + codeType, + status: EntityStatus.ACTIVE, + }, + { + status: EntityStatus.INACTIVE, + }, + ); + } + + private async createOtpRecord( + userId: string, + codeType: CODE_TYPE, + ): Promise<{ otp: string }> { + await this.replaceOtp(userId, codeType); + + const otp = GenerateOTP(); + const otpHash = await bcrypt.hash(otp, SALT_ROUNDS); + + await this.userCodeRepository.save( + this.userCodeRepository.create({ + userId, + codeType, + otpHash, + expiresAt: this.getOtpExpiresAt(), + attempts: 0, + }), + ); + + return { otp }; + } + + private async sendOtpEmail(user: UserEntity, codeType: CODE_TYPE, otp: string) { + const purposeLabel = + codeType === CODE_TYPE.EMAIL_VERIFICATION + ? "Email verification" + : "Password reset"; + + await this.emailService.sendOtpEmail({ + toEmail: user.email, + toName: user.name, + otp, + purposeLabel, + }); + } + + private async getLatestActiveOtpRecord( + userId: string, + codeType: CODE_TYPE, + ): Promise { + return this.userCodeRepository.findOne({ + where: { + userId, + codeType, + status: EntityStatus.ACTIVE, + }, + order: { + createdAt: "DESC", + }, + }); + } + /** * Generate access and refresh tokens for a user */ @@ -165,7 +241,7 @@ export class AuthService { const savedOrganization = await organizationRepository.save(organization); - const passwordHash = await bcrypt.hash(signupDto.password, 10); + const passwordHash = await bcrypt.hash(signupDto.password, SALT_ROUNDS); const userRepository = queryRunner.manager.getRepository(UserEntity); @@ -175,14 +251,21 @@ export class AuthService { passwordHash, organizationId: savedOrganization.id, signUpAt: dayjs().unix(), - isEmailVerified: true, - emailVerifiedAt: dayjs().unix(), + isEmailVerified: false, + emailVerifiedAt: undefined, + dateOfBirth: undefined, }); const savedUser = await userRepository.save(user); await queryRunner.commitTransaction(); + const { otp } = await this.createOtpRecord( + savedUser.id, + CODE_TYPE.EMAIL_VERIFICATION, + ); + await this.sendOtpEmail(savedUser, CODE_TYPE.EMAIL_VERIFICATION, otp); + return { message: SUCCESS_MESSAGES.AUTH.SIGNUP, data: { @@ -234,6 +317,10 @@ export class AuthService { throw new UnauthorizedException(ERROR_MESSAGES.AUTH.INVALID_CREDENTIALS); } + if (!user.isEmailVerified) { + throw new UnauthorizedException(ERROR_MESSAGES.AUTH.EMAIL_NOT_VERIFIED); + } + user.lastLoginAt = dayjs().valueOf(); await this.userRepository.save(user); @@ -407,41 +494,37 @@ export class AuthService { }); if (!user) { - return { - message: SUCCESS_MESSAGES.AUTH.OTP_SENT, - }; + throw new NotFoundException(ERROR_MESSAGES.AUTH.USER_NOT_FOUND); } - await this.userCodeRepository.update( - { - userId: user.id, - codeType: CODE_TYPE.FORGOT_PASSWORD, - verifiedAt: IsNull(), - }, - { - status: EntityStatus.INACTIVE, - }, + const { otp } = await this.createOtpRecord( + user.id, + CODE_TYPE.FORGOT_PASSWORD, ); + await this.sendOtpEmail(user, CODE_TYPE.FORGOT_PASSWORD, otp); - const otp = GenerateOTP(); + return { + message: SUCCESS_MESSAGES.AUTH.OTP_SENT, + }; + } - const otpHash = await bcrypt.hash(otp, 10); + async resendOtp(email: string) { + const user = await this.userRepository.findOne({ + where: { email: email.toLowerCase().trim() }, + }); - await this.userCodeRepository.save( - this.userCodeRepository.create({ - userId: user.id, - codeType: CODE_TYPE.FORGOT_PASSWORD, - otpHash, - expiresAt: dayjs().add(10, "minute").unix(), - attempts: 0, - }), + if (!user) { + throw new UnauthorizedException(ERROR_MESSAGES.AUTH.UNAUTHORIZED); + } + + const { otp } = await this.createOtpRecord( + user.id, + CODE_TYPE.EMAIL_VERIFICATION, ); + await this.sendOtpEmail(user, CODE_TYPE.EMAIL_VERIFICATION, otp); return { - message: SUCCESS_MESSAGES.AUTH.OTP_SENT, - data: { - otp, - }, + message: SUCCESS_MESSAGES.AUTH.OTP_RESENT, }; } @@ -459,16 +542,7 @@ export class AuthService { throw new UnauthorizedException(ERROR_MESSAGES.AUTH.INVALID_OTP); } - const otpRecord = await this.userCodeRepository.findOne({ - where: { - userId: user.id, - codeType: CODE_TYPE.FORGOT_PASSWORD, - status: EntityStatus.ACTIVE, - }, - order: { - createdAt: "DESC", - }, - }); + const otpRecord = await this.getLatestActiveOtpRecord(user.id, verifyOtpDto.purpose); if (!otpRecord) { throw new UnauthorizedException(ERROR_MESSAGES.AUTH.INVALID_OTP); @@ -496,6 +570,12 @@ export class AuthService { await this.userCodeRepository.save(otpRecord); + if (verifyOtpDto.purpose === CODE_TYPE.EMAIL_VERIFICATION) { + user.isEmailVerified = true; + user.emailVerifiedAt = dayjs().unix(); + await this.userRepository.save(user); + } + return { message: SUCCESS_MESSAGES.AUTH.OTP_VERIFIED, }; @@ -515,16 +595,10 @@ export class AuthService { throw new UnauthorizedException(ERROR_MESSAGES.AUTH.USER_NOT_FOUND); } - const otpRecord = await this.userCodeRepository.findOne({ - where: { - userId: user.id, - codeType: CODE_TYPE.FORGOT_PASSWORD, - status: EntityStatus.ACTIVE, - }, - order: { - createdAt: "DESC", - }, - }); + const otpRecord = await this.getLatestActiveOtpRecord( + user.id, + CODE_TYPE.FORGOT_PASSWORD, + ); if (!otpRecord?.verifiedAt) { throw new UnauthorizedException(ERROR_MESSAGES.AUTH.OTP_NOT_VERIFIED); @@ -542,6 +616,15 @@ export class AuthService { otpRecord.status = EntityStatus.INACTIVE; await this.userCodeRepository.save(otpRecord); + await this.userCodeRepository.update( + { + userId: user.id, + codeType: CODE_TYPE.FORGOT_PASSWORD, + }, + { + status: EntityStatus.INACTIVE, + }, + ); await this.revokeAllUserSessions(user.id); diff --git a/apps/control-panel-app/src/modules/auth/dto/verify-otp.dto.ts b/apps/control-panel-app/src/modules/auth/dto/verify-otp.dto.ts index 18a9943..b8a8a4d 100644 --- a/apps/control-panel-app/src/modules/auth/dto/verify-otp.dto.ts +++ b/apps/control-panel-app/src/modules/auth/dto/verify-otp.dto.ts @@ -1,4 +1,5 @@ -import { IsEmail, IsString, Length } from "class-validator"; +import { IsEmail, IsEnum, IsString, Length } from "class-validator"; +import { CODE_TYPE } from "../enum/codeType.enum"; export class VerifyOtpDto { @IsEmail() @@ -7,4 +8,7 @@ export class VerifyOtpDto { @IsString() @Length(6, 6) otp!: string; + + @IsEnum(CODE_TYPE) + purpose!: CODE_TYPE; } diff --git a/apps/control-panel-app/src/modules/email/email.service.ts b/apps/control-panel-app/src/modules/email/email.service.ts new file mode 100644 index 0000000..870f711 --- /dev/null +++ b/apps/control-panel-app/src/modules/email/email.service.ts @@ -0,0 +1,49 @@ +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { BrevoClient } from "@getbrevo/brevo"; + +@Injectable() +export class EmailService { + private readonly brevo: BrevoClient; + private readonly fromEmail: string; + private readonly fromName: string; + + constructor(private readonly configService: ConfigService) { + const apiKey = this.configService.getOrThrow("BREVO_API_KEY"); + + this.fromEmail = this.configService.getOrThrow("BREVO_FROM_EMAIL"); + this.fromName = + this.configService.get("BREVO_FROM_NAME") ?? "Kubeara"; + + this.brevo = new BrevoClient({ apiKey }); + } + + async sendOtpEmail(input: { + toEmail: string; + toName?: string; + otp: string; + purposeLabel: string; + }): Promise { + const subject = `${input.purposeLabel} OTP`; + const htmlContent = ` +

Your verification code is:

+

${input.otp}

+

This code expires soon. If you did not request it, you can ignore this email.

+ `; + + await this.brevo.transactionalEmails.sendTransacEmail({ + subject, + htmlContent, + sender: { + email: this.fromEmail, + name: this.fromName, + }, + to: [ + { + email: input.toEmail, + ...(input.toName ? { name: input.toName } : {}), + }, + ], + }); + } +} diff --git a/console-app/src/api/axios.ts b/console-app/src/api/axios.ts index 81369e3..739e0bb 100644 --- a/console-app/src/api/axios.ts +++ b/console-app/src/api/axios.ts @@ -14,6 +14,7 @@ import { import { shouldSkipRefreshForUrl } from "@/features/auth/constants"; import { getApiBaseUrl } from "@/lib/api-config"; + type RetriableRequestConfig = InternalAxiosRequestConfig & { _retry?: boolean; }; diff --git a/console-app/src/app/router/index.tsx b/console-app/src/app/router/index.tsx index c19aa3a..b4e68aa 100644 --- a/console-app/src/app/router/index.tsx +++ b/console-app/src/app/router/index.tsx @@ -7,6 +7,7 @@ import { DeployConfigurePage } from "@/pages/deploy-configure-page"; import { DeployLogsPage } from "@/pages/deploy-logs-page"; import { ContainerLogsPage } from "@/pages/container-logs-page"; import { ForgotPasswordPage } from "@/pages/forgot-password-page"; +import { ForgotPasswordVerifyPage } from "@/pages/forgot-password-verify-page"; import { LoginPage } from "@/pages/login-page"; import { NotFoundPage } from "@/pages/not-found-page"; import { ProfilePage } from "@/pages/profile-page"; @@ -16,6 +17,7 @@ import { ServerDetailPage } from "@/pages/server-detail-page"; import { McpServersPage } from "@/pages/mcp-servers-page"; import { ServersPage } from "@/pages/servers-page"; import { TemplatesPage } from "@/pages/templates-page"; +import { VerifyEmailPage } from "@/pages/verify-email-page"; const SentryRoutes = Sentry.withSentryReactRouterV7Routing(Routes); @@ -33,6 +35,8 @@ export function AppRoutes() { } /> } /> } /> + } /> + } /> } /> diff --git a/console-app/src/components/shared/form-errors-summary.tsx b/console-app/src/components/shared/form-errors-summary.tsx index a3c001e..676a1c7 100644 --- a/console-app/src/components/shared/form-errors-summary.tsx +++ b/console-app/src/components/shared/form-errors-summary.tsx @@ -9,7 +9,7 @@ export function FormErrorsSummary({ formError }: FormErrorsSummaryProps) { return (
-

{formError}

+

{formError}

); } diff --git a/console-app/src/features/auth/api/index.ts b/console-app/src/features/auth/api/index.ts index b2a31ed..6d879d6 100644 --- a/console-app/src/features/auth/api/index.ts +++ b/console-app/src/features/auth/api/index.ts @@ -13,6 +13,7 @@ import type { LoginRequest, MessageResponse, ResetPasswordRequest, + ResendOtpRequest, SignupRequest, SignupResponse, VerifyOtpRequest, @@ -91,6 +92,16 @@ export async function forgotPassword( return { message: response.data.message }; } +export async function resendOtp( + input: ResendOtpRequest, +): Promise { + const response = await apiClient.post( + "/auth/resend-otp", + input, + ); + return { message: response.data.message }; +} + export async function verifyOtp( input: VerifyOtpRequest, ): Promise { diff --git a/console-app/src/features/auth/components/auth-card.tsx b/console-app/src/features/auth/components/auth-card.tsx index 0ab85c6..c976f4a 100644 --- a/console-app/src/features/auth/components/auth-card.tsx +++ b/console-app/src/features/auth/components/auth-card.tsx @@ -3,7 +3,7 @@ import { KubearaLogo } from "@/components/shared/kubeara-logo"; type AuthCardProps = { title: string; - subtitle?: string; + subtitle?: React.ReactNode; children: React.ReactNode; footer?: React.ReactNode; }; @@ -30,7 +30,8 @@ export function AuthCard({ title, subtitle, children, footer }: AuthCardProps) {

{title}

- {subtitle &&

{subtitle}

} + {subtitle != null && + (typeof subtitle === "string" ?

{subtitle}

: subtitle)}
{children} diff --git a/console-app/src/features/auth/components/auth-form.tsx b/console-app/src/features/auth/components/auth-form.tsx index 3b2dd5b..a0a4a42 100644 --- a/console-app/src/features/auth/components/auth-form.tsx +++ b/console-app/src/features/auth/components/auth-form.tsx @@ -18,6 +18,7 @@ type AuthFormProps = { submitLabel: string; onSubmit: (formData: FormData) => Promise; error?: string | null; + errorAfterFields?: boolean; success?: string | null; loading?: boolean; children?: React.ReactNode; @@ -32,6 +33,7 @@ export function AuthForm({ submitLabel, onSubmit, error, + errorAfterFields = false, success, loading, children, @@ -45,7 +47,7 @@ export function AuthForm({ return (
- + {!errorAfterFields && } {fields.map((field) => { const inputType = field.validateAsEmail ? "text" : field.type; const inputMode = field.validateAsEmail ? "email" : undefined; @@ -93,6 +95,7 @@ export function AuthForm({ ); })} + {errorAfterFields && } {success &&

{success}

} + + + ) : ( +

{error}

+ )} + + ); +} diff --git a/console-app/src/pages/login-page.tsx b/console-app/src/pages/login-page.tsx index 46b8166..c680324 100644 --- a/console-app/src/pages/login-page.tsx +++ b/console-app/src/pages/login-page.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { AuthCard } from "@/features/auth/components/auth-card"; import { AuthForm } from "@/features/auth/components/auth-form"; import { useLoginMutation } from "@/features/auth/hooks"; -import { getErrorMessage } from "@/api/api-error"; +import { getErrorMessage, toApiError } from "@/api/api-error"; import { validateEmail, validateRequired } from "@/lib/validation"; /** @@ -52,6 +52,14 @@ export function LoginPage() { const from = searchParams.get("from") ?? "/servers"; navigate(from, { replace: true }); } catch (err) { + const apiError = toApiError(err); + if (apiError.message.toLowerCase().includes("email not verified")) { + navigate( + `/verify-email?email=${encodeURIComponent(email.trim())}`, + { replace: true }, + ); + return; + } setError(getErrorMessage(err)); } } @@ -88,6 +96,7 @@ export function LoginPage() { submitLabel="Sign in" onSubmit={handleSubmit} error={error} + errorAfterFields fieldErrors={fieldErrors} loading={loginMutation.isPending} > diff --git a/console-app/src/pages/register-page.tsx b/console-app/src/pages/register-page.tsx index 92ff9cf..acb5d93 100644 --- a/console-app/src/pages/register-page.tsx +++ b/console-app/src/pages/register-page.tsx @@ -70,7 +70,10 @@ export function RegisterPage() { password, }); - navigate("/login", { replace: true }); + navigate( + `/verify-email?email=${encodeURIComponent(email.trim())}`, + { replace: true }, + ); } catch (err) { setError(getErrorMessage(err)); } @@ -87,7 +90,6 @@ export function RegisterPage() { } >
-
Full name @@ -172,6 +174,7 @@ export function RegisterPage() { disabled={signupMutation.isPending} error={fieldErrors.confirmPassword} /> + + + + ) : ( +

{error}

+ )} + + ); +} diff --git a/package.json b/package.json index 4846927..86d65b5 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "@nestjs/platform-socket.io": "^11.1.20", "@nestjs/typeorm": "^11.0.1", "@nestjs/websockets": "^11.1.20", + "@getbrevo/brevo": "^6.0.1", "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", From 03c6533223aca7e523721383783f7a70b4bb73ed Mon Sep 17 00:00:00 2001 From: Purvang V Date: Wed, 17 Jun 2026 17:07:49 +0530 Subject: [PATCH 2/4] feature: implement OTP resend limits and error handling --- .../common/filters/http-exception.filter.ts | 10 ++ .../interfaces/error-response.interface.ts | 1 + apps/control-panel-app/src/constants/error.ts | 2 + .../src/constants/success.ts | 11 +- .../src/modules/auth/auth.controller.ts | 1 + .../src/modules/auth/auth.service.ts | 111 ++++++++++++- .../src/modules/email/email.service.ts | 4 +- console-app/src/api/api-error.ts | 19 +++ console-app/src/features/auth/api/index.ts | 30 +++- .../src/features/auth/constants/index.ts | 37 ++++- .../features/auth/utils/otp-resend-limit.ts | 148 +++++++++++++++++ console-app/src/index.css | 8 + .../src/pages/forgot-password-page.tsx | 150 ++++++++++-------- .../src/pages/forgot-password-verify-page.tsx | 118 +++++++++++++- console-app/src/pages/verify-email-page.tsx | 105 +++++++++++- console-app/src/vite-env.d.ts | 3 + 16 files changed, 665 insertions(+), 93 deletions(-) create mode 100644 console-app/src/features/auth/utils/otp-resend-limit.ts diff --git a/apps/control-panel-app/src/common/filters/http-exception.filter.ts b/apps/control-panel-app/src/common/filters/http-exception.filter.ts index a3f983d..bd201ef 100644 --- a/apps/control-panel-app/src/common/filters/http-exception.filter.ts +++ b/apps/control-panel-app/src/common/filters/http-exception.filter.ts @@ -35,6 +35,7 @@ export class HttpExceptionFilter implements ExceptionFilter { let message = "Internal server error"; let errorCode = "INTERNAL_SERVER_ERROR"; let errorDetail: string | undefined; + let retryAfterSeconds: number | undefined; if (exception instanceof HttpException) { status = exception.getStatus(); @@ -65,6 +66,14 @@ export class HttpExceptionFilter implements ExceptionFilter { errorCode = body.errorCode; } + if ( + typeof body.retryAfterSeconds === "number" && + Number.isFinite(body.retryAfterSeconds) && + body.retryAfterSeconds > 0 + ) { + retryAfterSeconds = Math.ceil(body.retryAfterSeconds); + } + if (typeof body.error === "string") { const candidate = body.error.trim(); if ( @@ -96,6 +105,7 @@ export class HttpExceptionFilter implements ExceptionFilter { errorCode, message, ...(errorDetail ? { error: errorDetail } : {}), + ...(retryAfterSeconds ? { retryAfterSeconds } : {}), }; response.status(status).json({ diff --git a/apps/control-panel-app/src/common/interfaces/error-response.interface.ts b/apps/control-panel-app/src/common/interfaces/error-response.interface.ts index 9279168..b3c7f2b 100644 --- a/apps/control-panel-app/src/common/interfaces/error-response.interface.ts +++ b/apps/control-panel-app/src/common/interfaces/error-response.interface.ts @@ -4,4 +4,5 @@ export interface ErrorResponse { errorCode: string; message: string; error?: string; + retryAfterSeconds?: number; } diff --git a/apps/control-panel-app/src/constants/error.ts b/apps/control-panel-app/src/constants/error.ts index 53eb70b..3f8ae0f 100644 --- a/apps/control-panel-app/src/constants/error.ts +++ b/apps/control-panel-app/src/constants/error.ts @@ -58,6 +58,8 @@ export const ERROR_MESSAGES = { MAX_OTP_ATTEMPTS: "OTP attempts exhausted, please try again later", EMAIL_NOT_VERIFIED: "Email not verified", OTP_EXPIRED_OR_INVALID: "OTP expired or invalid", + OTP_RESEND_LIMIT_REACHED: + "You have reached the resend limit. Try again after {minutes} minutes.", }, PROFILE: { diff --git a/apps/control-panel-app/src/constants/success.ts b/apps/control-panel-app/src/constants/success.ts index 3b83edd..77d06a3 100644 --- a/apps/control-panel-app/src/constants/success.ts +++ b/apps/control-panel-app/src/constants/success.ts @@ -22,17 +22,18 @@ export const SUCCESS_MESSAGES = { }, AUTH: { - SIGNUP: "User registered successfully", + SIGNUP: "Account created. Check your email for a verification code.", LOGIN: "User logged in successfully", REFRESH: "Tokens refreshed successfully", LOGOUT: "User logged out successfully", LOGOUT_ALL: "Logged out from all devices successfully", PROFILE: "Profile fetched successfully", RESET_PASSWORD: "Password updated successfully", - OTP_SENT: "OTP sent successfully", - OTP_VERIFIED: "OTP verified successfully", - PASSWORD_RESET: "Password updated successfully", - OTP_RESENT: "OTP resent successfully", + OTP_SENT: "Verification code sent to your email.", + OTP_RESENT: "A new verification code has been sent to your email.", + EMAIL_VERIFIED: "Your email has been verified. You can sign in now.", + RESET_CODE_VERIFIED: "Code verified. You can set a new password.", + PASSWORD_RESET: "Your password has been updated. You can sign in now.", }, PROFILE: { diff --git a/apps/control-panel-app/src/modules/auth/auth.controller.ts b/apps/control-panel-app/src/modules/auth/auth.controller.ts index 84b388a..d7c5f3d 100644 --- a/apps/control-panel-app/src/modules/auth/auth.controller.ts +++ b/apps/control-panel-app/src/modules/auth/auth.controller.ts @@ -126,6 +126,7 @@ export class AuthController { */ @Post("resend-otp") async resendOtp(@Body() forgotPasswordDto: ForgotPasswordDto) { + console.log('the email called !') return this.authService.resendOtp(forgotPasswordDto.email); } diff --git a/apps/control-panel-app/src/modules/auth/auth.service.ts b/apps/control-panel-app/src/modules/auth/auth.service.ts index 64803fe..6d7b755 100644 --- a/apps/control-panel-app/src/modules/auth/auth.service.ts +++ b/apps/control-panel-app/src/modules/auth/auth.service.ts @@ -1,11 +1,13 @@ import { ConflictException, + HttpException, + HttpStatus, Injectable, UnauthorizedException, NotFoundException, } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { DataSource, Repository } from "typeorm"; +import { DataSource, MoreThan, Repository } from "typeorm"; import { JwtService } from "@nestjs/jwt"; import dayjs from "dayjs"; import * as bcrypt from "bcrypt"; @@ -73,7 +75,8 @@ export class AuthService { } private getOtpExpiresAt(): number { - const expiresIn = this.configService.getOrThrow("OTP_EXPIRES_IN"); + const expiresIn = + this.configService.getOrThrow("OTP_EXPIRES_IN"); const expiresInMs = ms(expiresIn); if (typeof expiresInMs !== "number") { throw new Error(`Invalid OTP expiry: ${expiresIn}`); @@ -81,6 +84,84 @@ export class AuthService { return dayjs().add(expiresInMs, "millisecond").unix(); } + private getOtpResendMaxAttempts(): number { + const parsed = Number(this.configService.get("OTP_RESEND_MAX_ATTEMPTS", 3)); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 3; + } + + private getOtpResendWindowSeconds(): number { + const parsed = Number( + this.configService.get("OTP_RESEND_WINDOW_MINUTES", 15), + ); + const minutes = Number.isFinite(parsed) && parsed > 0 ? parsed : 15; + return minutes * 60; + } + + private async countOtpSendsInWindow( + userId: string, + codeType: CODE_TYPE, + ): Promise { + const windowStart = dayjs().unix() - this.getOtpResendWindowSeconds(); + + return this.userCodeRepository.count({ + where: { + userId, + codeType, + createdAt: MoreThan(windowStart), + }, + }); + } + + private async getOtpResendRetryAfterSeconds( + userId: string, + codeType: CODE_TYPE, + ): Promise { + const windowStart = dayjs().unix() - this.getOtpResendWindowSeconds(); + const oldest = await this.userCodeRepository.findOne({ + where: { + userId, + codeType, + createdAt: MoreThan(windowStart), + }, + order: { + createdAt: "ASC", + }, + }); + + if (!oldest) { + return this.getOtpResendWindowSeconds(); + } + + const windowEnd = + Number(oldest.createdAt) + this.getOtpResendWindowSeconds(); + return Math.max(1, windowEnd - dayjs().unix()); + } + + private async assertOtpResendAllowed( + userId: string, + codeType: CODE_TYPE, + ): Promise { + const sendCount = await this.countOtpSendsInWindow(userId, codeType); + if (sendCount >= 1 + this.getOtpResendMaxAttempts()) { + const retryAfterSeconds = await this.getOtpResendRetryAfterSeconds( + userId, + codeType, + ); + const retryMinutes = Math.max(1, Math.ceil(retryAfterSeconds / 60)); + + throw new HttpException( + { + message: ERROR_MESSAGES.AUTH.OTP_RESEND_LIMIT_REACHED.replace( + "{minutes}", + String(retryMinutes), + ), + retryAfterSeconds, + }, + HttpStatus.TOO_MANY_REQUESTS, + ); + } + } + private async replaceOtp(userId: string, codeType: CODE_TYPE): Promise { await this.userCodeRepository.update( { @@ -116,7 +197,11 @@ export class AuthService { return { otp }; } - private async sendOtpEmail(user: UserEntity, codeType: CODE_TYPE, otp: string) { + private async sendOtpEmail( + user: UserEntity, + codeType: CODE_TYPE, + otp: string, + ) { const purposeLabel = codeType === CODE_TYPE.EMAIL_VERIFICATION ? "Email verification" @@ -497,6 +582,8 @@ export class AuthService { throw new NotFoundException(ERROR_MESSAGES.AUTH.USER_NOT_FOUND); } + await this.assertOtpResendAllowed(user.id, CODE_TYPE.FORGOT_PASSWORD); + const { otp } = await this.createOtpRecord( user.id, CODE_TYPE.FORGOT_PASSWORD, @@ -505,6 +592,7 @@ export class AuthService { return { message: SUCCESS_MESSAGES.AUTH.OTP_SENT, + data: null, }; } @@ -517,6 +605,8 @@ export class AuthService { throw new UnauthorizedException(ERROR_MESSAGES.AUTH.UNAUTHORIZED); } + await this.assertOtpResendAllowed(user.id, CODE_TYPE.EMAIL_VERIFICATION); + const { otp } = await this.createOtpRecord( user.id, CODE_TYPE.EMAIL_VERIFICATION, @@ -525,6 +615,7 @@ export class AuthService { return { message: SUCCESS_MESSAGES.AUTH.OTP_RESENT, + data: null, }; } @@ -542,7 +633,10 @@ export class AuthService { throw new UnauthorizedException(ERROR_MESSAGES.AUTH.INVALID_OTP); } - const otpRecord = await this.getLatestActiveOtpRecord(user.id, verifyOtpDto.purpose); + const otpRecord = await this.getLatestActiveOtpRecord( + user.id, + verifyOtpDto.purpose, + ); if (!otpRecord) { throw new UnauthorizedException(ERROR_MESSAGES.AUTH.INVALID_OTP); @@ -576,8 +670,14 @@ export class AuthService { await this.userRepository.save(user); } + const message = + verifyOtpDto.purpose === CODE_TYPE.EMAIL_VERIFICATION + ? SUCCESS_MESSAGES.AUTH.EMAIL_VERIFIED + : SUCCESS_MESSAGES.AUTH.RESET_CODE_VERIFIED; + return { - message: SUCCESS_MESSAGES.AUTH.OTP_VERIFIED, + message, + data: null, }; } @@ -630,6 +730,7 @@ export class AuthService { return { message: SUCCESS_MESSAGES.AUTH.PASSWORD_RESET, + data: null, }; } } diff --git a/apps/control-panel-app/src/modules/email/email.service.ts b/apps/control-panel-app/src/modules/email/email.service.ts index 870f711..90ac6d1 100644 --- a/apps/control-panel-app/src/modules/email/email.service.ts +++ b/apps/control-panel-app/src/modules/email/email.service.ts @@ -40,8 +40,8 @@ export class EmailService { }, to: [ { - email: input.toEmail, - ...(input.toName ? { name: input.toName } : {}), + email: input.toEmail, + ...(input.toName ? { name: input.toName } : {}), }, ], }); diff --git a/console-app/src/api/api-error.ts b/console-app/src/api/api-error.ts index 7ce6a57..e889069 100644 --- a/console-app/src/api/api-error.ts +++ b/console-app/src/api/api-error.ts @@ -97,6 +97,21 @@ export function extractMessageFromBody( return null; } +export function extractRetryAfterSeconds( + data: Record | undefined, +): number | null { + if (!data) { + return null; + } + + const value = data.retryAfterSeconds; + if (typeof value === "number" && Number.isFinite(value) && value > 0) { + return Math.ceil(value); + } + + return null; +} + /** * Extract a human-readable error message from various error types. * @@ -135,6 +150,10 @@ export function getErrorMessage(error: unknown): string { return "You do not have permission to perform this action."; } + if (error.response.status === 429) { + return "Too many requests. Please try again later."; + } + return error.message || "Request failed"; } diff --git a/console-app/src/features/auth/api/index.ts b/console-app/src/features/auth/api/index.ts index 6d879d6..6f24cca 100644 --- a/console-app/src/features/auth/api/index.ts +++ b/console-app/src/features/auth/api/index.ts @@ -18,6 +18,20 @@ import type { SignupResponse, VerifyOtpRequest, } from "../types"; +import { AUTH_TOAST_MESSAGES } from "../constants"; + +const GENERIC_SUCCESS_MESSAGE = "Request completed successfully"; + +function getAuthMessage( + response: AuthApiResponse, + fallback: string, +): string { + const message = response.message?.trim(); + if (!message || message === GENERIC_SUCCESS_MESSAGE) { + return fallback; + } + return message; +} export async function signup(input: SignupRequest): Promise { const response = await apiClient.post>( @@ -89,7 +103,7 @@ export async function forgotPassword( "/auth/forgot-password", input, ); - return { message: response.data.message }; + return { message: getAuthMessage(response.data, AUTH_TOAST_MESSAGES.OTP_SENT) }; } export async function resendOtp( @@ -99,7 +113,9 @@ export async function resendOtp( "/auth/resend-otp", input, ); - return { message: response.data.message }; + return { + message: getAuthMessage(response.data, AUTH_TOAST_MESSAGES.OTP_RESENT), + }; } export async function verifyOtp( @@ -109,7 +125,11 @@ export async function verifyOtp( "/auth/verify-otp", input, ); - return { message: response.data.message }; + const fallback = + input.purpose === "EMAIL_VERIFICATION" + ? AUTH_TOAST_MESSAGES.EMAIL_VERIFIED + : AUTH_TOAST_MESSAGES.RESET_CODE_VERIFIED; + return { message: getAuthMessage(response.data, fallback) }; } export async function resetPassword( @@ -119,5 +139,7 @@ export async function resetPassword( "/auth/reset-password", input, ); - return { message: response.data.message }; + return { + message: getAuthMessage(response.data, AUTH_TOAST_MESSAGES.PASSWORD_RESET), + }; } diff --git a/console-app/src/features/auth/constants/index.ts b/console-app/src/features/auth/constants/index.ts index 38b844a..7b28d26 100644 --- a/console-app/src/features/auth/constants/index.ts +++ b/console-app/src/features/auth/constants/index.ts @@ -9,7 +9,42 @@ export const OTP_CODE_TYPE = { export type OtpCodeType = (typeof OTP_CODE_TYPE)[keyof typeof OTP_CODE_TYPE]; -export const OTP_RESEND_COOLDOWN_SECONDS = 60; +const parsedResendCooldownSeconds = Number( + import.meta.env.VITE_RESEND_OTP_COOLDOWN_SECONDS, +); +export const OTP_RESEND_COOLDOWN_SECONDS = + Number.isFinite(parsedResendCooldownSeconds) && parsedResendCooldownSeconds > 0 + ? parsedResendCooldownSeconds + : 60; + +export const OTP_RESEND_MAX_ATTEMPTS = (() => { + const parsed = Number(import.meta.env.VITE_RESEND_OTP_MAX_ATTEMPTS); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 3; +})(); + +const parsedResendWindowMinutes = Number(import.meta.env.VITE_RESEND_OTP_MINUTES); +export const OTP_RESEND_WINDOW_MINUTES = + Number.isFinite(parsedResendWindowMinutes) && parsedResendWindowMinutes > 0 + ? parsedResendWindowMinutes + : 15; +export const OTP_RESEND_WINDOW_MS = OTP_RESEND_WINDOW_MINUTES * 60 * 1000; + +export function getOtpResendRetrySecondsRemaining(startedAt: number): number { + const remaining = OTP_RESEND_WINDOW_MS - (Date.now() - startedAt); + return Math.max(1, Math.ceil(remaining / 1000)); +} + +export function getOtpResendRetryMinutesRemaining(startedAt: number): number { + return Math.max(1, Math.ceil(getOtpResendRetrySecondsRemaining(startedAt) / 60)); +} + +export const AUTH_TOAST_MESSAGES = { + OTP_SENT: "Verification code sent to your email.", + OTP_RESENT: "A new verification code has been sent to your email.", + EMAIL_VERIFIED: "Your email has been verified. You can sign in now.", + RESET_CODE_VERIFIED: "Code verified. You can set a new password.", + PASSWORD_RESET: "Your password has been updated. You can sign in now.", +} as const; /** BroadcastChannel name for cross-tab auth synchronization */ export const AUTH_BROADCAST_CHANNEL = "kubeara-auth"; diff --git a/console-app/src/features/auth/utils/otp-resend-limit.ts b/console-app/src/features/auth/utils/otp-resend-limit.ts new file mode 100644 index 0000000..f21b303 --- /dev/null +++ b/console-app/src/features/auth/utils/otp-resend-limit.ts @@ -0,0 +1,148 @@ +import { + OTP_RESEND_MAX_ATTEMPTS, + OTP_RESEND_WINDOW_MS, + getOtpResendRetrySecondsRemaining, +} from "@/features/auth/constants"; + +export type OtpResendFlow = "email-verification" | "forgot-password"; + +export type OtpResendLimitState = { + /** Resend attempts used (initial OTP is not counted). */ + count: number; + startedAt: number; +}; + +const EMPTY_STATE: OtpResendLimitState = { count: 0, startedAt: 0 }; + +function getResendLimitStorageKey( + email: string, + flow: OtpResendFlow, +): string { + return `otp-resend-limit:${flow}:${email.toLowerCase().trim()}`; +} + +export function loadOtpResendLimitState( + email: string, + flow: OtpResendFlow, +): OtpResendLimitState { + if (!email) { + return EMPTY_STATE; + } + + const storageKey = getResendLimitStorageKey(email, flow); + const raw = window.localStorage.getItem(storageKey); + if (!raw) { + return EMPTY_STATE; + } + + try { + const parsed = JSON.parse(raw) as { count?: number; startedAt?: number }; + const count = Number(parsed.count ?? 0); + const startedAt = Number(parsed.startedAt ?? 0); + + if ( + !Number.isFinite(count) || + !Number.isFinite(startedAt) || + count < 0 || + startedAt <= 0 + ) { + window.localStorage.removeItem(storageKey); + return EMPTY_STATE; + } + + if (Date.now() - startedAt >= OTP_RESEND_WINDOW_MS) { + window.localStorage.removeItem(storageKey); + return EMPTY_STATE; + } + + return { count, startedAt }; + } catch { + window.localStorage.removeItem(storageKey); + return EMPTY_STATE; + } +} + +export function saveOtpResendLimitState( + email: string, + flow: OtpResendFlow, + state: OtpResendLimitState, +): void { + window.localStorage.setItem( + getResendLimitStorageKey(email, flow), + JSON.stringify(state), + ); +} + +export function clearOtpResendLimitState( + email: string, + flow: OtpResendFlow, +): void { + window.localStorage.removeItem(getResendLimitStorageKey(email, flow)); +} + +export function hasActiveOtpWindow( + email: string, + flow: OtpResendFlow, +): boolean { + return loadOtpResendLimitState(email, flow).startedAt > 0; +} + +export function isOtpResendLimitReached( + email: string, + flow: OtpResendFlow, +): boolean { + const state = loadOtpResendLimitState(email, flow); + return state.count >= OTP_RESEND_MAX_ATTEMPTS; +} + +export function startOtpWindow( + email: string, + flow: OtpResendFlow, +): OtpResendLimitState { + const current = loadOtpResendLimitState(email, flow); + if (current.startedAt > 0) { + return current; + } + + const state = { count: 0, startedAt: Date.now() }; + saveOtpResendLimitState(email, flow, state); + return state; +} + +export function recordOtpResend( + email: string, + flow: OtpResendFlow, +): OtpResendLimitState { + const current = loadOtpResendLimitState(email, flow); + const base = + current.startedAt > 0 ? current : { count: 0, startedAt: Date.now() }; + const next = { + count: base.count + 1, + startedAt: base.startedAt, + }; + saveOtpResendLimitState(email, flow, next); + return next; +} + +export function syncOtpResendLimitFromApiError( + email: string, + flow: OtpResendFlow, + retryAfterSeconds?: number, +): OtpResendLimitState { + const startedAt = + retryAfterSeconds != null && retryAfterSeconds > 0 + ? Date.now() - (OTP_RESEND_WINDOW_MS - retryAfterSeconds * 1000) + : Date.now(); + const state = { + count: OTP_RESEND_MAX_ATTEMPTS, + startedAt, + }; + saveOtpResendLimitState(email, flow, state); + return state; +} + +export function getOtpResendRetrySecondsForState( + startedAt: number, +): number { + return getOtpResendRetrySecondsRemaining(startedAt); +} diff --git a/console-app/src/index.css b/console-app/src/index.css index 6ed9494..eab1445 100644 --- a/console-app/src/index.css +++ b/console-app/src/index.css @@ -276,6 +276,14 @@ a:hover { font-size: 0.875rem; } +.auth-form-note { + margin: 0; + text-align: center; + font-size: 0.8125rem; + color: var(--muted); + line-height: 1.4; +} + .verify-email-address { margin: 0; font-size: 0.9375rem; diff --git a/console-app/src/pages/forgot-password-page.tsx b/console-app/src/pages/forgot-password-page.tsx index 243699b..efef4b6 100644 --- a/console-app/src/pages/forgot-password-page.tsx +++ b/console-app/src/pages/forgot-password-page.tsx @@ -3,75 +3,101 @@ import { useState } from "react"; import { AuthCard } from "@/features/auth/components/auth-card"; import { AuthForm } from "@/features/auth/components/auth-form"; import { useForgotPasswordMutation } from "@/features/auth/hooks"; -import { getErrorMessage } from "@/api/api-error"; +import { + extractRetryAfterSeconds, + getErrorMessage, + toApiError, +} from "@/api/api-error"; import { validateEmail } from "@/lib/validation"; import { showSuccessToast } from "@/lib/toast"; +import { + hasActiveOtpWindow, + isOtpResendLimitReached, + recordOtpResend, + startOtpWindow, + syncOtpResendLimitFromApiError, +} from "@/features/auth/utils/otp-resend-limit"; -/** - * Forgot password page component. - * - * Features: - * - Request password reset OTP via email - * - Success message display - * - Link back to login page - * - Error handling and display - */ export function ForgotPasswordPage() { - const navigate = useNavigate(); - const forgotMutation = useForgotPasswordMutation(); - const [error, setError] = useState(null); - const [fieldErrors, setFieldErrors] = useState>({}); + const navigate = useNavigate(); + const forgotMutation = useForgotPasswordMutation(); + const [error, setError] = useState(null); + const [fieldErrors, setFieldErrors] = useState>({}); - async function handleSubmit(formData: FormData) { - setError(null); + async function handleSubmit(formData: FormData) { + setError(null); - const email = String(formData.get("email") ?? "").trim(); - const emailError = validateEmail(email); - if (emailError) { - setFieldErrors({ email: emailError }); - return; - } - setFieldErrors({}); + const email = String(formData.get("email") ?? "").trim(); + const emailError = validateEmail(email); + if (emailError) { + setFieldErrors({ email: emailError }); + return; + } + setFieldErrors({}); + + if (isOtpResendLimitReached(email, "forgot-password")) { + navigate(`/forgot-password/verify?email=${encodeURIComponent(email)}`, { + replace: true, + }); + return; + } - try { - const data = await forgotMutation.mutateAsync({ email }); - showSuccessToast(data.message); - navigate(`/forgot-password/verify?email=${encodeURIComponent(email)}`, { - replace: true, - }); - } catch (err) { - setError(getErrorMessage(err)); - } + try { + const data = await forgotMutation.mutateAsync({ email }); + if (hasActiveOtpWindow(email, "forgot-password")) { + recordOtpResend(email, "forgot-password"); + } else { + startOtpWindow(email, "forgot-password"); + } + showSuccessToast(data.message); + navigate(`/forgot-password/verify?email=${encodeURIComponent(email)}`, { + replace: true, + }); + } catch (err) { + const apiError = toApiError(err); + if (apiError.status === 429) { + syncOtpResendLimitFromApiError( + email, + "forgot-password", + extractRetryAfterSeconds(apiError.body) ?? undefined, + ); + navigate(`/forgot-password/verify?email=${encodeURIComponent(email)}`, { + replace: true, + }); + return; + } + setError(getErrorMessage(err)); } + } - return ( - - Remember your password? Back to sign in -

- } - > - -
- ); + return ( + + Remember your password? Back to sign in +

+ } + > + +
+ ); } diff --git a/console-app/src/pages/forgot-password-verify-page.tsx b/console-app/src/pages/forgot-password-verify-page.tsx index 87c4920..db4e162 100644 --- a/console-app/src/pages/forgot-password-verify-page.tsx +++ b/console-app/src/pages/forgot-password-verify-page.tsx @@ -6,14 +6,30 @@ import { FormErrorsSummary } from "@/components/shared/form-errors-summary"; import { OTP_CODE_TYPE, OTP_RESEND_COOLDOWN_SECONDS, + OTP_RESEND_MAX_ATTEMPTS, + OTP_RESEND_WINDOW_MS, } from "@/features/auth/constants"; +import { + clearOtpResendLimitState, + getOtpResendRetrySecondsForState, + loadOtpResendLimitState, + recordOtpResend, + startOtpWindow, + syncOtpResendLimitFromApiError, +} from "@/features/auth/utils/otp-resend-limit"; import { useForgotPasswordMutation, useVerifyOtpMutation, } from "@/features/auth/hooks"; -import { getErrorMessage } from "@/api/api-error"; +import { + extractRetryAfterSeconds, + getErrorMessage, + toApiError, +} from "@/api/api-error"; import { showSuccessToast } from "@/lib/toast"; +const OTP_RESEND_FLOW = "forgot-password" as const; + export function ForgotPasswordVerifyPage() { const navigate = useNavigate(); const [searchParams] = useSearchParams(); @@ -27,9 +43,63 @@ export function ForgotPasswordVerifyPage() { email ? null : "Missing email parameter.", ); const [cooldown, setCooldown] = useState(OTP_RESEND_COOLDOWN_SECONDS); + console.log("🚀 ~ ForgotPasswordVerifyPage ~ OTP_RESEND_COOLDOWN_SECONDS -> ", OTP_RESEND_COOLDOWN_SECONDS) + const [resendLimitState, setResendLimitState] = useState(() => + loadOtpResendLimitState(email, OTP_RESEND_FLOW), + ); + const [retryAfterSeconds, setRetryAfterSeconds] = useState(0); + + const resendCount = resendLimitState.count; + const isResendLimitReached = resendCount >= OTP_RESEND_MAX_ATTEMPTS; + const retryAfterMinutes = Math.max(1, Math.ceil(retryAfterSeconds / 60)); const isPending = verifyMutation.isPending || resendMutation.isPending; + useEffect(() => { + if (email) { + setResendLimitState(startOtpWindow(email, OTP_RESEND_FLOW)); + } + }, [email]); + + useEffect(() => { + if (!email || resendLimitState.startedAt <= 0) { + return; + } + + const elapsed = Date.now() - resendLimitState.startedAt; + const remaining = OTP_RESEND_WINDOW_MS - elapsed; + + if (remaining <= 0) { + clearOtpResendLimitState(email, OTP_RESEND_FLOW); + setResendLimitState({ count: 0, startedAt: 0 }); + return; + } + + const timer = window.setTimeout(() => { + clearOtpResendLimitState(email, OTP_RESEND_FLOW); + setResendLimitState({ count: 0, startedAt: 0 }); + }, remaining); + + return () => window.clearTimeout(timer); + }, [email, resendLimitState.startedAt]); + + useEffect(() => { + if (!isResendLimitReached || resendLimitState.startedAt <= 0) { + setRetryAfterSeconds(0); + return; + } + + function updateRetryAfterSeconds() { + setRetryAfterSeconds( + getOtpResendRetrySecondsForState(resendLimitState.startedAt), + ); + } + + updateRetryAfterSeconds(); + const interval = window.setInterval(updateRetryAfterSeconds, 1000); + return () => window.clearInterval(interval); + }, [isResendLimitReached, resendLimitState.startedAt]); + useEffect(() => { if (cooldown <= 0) { return; @@ -67,7 +137,12 @@ export function ForgotPasswordVerifyPage() { } async function handleResend() { - if (!email || cooldown > 0 || resendMutation.isPending) { + if ( + !email || + cooldown > 0 || + resendMutation.isPending || + isResendLimitReached + ) { return; } @@ -78,7 +153,18 @@ export function ForgotPasswordVerifyPage() { showSuccessToast(data.message); setCooldown(OTP_RESEND_COOLDOWN_SECONDS); setOtp(""); + setResendLimitState(recordOtpResend(email, OTP_RESEND_FLOW)); } catch (err) { + const apiError = toApiError(err); + if (apiError.status === 429) { + setResendLimitState( + syncOtpResendLimitFromApiError( + email, + OTP_RESEND_FLOW, + extractRetryAfterSeconds(apiError.body) ?? undefined, + ), + ); + } setError(getErrorMessage(err)); } } @@ -86,7 +172,16 @@ export function ForgotPasswordVerifyPage() { return ( +

{email}

+

Enter the 6-digit code sent to your email address.

+ + ) : ( + "Enter the 6-digit code sent to your email address." + ) + } footer={

Back to sign in @@ -96,7 +191,6 @@ export function ForgotPasswordVerifyPage() { {email ? (

-

{email}

0} + disabled={isPending || cooldown > 0 || isResendLimitReached} > {resendMutation.isPending ? "Please wait…" - : cooldown > 0 - ? `Resend code in ${cooldown}s` - : "Resend code"} + : isResendLimitReached + ? "Resend limit reached" + : cooldown > 0 + ? `Resend code in ${cooldown}s` + : "Resend code"} + {isResendLimitReached && retryAfterSeconds > 0 && ( +

+ You have reached the resend limit. Try again after{" "} + {retryAfterMinutes} minute{retryAfterMinutes === 1 ? "" : "s"}. +

+ )} ) : (

{error}

diff --git a/console-app/src/pages/verify-email-page.tsx b/console-app/src/pages/verify-email-page.tsx index f887f32..d71c751 100644 --- a/console-app/src/pages/verify-email-page.tsx +++ b/console-app/src/pages/verify-email-page.tsx @@ -6,14 +6,30 @@ import { FormErrorsSummary } from "@/components/shared/form-errors-summary"; import { OTP_CODE_TYPE, OTP_RESEND_COOLDOWN_SECONDS, + OTP_RESEND_MAX_ATTEMPTS, + OTP_RESEND_WINDOW_MS, } from "@/features/auth/constants"; +import { + clearOtpResendLimitState, + getOtpResendRetrySecondsForState, + loadOtpResendLimitState, + recordOtpResend, + startOtpWindow, + syncOtpResendLimitFromApiError, +} from "@/features/auth/utils/otp-resend-limit"; import { useResendOtpMutation, useVerifyOtpMutation, } from "@/features/auth/hooks"; -import { getErrorMessage } from "@/api/api-error"; +import { + extractRetryAfterSeconds, + getErrorMessage, + toApiError, +} from "@/api/api-error"; import { showSuccessToast } from "@/lib/toast"; +const OTP_RESEND_FLOW = "email-verification" as const; + export function VerifyEmailPage() { const navigate = useNavigate(); const [searchParams] = useSearchParams(); @@ -27,9 +43,62 @@ export function VerifyEmailPage() { email ? null : "Missing email parameter.", ); const [cooldown, setCooldown] = useState(OTP_RESEND_COOLDOWN_SECONDS); + const [resendLimitState, setResendLimitState] = useState(() => + loadOtpResendLimitState(email, OTP_RESEND_FLOW), + ); + const [retryAfterSeconds, setRetryAfterSeconds] = useState(0); + + const resendCount = resendLimitState.count; + const isResendLimitReached = resendCount >= OTP_RESEND_MAX_ATTEMPTS; + const retryAfterMinutes = Math.max(1, Math.ceil(retryAfterSeconds / 60)); const isPending = verifyMutation.isPending || resendMutation.isPending; + useEffect(() => { + if (email) { + setResendLimitState(startOtpWindow(email, OTP_RESEND_FLOW)); + } + }, [email]); + + useEffect(() => { + if (!email || resendLimitState.startedAt <= 0) { + return; + } + + const elapsed = Date.now() - resendLimitState.startedAt; + const remaining = OTP_RESEND_WINDOW_MS - elapsed; + + if (remaining <= 0) { + clearOtpResendLimitState(email, OTP_RESEND_FLOW); + setResendLimitState({ count: 0, startedAt: 0 }); + return; + } + + const timer = window.setTimeout(() => { + clearOtpResendLimitState(email, OTP_RESEND_FLOW); + setResendLimitState({ count: 0, startedAt: 0 }); + }, remaining); + + return () => window.clearTimeout(timer); + }, [email, resendLimitState.startedAt]); + + useEffect(() => { + if (!isResendLimitReached || resendLimitState.startedAt <= 0) { + setRetryAfterSeconds(0); + return; + } + + function updateRetryAfterSeconds() { + setRetryAfterSeconds( + getOtpResendRetrySecondsForState(resendLimitState.startedAt), + ); + } + + updateRetryAfterSeconds(); + const interval = window.setInterval(updateRetryAfterSeconds, 1000); + return () => window.clearInterval(interval); + }, [isResendLimitReached, resendLimitState.startedAt]); + useEffect(() => { if (cooldown <= 0) { return; @@ -65,7 +134,12 @@ export function VerifyEmailPage() { } async function handleResend() { - if (!email || cooldown > 0 || resendMutation.isPending) { + if ( + !email || + cooldown > 0 || + resendMutation.isPending || + isResendLimitReached + ) { return; } @@ -76,7 +150,18 @@ export function VerifyEmailPage() { showSuccessToast(data.message); setCooldown(OTP_RESEND_COOLDOWN_SECONDS); setOtp(""); + setResendLimitState(recordOtpResend(email, OTP_RESEND_FLOW)); } catch (err) { + const apiError = toApiError(err); + if (apiError.status === 429) { + setResendLimitState( + syncOtpResendLimitFromApiError( + email, + OTP_RESEND_FLOW, + extractRetryAfterSeconds(apiError.body) ?? undefined, + ), + ); + } setError(getErrorMessage(err)); } } @@ -123,14 +208,22 @@ export function VerifyEmailPage() { type="button" className="btn-secondary" onClick={handleResend} - disabled={isPending || cooldown > 0} + disabled={isPending || cooldown > 0 || isResendLimitReached} > {resendMutation.isPending ? "Please wait…" - : cooldown > 0 - ? `Resend code in ${cooldown}s` - : "Resend code"} + : isResendLimitReached + ? "Resend limit reached" + : cooldown > 0 + ? `Resend code in ${cooldown}s` + : "Resend code"} + {isResendLimitReached && retryAfterSeconds > 0 && ( +

+ You have reached the resend limit. Try again after{" "} + {retryAfterMinutes} minute{retryAfterMinutes === 1 ? "" : "s"}. +

+ )} ) : (

{error}

diff --git a/console-app/src/vite-env.d.ts b/console-app/src/vite-env.d.ts index e06ea02..9942518 100644 --- a/console-app/src/vite-env.d.ts +++ b/console-app/src/vite-env.d.ts @@ -21,6 +21,9 @@ interface ImportMetaEnv { readonly VITE_SENTRY_REPLAYS_SESSION_SAMPLE_RATE?: string; /** Set to "true" to enable verbose Sentry SDK console logging. */ readonly VITE_SENTRY_DEBUG?: string; + readonly VITE_RESEND_OTP_MINUTES?: string; + readonly VITE_RESEND_OTP_MAX_ATTEMPTS?: string; + readonly VITE_RESEND_OTP_COOLDOWN_SECONDS?: string; } interface ImportMeta { From f3b7e7a94647c56e559a3bdd9ab7c5f6dee8bab7 Mon Sep 17 00:00:00 2001 From: Purvang V Date: Mon, 22 Jun 2026 14:20:48 +0530 Subject: [PATCH 3/4] fix: email template issue --- .../src/modules/auth/auth.service.ts | 37 ++++++-- .../src/modules/email/email.service.ts | 87 +++++++++++++++++-- console-app/package-lock.json | 17 +--- package-lock.json | 54 ++++-------- 4 files changed, 125 insertions(+), 70 deletions(-) diff --git a/apps/control-panel-app/src/modules/auth/auth.service.ts b/apps/control-panel-app/src/modules/auth/auth.service.ts index 6d7b755..7d50dd7 100644 --- a/apps/control-panel-app/src/modules/auth/auth.service.ts +++ b/apps/control-panel-app/src/modules/auth/auth.service.ts @@ -7,7 +7,7 @@ import { NotFoundException, } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { DataSource, MoreThan, Repository } from "typeorm"; +import { DataSource, EntityManager, MoreThan, Repository } from "typeorm"; import { JwtService } from "@nestjs/jwt"; import dayjs from "dayjs"; import * as bcrypt from "bcrypt"; @@ -162,8 +162,22 @@ export class AuthService { } } - private async replaceOtp(userId: string, codeType: CODE_TYPE): Promise { - await this.userCodeRepository.update( + private getUserCodeRepository( + manager?: EntityManager, + ): Repository { + return manager + ? manager.getRepository(UserCodeEntity) + : this.userCodeRepository; + } + + private async replaceOtp( + userId: string, + codeType: CODE_TYPE, + manager?: EntityManager, + ): Promise { + const userCodeRepository = this.getUserCodeRepository(manager); + + await userCodeRepository.update( { userId, codeType, @@ -178,14 +192,16 @@ export class AuthService { private async createOtpRecord( userId: string, codeType: CODE_TYPE, + manager?: EntityManager, ): Promise<{ otp: string }> { - await this.replaceOtp(userId, codeType); + await this.replaceOtp(userId, codeType, manager); const otp = GenerateOTP(); const otpHash = await bcrypt.hash(otp, SALT_ROUNDS); + const userCodeRepository = this.getUserCodeRepository(manager); - await this.userCodeRepository.save( - this.userCodeRepository.create({ + await userCodeRepository.save( + userCodeRepository.create({ userId, codeType, otpHash, @@ -343,14 +359,15 @@ export class AuthService { const savedUser = await userRepository.save(user); - await queryRunner.commitTransaction(); - const { otp } = await this.createOtpRecord( savedUser.id, CODE_TYPE.EMAIL_VERIFICATION, + queryRunner.manager, ); await this.sendOtpEmail(savedUser, CODE_TYPE.EMAIL_VERIFICATION, otp); + await queryRunner.commitTransaction(); + return { message: SUCCESS_MESSAGES.AUTH.SIGNUP, data: { @@ -361,7 +378,9 @@ export class AuthService { }, }; } catch (error) { - await queryRunner.rollbackTransaction(); + if (queryRunner.isTransactionActive) { + await queryRunner.rollbackTransaction(); + } throw error; } finally { await queryRunner.release(); diff --git a/apps/control-panel-app/src/modules/email/email.service.ts b/apps/control-panel-app/src/modules/email/email.service.ts index 90ac6d1..e973c2c 100644 --- a/apps/control-panel-app/src/modules/email/email.service.ts +++ b/apps/control-panel-app/src/modules/email/email.service.ts @@ -2,6 +2,19 @@ import { Injectable } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { BrevoClient } from "@getbrevo/brevo"; +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function formatOtp(otp: string): string { + return otp.length === 6 ? `${otp.slice(0, 3)} ${otp.slice(3)}` : otp; +} + @Injectable() export class EmailService { private readonly brevo: BrevoClient; @@ -18,18 +31,80 @@ export class EmailService { this.brevo = new BrevoClient({ apiKey }); } + private buildOtpEmailHtml(input: { + toName?: string; + otp: string; + purposeLabel: string; + }): string { + const greeting = input.toName + ? `Hi ${escapeHtml(input.toName)},` + : "Hi there,"; + const purpose = escapeHtml(input.purposeLabel); + const formattedOtp = escapeHtml(formatOtp(input.otp)); + const brandName = escapeHtml(this.fromName); + + return ` + + + + + ${purpose} code + + + + + + +
+ + + + + + + + + + +
+

${brandName}

+

Secure one-time verification

+
+

${greeting}

+

+ Use the code below to complete your ${purpose}. + This code is valid for a limited time and can only be used once. +

+ + + + +
+

Your verification code

+

${formattedOtp}

+
+

+ Enter this code in the app to continue. If you did not request this, you can safely ignore this email. + Your account will remain unchanged. +

+
+

+ This is an automated message from ${brandName}. Please do not reply to this email. +

+
+
+ +`; + } + async sendOtpEmail(input: { toEmail: string; toName?: string; otp: string; purposeLabel: string; }): Promise { - const subject = `${input.purposeLabel} OTP`; - const htmlContent = ` -

Your verification code is:

-

${input.otp}

-

This code expires soon. If you did not request it, you can ignore this email.

- `; + const subject = `Your ${input.purposeLabel} code`; + const htmlContent = this.buildOtpEmailHtml(input); await this.brevo.transactionalEmails.sendTransacEmail({ subject, diff --git a/console-app/package-lock.json b/console-app/package-lock.json index eb9ac6e..51dd869 100644 --- a/console-app/package-lock.json +++ b/console-app/package-lock.json @@ -86,7 +86,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -2476,7 +2475,6 @@ "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2487,7 +2485,6 @@ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2498,7 +2495,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2548,7 +2544,6 @@ "integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.61.1", "@typescript-eslint/types": "8.61.1", @@ -2829,8 +2824,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/acorn": { "version": "8.17.0", @@ -2838,7 +2832,6 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3013,7 +3006,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -3326,7 +3318,6 @@ "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -3678,7 +3669,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5276,7 +5266,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5286,7 +5275,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5299,7 +5287,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.79.0.tgz", "integrity": "sha512-mhYp/MTmXvzYX6AJcJVko0rktoIhhmRnEouObj4wF5i/tCttgJvnp1+9wRkpITZjDTqpo4IOSJqu0dBlPlV/Lw==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -5736,7 +5723,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5823,7 +5809,6 @@ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/package-lock.json b/package-lock.json index b8cee46..729d713 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "license": "UNLICENSED", "dependencies": { + "@getbrevo/brevo": "^6.0.1", "@modelcontextprotocol/sdk": "^1.29.0", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.4", @@ -247,7 +248,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -1173,7 +1173,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" @@ -1186,7 +1185,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -1825,6 +1823,14 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@getbrevo/brevo": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@getbrevo/brevo/-/brevo-6.0.1.tgz", + "integrity": "sha512-XFFbR1CvMTdu9n98ToLC1lWPIZ0F1P5y7Wxml6YpLEMt9lDwqOwt5hqkgavQ8/t2oJn2/BcyiTZIgYaaLI3uzA==", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@hono/node-server": { "version": "1.19.14", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", @@ -3640,7 +3646,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.27.tgz", "integrity": "sha512-kEGSzqM2lWr4whh4Ubflw+oPZSEzxvRMu9WL+LveZploJWTjec5bBlCiRVlVzTPg2kIwBiLwWSvCCW7Wnin1gg==", "license": "MIT", - "peer": true, "dependencies": { "file-type": "21.3.4", "iterare": "1.2.1", @@ -3699,7 +3704,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.27.tgz", "integrity": "sha512-K6DX7hcqmZdeXkv7tsPakKBRCgqL19a4mtbX4FluY0hWtFdtPKp6lbe+lb8gWPfvLdbOWr/CPScn7BSjBX+Ecg==", "license": "MIT", - "peer": true, "dependencies": { "fast-safe-stringify": "2.1.1", "iterare": "1.2.1", @@ -3782,7 +3786,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.27.tgz", "integrity": "sha512-0ZFhz6H6EdGh4xQVbUNwjoAwBuz73P7FvUAl67h9CTdMqQlJDaQYJApBv8pKfVZ1fGjMCbl0m9DcC6pXaZPWSQ==", "license": "MIT", - "peer": true, "dependencies": { "cors": "2.8.6", "express": "5.2.1", @@ -3804,7 +3807,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.1.27.tgz", "integrity": "sha512-xgpLzaIDGOCC6xOAtHnRAz8sqieFgGxxu3MN5ID026Jt6oeL3efp29N5QHhPr7UlqBfy/Jd02uj0POkZq6Au3Q==", "license": "MIT", - "peer": true, "dependencies": { "socket.io": "4.8.3", "tslib": "2.8.1" @@ -3962,7 +3964,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.27.tgz", "integrity": "sha512-X3OgJt9KgYTvt9D7sNz9SOj3A1daAHy7DZrYhM1pky8Fh+erlKQH5IQ/tKm+GaJKA5M0srBUr1CMqjak/qNxOw==", "license": "MIT", - "peer": true, "dependencies": { "iterare": "1.2.1", "object-hash": "3.0.0", @@ -4087,7 +4088,6 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.12.1.tgz", "integrity": "sha512-7aPGWeqA3uFm43o19umzdl16CEjK/JQGtSXVPevplTaOU3VJA/rseBC1QvYUz9lLDIMBimc4SW/zrW4S89BaCA==", "license": "MIT", - "peer": true, "dependencies": { "cluster-key-slot": "1.1.2" }, @@ -4430,7 +4430,6 @@ "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -4537,7 +4536,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -4744,7 +4742,6 @@ "integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.61.1", "@typescript-eslint/types": "8.61.1", @@ -5496,7 +5493,6 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5545,7 +5541,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -6005,7 +6000,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -6219,7 +6213,6 @@ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -6267,15 +6260,13 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/class-validator": { "version": "0.15.1", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.15.1.tgz", "integrity": "sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==", "license": "MIT", - "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -6704,7 +6695,6 @@ "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -7313,7 +7303,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7374,7 +7363,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -7659,7 +7647,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -8425,7 +8412,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -8871,7 +8857,6 @@ "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.4.2", "@jest/types": "30.4.1", @@ -10986,7 +10971,6 @@ "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", "license": "MIT", - "peer": true, "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", @@ -11111,7 +11095,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.13.0", "pg-pool": "^3.14.0", @@ -11368,7 +11351,6 @@ "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -11549,7 +11531,6 @@ "resolved": "https://registry.npmjs.org/redis/-/redis-5.12.1.tgz", "integrity": "sha512-LDsoVvb/CpoV9EN3FXvgvSHNJWuCIzl9MiO3ppOevuGLpSGJhwfQjpEwfFJcQvNSddHADDdZaWx0HnmMxRXG7g==", "license": "MIT", - "peer": true, "dependencies": { "@redis/bloom": "5.12.1", "@redis/client": "5.12.1", @@ -11565,8 +11546,7 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/require-directory": { "version": "2.1.1", @@ -11671,7 +11651,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -11727,7 +11706,6 @@ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -12947,7 +12925,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -13117,7 +13094,6 @@ "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-1.0.0.tgz", "integrity": "sha512-2mSKNqucP8vo+xQLP59xlHUcqLvG6qajxA7q7tnhJgeZjTrA6lK/Ar7LRyiAxdXhyXmGbIPsArPmcUB9Xg+M7w==", "license": "MIT", - "peer": true, "dependencies": { "@sqltools/formatter": "^1.2.5", "ansis": "^4.2.0", @@ -13212,7 +13188,6 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13305,7 +13280,6 @@ "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "pathe": "^2.0.3" } @@ -13570,6 +13544,7 @@ "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -13588,6 +13563,7 @@ "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -13602,6 +13578,7 @@ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=4.0" } @@ -13612,6 +13589,7 @@ "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -13665,7 +13643,6 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "bin": { "workerd": "bin/workerd" }, @@ -14033,7 +14010,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } From 33e95bfa13d84c6e9e03b301bed3ef55fe7bd11f Mon Sep 17 00:00:00 2001 From: Purvang V Date: Mon, 22 Jun 2026 14:21:58 +0530 Subject: [PATCH 4/4] fix: email template issue --- apps/control-panel-app/src/modules/auth/auth.controller.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/control-panel-app/src/modules/auth/auth.controller.ts b/apps/control-panel-app/src/modules/auth/auth.controller.ts index d7c5f3d..84b388a 100644 --- a/apps/control-panel-app/src/modules/auth/auth.controller.ts +++ b/apps/control-panel-app/src/modules/auth/auth.controller.ts @@ -126,7 +126,6 @@ export class AuthController { */ @Post("resend-otp") async resendOtp(@Body() forgotPasswordDto: ForgotPasswordDto) { - console.log('the email called !') return this.authService.resendOtp(forgotPasswordDto.email); }