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 (
- }
- >
-
-
- );
+ 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
new file mode 100644
index 0000000..db4e162
--- /dev/null
+++ b/console-app/src/pages/forgot-password-verify-page.tsx
@@ -0,0 +1,238 @@
+import { Link, useNavigate, useSearchParams } from "react-router-dom";
+import { useEffect, useState } from "react";
+import { AuthCard } from "@/features/auth/components/auth-card";
+import { OtpInput } from "@/features/auth/components/otp-input";
+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 {
+ 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();
+ const email = searchParams.get("email") ?? "";
+
+ const verifyMutation = useVerifyOtpMutation();
+ const resendMutation = useForgotPasswordMutation();
+
+ const [otp, setOtp] = useState("");
+ const [error, setError] = useState(
+ 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;
+ }
+
+ const timer = window.setTimeout(() => {
+ setCooldown((current) => current - 1);
+ }, 1000);
+
+ return () => window.clearTimeout(timer);
+ }, [cooldown]);
+
+ async function handleVerify(e: React.FormEvent) {
+ e.preventDefault();
+ if (!email || otp.length !== 6) {
+ setError("Enter the 6-digit verification code.");
+ return;
+ }
+
+ setError(null);
+
+ try {
+ const data = await verifyMutation.mutateAsync({
+ email,
+ otp,
+ purpose: OTP_CODE_TYPE.FORGOT_PASSWORD,
+ });
+ showSuccessToast(data.message);
+ navigate(`/reset-password?email=${encodeURIComponent(email)}`, {
+ replace: true,
+ });
+ } catch (err) {
+ setError(getErrorMessage(err));
+ }
+ }
+
+ async function handleResend() {
+ if (
+ !email ||
+ cooldown > 0 ||
+ resendMutation.isPending ||
+ isResendLimitReached
+ ) {
+ return;
+ }
+
+ setError(null);
+
+ try {
+ const data = await resendMutation.mutateAsync({ email });
+ 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));
+ }
+ }
+
+ 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
+
+ }
+ >
+ {email ? (
+
+ ) : (
+ {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() {
}
>