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 (
+ }
+ >
+ {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() {
}
>