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 6f6cdcb..0b78302 100644 --- a/apps/control-panel-app/src/constants/error.ts +++ b/apps/control-panel-app/src/constants/error.ts @@ -57,6 +57,10 @@ 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", + 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 585eb1b..559ce42 100644 --- a/apps/control-panel-app/src/constants/success.ts +++ b/apps/control-panel-app/src/constants/success.ts @@ -23,16 +23,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_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 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..7d50dd7 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, IsNull, Repository } from "typeorm"; +import { DataSource, EntityManager, MoreThan, Repository } from "typeorm"; import { JwtService } from "@nestjs/jwt"; import dayjs from "dayjs"; import * as bcrypt from "bcrypt"; @@ -31,6 +33,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 +56,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 +74,179 @@ 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 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 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, + status: EntityStatus.ACTIVE, + }, + { + status: EntityStatus.INACTIVE, + }, + ); + } + + private async createOtpRecord( + userId: string, + codeType: CODE_TYPE, + manager?: EntityManager, + ): Promise<{ otp: string }> { + await this.replaceOtp(userId, codeType, manager); + + const otp = GenerateOTP(); + const otpHash = await bcrypt.hash(otp, SALT_ROUNDS); + const userCodeRepository = this.getUserCodeRepository(manager); + + await userCodeRepository.save( + 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 +342,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,12 +352,20 @@ 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); + 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 { @@ -193,7 +378,9 @@ export class AuthService { }, }; } catch (error) { - await queryRunner.rollbackTransaction(); + if (queryRunner.isTransactionActive) { + await queryRunner.rollbackTransaction(); + } throw error; } finally { await queryRunner.release(); @@ -234,6 +421,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 +598,43 @@ 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, - }, + await this.assertOtpResendAllowed(user.id, CODE_TYPE.FORGOT_PASSWORD); + + 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, + data: null, + }; + } - 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); + } + + await this.assertOtpResendAllowed(user.id, CODE_TYPE.EMAIL_VERIFICATION); + + 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, + data: null, }; } @@ -459,16 +652,10 @@ 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,8 +683,20 @@ 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); + } + + 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, }; } @@ -515,16 +714,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,11 +735,21 @@ 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); return { message: SUCCESS_MESSAGES.AUTH.PASSWORD_RESET, + data: null, }; } } 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..e973c2c --- /dev/null +++ b/apps/control-panel-app/src/modules/email/email.service.ts @@ -0,0 +1,124 @@ +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; + 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 }); + } + + 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 = `Your ${input.purposeLabel} code`; + const htmlContent = this.buildOtpEmailHtml(input); + + 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/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/console-app/src/api/api-error.ts b/console-app/src/api/api-error.ts index 0adacaf..b90b421 100644 --- a/console-app/src/api/api-error.ts +++ b/console-app/src/api/api-error.ts @@ -151,6 +151,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 user-facing error message from various error types. * @@ -191,7 +206,11 @@ export function getErrorMessage(error: unknown): string { return "You do not have permission to perform this action."; } - return GENERIC_ERROR_MESSAGE; + if (status === 429) { + return "Too many requests. Please try again later."; + } + + return error.message || "Request failed"; } if (error instanceof Error) { 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..6f24cca 100644 --- a/console-app/src/features/auth/api/index.ts +++ b/console-app/src/features/auth/api/index.ts @@ -13,10 +13,25 @@ import type { LoginRequest, MessageResponse, ResetPasswordRequest, + ResendOtpRequest, SignupRequest, 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>( @@ -88,7 +103,19 @@ 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( + input: ResendOtpRequest, +): Promise { + const response = await apiClient.post( + "/auth/resend-otp", + input, + ); + return { + message: getAuthMessage(response.data, AUTH_TOAST_MESSAGES.OTP_RESENT), + }; } export async function verifyOtp( @@ -98,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( @@ -108,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/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}

} + + {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/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} /> + + + {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 { diff --git a/package-lock.json b/package-lock.json index 28e2e52..6218497 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", @@ -1822,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", diff --git a/package.json b/package.json index c36bce8..302edb8 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",