From d730476b09f462467113db22f6936ffd1bbed479 Mon Sep 17 00:00:00 2001 From: Nihar Date: Tue, 23 Jun 2026 14:12:40 +0530 Subject: [PATCH 01/13] feat: implement OTP-based email verification and update user schema --- backend/src/models/user.model.js | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/backend/src/models/user.model.js b/backend/src/models/user.model.js index c7895b0..013c88d 100644 --- a/backend/src/models/user.model.js +++ b/backend/src/models/user.model.js @@ -106,7 +106,7 @@ const userSchema = new mongoose.Schema( default: null, }, - // OTP-based recovery + // OTP-based password recovery resetOtp: { type: String, select: false, @@ -118,21 +118,22 @@ const userSchema = new mongoose.Schema( default: null, }, - // Future implementation - // Email Verification - // isVerified: { - // type: Boolean, - // default: false, - // }, + // OTP-based email verification + isVerified: { + type: Boolean, + default: false, + }, - // verificationToken: { - // type: String, - // select: false, - // }, + emailVerificationOtp: { + type: String, + select: false, + default: null, + }, - // verificationTokenExpires: { - // type: Date, - // }, + emailVerificationOtpExpiry: { + type: Date, + default: null, + }, }, { timestamps: true }, ); From 8c90d7d74efa13941211c4ae4c2e5bf93b47c1f2 Mon Sep 17 00:00:00 2001 From: Nihar Date: Tue, 23 Jun 2026 14:51:41 +0530 Subject: [PATCH 02/13] feat: enhance email sending functionality with OTP verification and improved error handling --- backend/src/lib/sendEmail.js | 173 ++++++++++++++++++++++------------- 1 file changed, 107 insertions(+), 66 deletions(-) diff --git a/backend/src/lib/sendEmail.js b/backend/src/lib/sendEmail.js index 5ef8c2c..250f749 100644 --- a/backend/src/lib/sendEmail.js +++ b/backend/src/lib/sendEmail.js @@ -1,19 +1,19 @@ export const sendWelcomeEmail = async (email, fullName) => { - try { - const response = await fetch("https://api.brevo.com/v3/smtp/email", { - method: "POST", - headers: { - "Content-Type": "application/json", - "api-key": process.env.BREVO_API_KEY, - }, - body: JSON.stringify({ - sender: { - name: "PASO", - email: process.env.BREVO_EMAIL, - }, - to: [{ email }], - subject: "Welcome to PASO - the best chat app", - htmlContent: ` + try { + const response = await fetch("https://api.brevo.com/v3/smtp/email", { + method: "POST", + headers: { + "Content-Type": "application/json", + "api-key": process.env.BREVO_API_KEY, + }, + body: JSON.stringify({ + sender: { + name: "PASO", + email: process.env.BREVO_EMAIL, + }, + to: [{ email }], + subject: "Welcome to PASO - the best chat app", + htmlContent: `

Welcome to PASO

Hey ${fullName},

@@ -22,73 +22,114 @@ export const sendWelcomeEmail = async (email, fullName) => {

Let’s chat smarter 😎

`, - }), - }); + }), + }); - const data = await response.text(); + const data = await response.text(); - if (!response.ok) { - console.error("Brevo API Error:", data); - } else { - console.log("Email sent successfully:", data); - } - } catch (error) { - console.error("Brevo error:", error); - } + if (!response.ok) { + console.error("Brevo API Error:", data); + } else { + console.log("Email sent successfully:", data); + } + } catch (error) { + console.error("Brevo error:", error); + } }; export const sendOtpEmail = async (email, otp) => { - console.log("BREVO_EMAIL:", process.env.BREVO_EMAIL); - console.log("BREVO_API_KEY loaded:", !!process.env.BREVO_API_KEY); - console.log("OTP email sent Successfully"); + console.log("BREVO_EMAIL:", process.env.BREVO_EMAIL); + console.log("BREVO_API_KEY loaded:", !!process.env.BREVO_API_KEY); + console.log("OTP email sent Successfully"); - if ( - !process.env.BREVO_API_KEY || - process.env.BREVO_API_KEY === "your_brevo_api_key" || - process.env.BREVO_API_KEY === "dummy" - ) { - return; - } + if ( + !process.env.BREVO_API_KEY || + process.env.BREVO_API_KEY === "your_brevo_api_key" || + process.env.BREVO_API_KEY === "dummy" + ) { + return; + } - try { - const response = await fetch("https://api.brevo.com/v3/smtp/email", { - method: "POST", - headers: { - "Content-Type": "application/json", - "api-key": process.env.BREVO_API_KEY, - }, - body: JSON.stringify({ - sender: { - name: "PASO Support", - email: process.env.BREVO_EMAIL, - }, - to: [{ email }], - subject: "PASO - Password Reset OTP", - htmlContent: ` -
+ try { + const response = await fetch("https://api.brevo.com/v3/smtp/email", { + method: "POST", + headers: { + "Content-Type": "application/json", + "api-key": process.env.BREVO_API_KEY, + }, + body: JSON.stringify({ + sender: { + name: "PASO Support", + email: process.env.BREVO_EMAIL, + }, + to: [{ email }], + subject: "PASO - Password Reset OTP", + htmlContent: ` +

PASO Password Recovery

Hello,

You requested to recover your password. Please use the following One-Time Password (OTP) to complete the verification process:

- ${otp} + ${otp}

This OTP is valid for 5 minutes only.

If you did not initiate this request, please ignore this email.

- PASO Chat App Security Team + PASO Chat App Security Team

-
+
`, - }), - }); + }), + }); - const data = await response.text(); - if (!response.ok) { - console.error("Brevo API Error (OTP):", data); - } else { - console.log("OTP Email sent successfully:", data); - } - } catch (error) { - console.error("Brevo OTP email error:", error); - } + const data = await response.text(); + if (!response.ok) { + console.error("Brevo API Error (OTP):", data); + } else { + console.log("OTP Email sent successfully:", data); + } + } catch (error) { + console.error("Brevo OTP email error:", error); + } }; + +export const sendVerificationOtpEmail = async (email, otp) => { + if (!process.env.BREVO_API_KEY || process.env.BREVO_API_KEY === "your_brevo_api_key" || process.env.BREVO_API_KEY === "dummy") { return; } + try { + const response = await fetch("https://api.brevo.com/v3/smtp/email", { + method: "POST", + headers: { + "Content-Type": "application/json", + "api-key": process.env.BREVO_API_KEY, + }, + body: JSON.stringify({ + sender: { + name: "PASO-Support", + email: process.env.BREVO_EMAIL, + }, + to: [{ email }], + subject: "Verify your PASO Account", + htmlContent: + `
+

Verify Your Email

+

Welcome to PASO!

+

Use the follwing One-Time_Password (OTP) for verify your email address:

+
+ ${otp} +
+

This OTP is valid for 5 minutes.

+

If you did not create this account, you can safely ignore this email.

+

PASO Security Team

+
` + }), + }); + const data = await response.text(); + if (!response.ok) { + console.error("Brevo API Error (Verification OTP):", data); + } else { + console.log("Verification OTP email sent successfully:", data); + } + } catch (error) { + console.error("Verification OTP email error:", error); + } +}; \ No newline at end of file From 1e1a5bdfe4e1f4d6d79d708b6d2f5407943924f6 Mon Sep 17 00:00:00 2001 From: Nihar Date: Tue, 23 Jun 2026 21:12:50 +0530 Subject: [PATCH 03/13] feat(auth): generate verification OTP during signup --- backend/src/controllers/auth.controller.js | 39 ++++++++++++---------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index 6b5d675..855f7c1 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -2,7 +2,7 @@ import { generateToken } from "../lib/utils.js"; import User from "../models/user.model.js"; import bcrypt from "bcryptjs"; import cloudinary from "../lib/cloudinary.js"; -import { sendWelcomeEmail, sendOtpEmail } from "../lib/sendEmail.js"; +import { sendOtpEmail, sendVerificationOtpEmail } from "../lib/sendEmail.js"; import crypto from "crypto"; //signup @@ -13,8 +13,10 @@ import crypto from "crypto"; // Check if user already exists in DB // Hash password using bcrypt // Create new user in database -// Generate JWT token and set cookie -// Send user data (without password) in response +// Generate email verification OTP +// Create unverified user +// Send verification OTP email +// Return verification required response export const signup = async (req, res) => { try { const { fullName, email, password, securityQuestions } = req.body; @@ -81,35 +83,38 @@ export const signup = async (req, res) => { ), })) ); + const verificationOtp = crypto.randomInt(100000, 1000000).toString(); - // Create user (NO verification fields) + const hashedVerificationOtp = crypto + .createHash("sha256") + .update(verificationOtp) + .digest("hex"); + + const verificationOtpExpiry = new Date( + Date.now() + 5 * 60 * 1000 + ); + // Create unverified user with email verification OTP const newUser = await User.create({ fullName, email: normalizedEmail, password: hashedPassword, securityQuestions: hashedQuestions, role: "user", + isVerified: false, + emailVerificationOtp: hashedVerificationOtp, + emailVerificationOtpExpiry: verificationOtpExpiry, }); - // Generate JWT immediately - const token = generateToken(newUser._id); - - // Send Welcome Email (non-blocking) setImmediate(() => { - sendWelcomeEmail( + sendVerificationOtpEmail( newUser.email, - newUser.fullName + verificationOtp ); }); - // Send user response res.status(201).json({ - _id: newUser._id, - fullName: newUser.fullName, - email: newUser.email, - profilePic: newUser.profilePic, - role: newUser.role, - token, + message: + "Account created successfully. Please verify your email using the OTP sent to your email address.", }); } catch (error) { console.error("Signup error:", error); From 84692b159d622fc25b207133dbde1937010893c0 Mon Sep 17 00:00:00 2001 From: Nihar Date: Wed, 24 Jun 2026 13:23:07 +0530 Subject: [PATCH 04/13] feat(auth): generate verification OTP during signup --- backend/src/controllers/auth.controller.js | 81 +++++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index 855f7c1..818bc73 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -2,7 +2,7 @@ import { generateToken } from "../lib/utils.js"; import User from "../models/user.model.js"; import bcrypt from "bcryptjs"; import cloudinary from "../lib/cloudinary.js"; -import { sendOtpEmail, sendVerificationOtpEmail } from "../lib/sendEmail.js"; +import { sendWelcomeEmail, sendOtpEmail, sendVerificationOtpEmail } from "../lib/sendEmail.js"; import crypto from "crypto"; //signup @@ -125,6 +125,85 @@ export const signup = async (req, res) => { } }; +export const verifyEmail = async (req, res) => { + try { + const { email, otp } = req.body; + + if (!email || !otp) { + return res.status(400).json({ + message: "Email and OTP are required", + }); + } + + const normalizedEmail = email.toLowerCase().trim(); + + const user = await User.findOne({ + email: normalizedEmail, + }).select( + "+emailVerificationOtp +emailVerificationOtpExpiry" + ); + + if (!user) { + return res.status(400).json({ + message: "Invalid email or OTP", + }); + } + + if (user.isVerified) { + return res.status(400).json({ + message: "Email is already verified", + }); + } + + if ( + !user.emailVerificationOtp || + !user.emailVerificationOtpExpiry || + user.emailVerificationOtpExpiry < new Date() + ) { + return res.status(400).json({ + message: "OTP has expired or is invalid", + }); + } + + const hashedOtp = crypto + .createHash("sha256") + .update(otp) + .digest("hex"); + + if (hashedOtp !== user.emailVerificationOtp) { + return res.status(400).json({ + message: "Invalid email or OTP", + }); + } + + user.isVerified = true; + user.emailVerificationOtp = null; + user.emailVerificationOtpExpiry = null; + + await user.save(); + + setImmediate(() => { + sendWelcomeEmail( + user.email, + user.fullName + ); + }); + + res.status(200).json({ + message: "Email verified successfully", + }); + } catch (error) { + console.error( + "Verify email error:", + error + ); + + res.status(500).json({ + message: "Internal Server Error", + }); + } +}; + //login // Get email, password from req.body // Convert email to lowercase From a210f6208a2103125e5da4c291f7417ba739ece5 Mon Sep 17 00:00:00 2001 From: Nihar Date: Wed, 24 Jun 2026 13:36:12 +0530 Subject: [PATCH 05/13] feat(auth): restrict login for unverified users --- backend/src/controllers/auth.controller.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index 818bc73..ef35ea9 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -245,11 +245,11 @@ export const login = async (req, res) => { }); } - // if (!user.isVerified) { - // return res.status(401).json({ - // message: "Please verify your email first", - // }); - // } + if (!user.isVerified) { + return res.status(403).json({ + message: "Please verify your email before logging in", + }); + } // checking user is baned from admin side or not if (user.isBanned) { From 7860297ba2ad1bfb68a4cb4f34787a0ed3c2c02e Mon Sep 17 00:00:00 2001 From: Nihar Date: Wed, 24 Jun 2026 13:54:46 +0530 Subject: [PATCH 06/13] feat(auth): add resend verification OTP endpoint --- backend/src/controllers/auth.controller.js | 76 ++++++++++++++++++++++ backend/src/routes/auth.route.js | 4 ++ 2 files changed, 80 insertions(+) diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index ef35ea9..bd6506e 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -872,4 +872,80 @@ export const verifyOtp = async ( message: "Internal Server Error", }); } +}; + +export const resendVerificationOtp = async ( + req, + res +) => { + try { + const { email } = req.body; + + if (!email) { + return res.status(400).json({ + message: "Email is required", + }); + } + + const normalizedEmail = + email.toLowerCase().trim(); + + const user = await User.findOne({ + email: normalizedEmail, + }).select( + "+emailVerificationOtp +emailVerificationOtpExpiry" + ); + + if (!user) { + return res.status(400).json({ + message: "User not found", + }); + } + + if (user.isVerified) { + return res.status(400).json({ + message: "Email is already verified", + }); + } + + const verificationOtp = crypto + .randomInt(100000, 1000000) + .toString(); + + const hashedVerificationOtp = crypto + .createHash("sha256") + .update(verificationOtp) + .digest("hex"); + + user.emailVerificationOtp = + hashedVerificationOtp; + + user.emailVerificationOtpExpiry = + new Date( + Date.now() + 5 * 60 * 1000 + ); + + await user.save(); + + setImmediate(() => { + sendVerificationOtpEmail( + user.email, + verificationOtp + ); + }); + + return res.status(200).json({ + message: + "Verification OTP sent successfully", + }); + } catch (error) { + console.error( + "Resend verification OTP error:", + error + ); + + return res.status(500).json({ + message: "Internal Server Error", + }); + } }; \ No newline at end of file diff --git a/backend/src/routes/auth.route.js b/backend/src/routes/auth.route.js index fdbae6a..cf0ba36 100644 --- a/backend/src/routes/auth.route.js +++ b/backend/src/routes/auth.route.js @@ -4,9 +4,11 @@ import { login, logout, signup, + verifyEmail, updateProfile, setupSecurityQuestions, verifySecurityAnswers, + resendVerificationOtp, resetPassword, getSecurityQuestions, sendOtp, @@ -19,6 +21,8 @@ import { forgotPasswordLimiter, otpRateLimiter } from "../middleware/rateLimiter const router = express.Router(); router.post("/signup", signup); +router.post("/verify-email", otpRateLimiter, verifyEmail); +router.post("/resend-verification-otp", resendVerificationOtp); router.post("/login", login); router.post("/logout", logout); router.get("/check", protectRoute, checkAuth); From 0574522914a5891a9b59078e1d489376129091f0 Mon Sep 17 00:00:00 2001 From: Nihar Date: Wed, 24 Jun 2026 18:05:22 +0530 Subject: [PATCH 07/13] feat: add email verification OTP flow --- backend/test/admin/admin.test.js | 7 +- backend/test/auth/checkAuth.test.js | 7 +- backend/test/auth/login.test.js | 26 ++- backend/test/auth/otp.test.js | 158 ++++++++++++++++++ backend/test/auth/signup.test.js | 68 ++++++-- backend/test/group/group.test.js | 7 +- backend/test/integration/integration.test.js | 99 +++++++---- backend/test/message/message.test.js | 4 +- .../test/middleware/auth.middleware.test.js | 5 +- backend/test/security/security.test.js | 59 +++++-- backend/test/socket/socket.test.js | 3 +- backend/test/utils/testHelpers.js | 10 +- 12 files changed, 366 insertions(+), 87 deletions(-) diff --git a/backend/test/admin/admin.test.js b/backend/test/admin/admin.test.js index f4a591f..bae70b0 100644 --- a/backend/test/admin/admin.test.js +++ b/backend/test/admin/admin.test.js @@ -4,11 +4,12 @@ */ import { jest } from "@jest/globals"; -import { ObjectId } from "mongodb"; +import mongoose from "mongoose"; jest.unstable_mockModule("../../src/lib/sendEmail.js", () => ({ sendWelcomeEmail: jest.fn(), sendOtpEmail: jest.fn(), + sendVerificationOtpEmail: jest.fn(), })); import request from "supertest"; @@ -226,7 +227,7 @@ describe("PATCH /api/admin/users/:id/ban - Ban/unban user", () => { const admin = await User.create(adminPayload); const token = generateTestToken(admin._id.toString()); - const fakeUserId = new ObjectId(); + const fakeUserId = new mongoose.Types.ObjectId(); const response = await request(app) .patch(`/api/admin/users/${fakeUserId}/ban`) @@ -293,7 +294,7 @@ describe("DELETE /api/admin/users/:id - Delete user", () => { const admin = await User.create(adminPayload); const token = generateTestToken(admin._id.toString()); - const fakeUserId = new ObjectId(); + const fakeUserId = new mongoose.Types.ObjectId(); const response = await request(app) .delete(`/api/admin/users/${fakeUserId}`) diff --git a/backend/test/auth/checkAuth.test.js b/backend/test/auth/checkAuth.test.js index 76ad422..e500e54 100644 --- a/backend/test/auth/checkAuth.test.js +++ b/backend/test/auth/checkAuth.test.js @@ -1,9 +1,10 @@ import { jest } from "@jest/globals"; -import { ObjectId } from "mongodb"; +import mongoose from "mongoose"; jest.unstable_mockModule("../../src/lib/sendEmail.js", () => ({ sendWelcomeEmail: jest.fn(), sendOtpEmail: jest.fn(), + sendVerificationOtpEmail: jest.fn(), })); import request from "supertest"; @@ -221,7 +222,7 @@ describe("GET /api/auth/check - Verify JWT authentication", () => { }); it("should reject token with non-existent userId", async () => { - const fakeUserId = new ObjectId(); + const fakeUserId = new mongoose.Types.ObjectId(); const token = generateTestToken(fakeUserId.toString()); const response = await request(app) @@ -354,4 +355,4 @@ describe("GET /api/auth/check - Verify JWT authentication", () => { expect(response.statusCode).toBe(401); }); }); -}); \ No newline at end of file +}); diff --git a/backend/test/auth/login.test.js b/backend/test/auth/login.test.js index 5bdc342..e0323e2 100644 --- a/backend/test/auth/login.test.js +++ b/backend/test/auth/login.test.js @@ -3,6 +3,7 @@ import { jest } from "@jest/globals"; jest.unstable_mockModule("../../src/lib/sendEmail.js", () => ({ sendWelcomeEmail: jest.fn(), sendOtpEmail: jest.fn(), + sendVerificationOtpEmail: jest.fn(), })); import request from "supertest"; @@ -15,7 +16,6 @@ import { import { cleanupRedis } from "../teardown.js"; import { createTestUser, - createLoginPayload, assertValidAuthResponse, } from "../utils/testHelpers.js"; @@ -125,6 +125,26 @@ describe("POST /api/auth/login - Authenticate user", () => { }); describe("βœ— Invalid credentials", () => { + it("should reject unverified users from logging in", async () => { + const userPayload = await createTestUser({ + email: "unverified@test.com", + isVerified: false, + }); + await User.create(userPayload); + + const response = await request(app) + .post("/api/auth/login") + .send({ + email: "unverified@test.com", + password: "testPassword123", + }); + + expect(response.statusCode).toBe(403); + expect(response.body.message).toBe( + "Please verify your email before logging in" + ); + }); + it("should reject wrong password", async () => { const userPayload = await createTestUser({ email: "wrongpass@test.com", @@ -320,6 +340,7 @@ describe("POST /api/auth/login - Authenticate user", () => { fullName: "Special Char User", email: "special@test.com", password: hashedPassword, + isVerified: true, securityQuestions: [ { question: "Q1", answer: "A1" }, { question: "Q2", answer: "A2" }, @@ -345,6 +366,7 @@ describe("POST /api/auth/login - Authenticate user", () => { fullName: "Long Pass User", email: "longpass@test.com", password: hashedPassword, + isVerified: true, securityQuestions: [ { question: "Q1", answer: "A1" }, { question: "Q2", answer: "A2" }, @@ -377,4 +399,4 @@ describe("POST /api/auth/login - Authenticate user", () => { }); }); }); - \ No newline at end of file + diff --git a/backend/test/auth/otp.test.js b/backend/test/auth/otp.test.js index 230a549..d7c30a3 100644 --- a/backend/test/auth/otp.test.js +++ b/backend/test/auth/otp.test.js @@ -2,18 +2,22 @@ import { jest } from "@jest/globals"; // Bind the mock to the global object to share it across module link cycles in ESM Jest global.sendOtpEmailMock = jest.fn(); +global.sendVerificationOtpEmailMock = jest.fn(); jest.unstable_mockModule("../../src/lib/sendEmail.js", () => ({ sendWelcomeEmail: jest.fn(), sendOtpEmail: global.sendOtpEmailMock, + sendVerificationOtpEmail: global.sendVerificationOtpEmailMock, })); import request from "supertest"; import bcrypt from "bcryptjs"; +import crypto from "crypto"; import { connectTestDB, disconnectTestDB } from "../setup.js"; import { cleanupRedis } from "../teardown.js"; +import { createTestUser } from "../utils/testHelpers.js"; const { app } = await import("../../src/index.js"); const { default: User } = await import("../../src/models/user.model.js"); @@ -32,6 +36,7 @@ afterAll(async () => { afterEach(async () => { await User.deleteMany(); global.sendOtpEmailMock.mockClear(); + global.sendVerificationOtpEmailMock.mockClear(); }); describe("OTP Password Recovery Flow", () => { @@ -229,4 +234,157 @@ describe("OTP Password Recovery Flow", () => { expect(secondResetRes.body.message).toBe("Unauthorized reset attempt"); }); }); + + describe("POST /api/auth/verify-email", () => { + const verificationEmail = "verify@test.com"; + const verificationOtp = "654321"; + const hashedVerificationOtp = crypto + .createHash("sha256") + .update(verificationOtp) + .digest("hex"); + + beforeEach(async () => { + const userPayload = await createTestUser({ + email: verificationEmail, + isVerified: false, + emailVerificationOtp: hashedVerificationOtp, + emailVerificationOtpExpiry: new Date(Date.now() + 5 * 60 * 1000), + }); + await User.create(userPayload); + }); + + it("should verify an account with a valid OTP", async () => { + const response = await request(app) + .post("/api/auth/verify-email") + .send({ email: verificationEmail, otp: verificationOtp }); + + expect(response.statusCode).toBe(200); + expect(response.body.message).toBe("Email verified successfully"); + + const user = await User.findOne({ email: verificationEmail }).select( + "+emailVerificationOtp +emailVerificationOtpExpiry" + ); + expect(user.isVerified).toBe(true); + expect(user.emailVerificationOtp).toBeNull(); + expect(user.emailVerificationOtpExpiry).toBeNull(); + }); + + it("should reject an invalid OTP", async () => { + const response = await request(app) + .post("/api/auth/verify-email") + .send({ email: verificationEmail, otp: "000000" }); + + expect(response.statusCode).toBe(400); + expect(response.body.message).toBe("Invalid email or OTP"); + }); + + it("should reject an expired OTP", async () => { + await User.findOneAndUpdate( + { email: verificationEmail }, + { + emailVerificationOtp: hashedVerificationOtp, + emailVerificationOtpExpiry: new Date(Date.now() - 1000), + } + ); + + const response = await request(app) + .post("/api/auth/verify-email") + .send({ email: verificationEmail, otp: verificationOtp }); + + expect(response.statusCode).toBe(400); + expect(response.body.message).toBe("OTP has expired or is invalid"); + }); + + it("should reject an already verified account", async () => { + await User.findOneAndUpdate( + { email: verificationEmail }, + { isVerified: true } + ); + + const response = await request(app) + .post("/api/auth/verify-email") + .send({ email: verificationEmail, otp: verificationOtp }); + + expect(response.statusCode).toBe(400); + expect(response.body.message).toBe("Email is already verified"); + }); + }); + + describe("POST /api/auth/resend-verification-otp", () => { + const resendEmail = "resend@test.com"; + + beforeEach(async () => { + const userPayload = await createTestUser({ + email: resendEmail, + isVerified: false, + emailVerificationOtp: crypto + .createHash("sha256") + .update("111111") + .digest("hex"), + emailVerificationOtpExpiry: new Date(Date.now() + 5 * 60 * 1000), + }); + await User.create(userPayload); + }); + + it("should generate a new verification OTP and update expiry", async () => { + const previousUser = await User.findOne({ email: resendEmail }).select( + "+emailVerificationOtp +emailVerificationOtpExpiry" + ); + const previousOtpHash = previousUser.emailVerificationOtp; + + const response = await request(app) + .post("/api/auth/resend-verification-otp") + .send({ email: resendEmail }); + + await wait(20); + + expect(response.statusCode).toBe(200); + expect(response.body.message).toBe( + "Verification OTP sent successfully" + ); + expect(global.sendVerificationOtpEmailMock).toHaveBeenCalledTimes(1); + expect(global.sendVerificationOtpEmailMock.mock.calls[0][0]).toBe( + resendEmail + ); + + const sentOtp = global.sendVerificationOtpEmailMock.mock.calls[0][1]; + expect(sentOtp).toMatch(/^\d{6}$/); + + const user = await User.findOne({ email: resendEmail }).select( + "+emailVerificationOtp +emailVerificationOtpExpiry" + ); + expect(user.emailVerificationOtp).not.toBe(previousOtpHash); + expect(user.emailVerificationOtpExpiry).toBeInstanceOf(Date); + + const matches = crypto + .createHash("sha256") + .update(sentOtp) + .digest("hex"); + expect(user.emailVerificationOtp).toBe(matches); + }); + + it("should reject verified users", async () => { + await User.findOneAndUpdate( + { email: resendEmail }, + { isVerified: true } + ); + + const response = await request(app) + .post("/api/auth/resend-verification-otp") + .send({ email: resendEmail }); + + expect(response.statusCode).toBe(400); + expect(response.body.message).toBe("Email is already verified"); + expect(global.sendVerificationOtpEmailMock).not.toHaveBeenCalled(); + }); + + it("should reject nonexistent users", async () => { + const response = await request(app) + .post("/api/auth/resend-verification-otp") + .send({ email: "missing@test.com" }); + + expect(response.statusCode).toBe(400); + expect(response.body.message).toBe("User not found"); + }); + }); }); diff --git a/backend/test/auth/signup.test.js b/backend/test/auth/signup.test.js index aea33b4..029c6ee 100644 --- a/backend/test/auth/signup.test.js +++ b/backend/test/auth/signup.test.js @@ -1,8 +1,13 @@ import { jest } from "@jest/globals"; +import bcrypt from "bcryptjs"; +import crypto from "crypto"; + +const sendVerificationOtpEmailMock = jest.fn(); jest.unstable_mockModule("../../src/lib/sendEmail.js", () => ({ sendWelcomeEmail: jest.fn(), sendOtpEmail: jest.fn(), + sendVerificationOtpEmail: sendVerificationOtpEmailMock, })); import request from "supertest"; @@ -12,10 +17,7 @@ import { clearAllCollections, } from "../setup.js"; import { cleanupRedis } from "../teardown.js"; -import { - createSignupPayload, - assertValidAuthResponse, -} from "../utils/testHelpers.js"; +import { createSignupPayload } from "../utils/testHelpers.js"; const { app } = await import("../../src/index.js"); const { default: User } = await import("../../src/models/user.model.js"); @@ -31,11 +33,12 @@ afterAll(async () => { afterEach(async () => { await clearAllCollections(); + sendVerificationOtpEmailMock.mockClear(); }); describe("POST /api/auth/signup - Create new user account", () => { describe("βœ“ Successful signup scenarios", () => { - it("should create new user with all required fields", async () => { + it("should create an unverified user and send a verification OTP", async () => { const payload = createSignupPayload(); const response = await request(app) @@ -43,23 +46,46 @@ describe("POST /api/auth/signup - Create new user account", () => { .send(payload); expect(response.statusCode).toBe(201); - assertValidAuthResponse(response.body); - expect(response.body.email).toBe(payload.email.toLowerCase()); + expect(response.body.message).toBe( + "Account created successfully. Please verify your email using the OTP sent to your email address." + ); + expect(response.body).not.toHaveProperty("token"); - const userInDB = await User.findOne({ email: payload.email }); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(sendVerificationOtpEmailMock).toHaveBeenCalledTimes(1); + expect(sendVerificationOtpEmailMock).toHaveBeenCalledWith( + payload.email.toLowerCase(), + expect.any(String) + ); + + const sentOtp = sendVerificationOtpEmailMock.mock.calls[0][1]; + expect(sentOtp).toMatch(/^\d{6}$/); + + const userInDB = await User.findOne({ email: payload.email }).select( + "+emailVerificationOtp +emailVerificationOtpExpiry" + ); expect(userInDB).not.toBeNull(); expect(userInDB.fullName).toBe(payload.fullName); + expect(userInDB.isVerified).toBe(false); + expect(userInDB.emailVerificationOtpExpiry).toBeInstanceOf(Date); + + const otpHash = crypto + .createHash("sha256") + .update(sentOtp) + .digest("hex"); + +expect(userInDB.emailVerificationOtp).toBe(otpHash); }); - it("should generate valid JWT token on signup", async () => { + it("should require email verification in the response", async () => { const payload = createSignupPayload(); const response = await request(app) .post("/api/auth/signup") .send(payload); - expect(response.body.token).toBeDefined(); - expect(typeof response.body.token).toBe("string"); - expect(response.body.token.split(".").length).toBe(3); // JWT format check + expect(response.statusCode).toBe(201); + expect(response.body.message).toContain("verify your email"); }); it("should hash password securely", async () => { @@ -96,7 +122,10 @@ describe("POST /api/auth/signup - Create new user account", () => { .post("/api/auth/signup") .send(payload); - expect(response.body.email).toBe("test@example.com"); + expect(response.statusCode).toBe(201); + + const userInDB = await User.findOne({ email: "test@example.com" }); + expect(userInDB.email).toBe("test@example.com"); }); it("should set default role as 'user'", async () => { @@ -105,7 +134,10 @@ describe("POST /api/auth/signup - Create new user account", () => { .post("/api/auth/signup") .send(payload); - expect(response.body.role).toBe("user"); + expect(response.statusCode).toBe(201); + + const userInDB = await User.findOne({ email: payload.email }); + expect(userInDB.role).toBe("user"); }); it("should not expose password in response", async () => { @@ -316,7 +348,7 @@ describe("POST /api/auth/signup - Create new user account", () => { expect(response.statusCode).toBe(201); // Verify trimming in DB - const user = await User.findById(response.body._id); + const user = await User.findOne({ email: payload.email.toLowerCase(), }); expect(user.fullName).toBe("John Doe"); }); @@ -358,7 +390,7 @@ describe("POST /api/auth/signup - Create new user account", () => { .send(payload); expect(response.statusCode).toBe(201); - const user = await User.findById(response.body._id); + const user = await User.findOne({ email: payload.email.toLowerCase(), }); expect(user.fullName).toBe('{"$ne": null}'); }); }); @@ -402,7 +434,7 @@ describe("POST /api/auth/signup - Create new user account", () => { .post("/api/auth/signup") .send(payload); - const userInDB = await User.findById(response.body._id); + const userInDB = await User.findOne({email: payload.email.toLowerCase(),}); expect(userInDB.email).toBe(payload.email.toLowerCase()); expect(userInDB.fullName).toBe(payload.fullName); }); @@ -426,4 +458,4 @@ describe("POST /api/auth/signup - Create new user account", () => { expect(userCountBefore).toBe(userCountAfter); }); }); -}); \ No newline at end of file +}); diff --git a/backend/test/group/group.test.js b/backend/test/group/group.test.js index 7bb1cb1..7313600 100644 --- a/backend/test/group/group.test.js +++ b/backend/test/group/group.test.js @@ -4,11 +4,12 @@ */ import { jest } from "@jest/globals"; -import { ObjectId } from "mongodb"; +import mongoose from "mongoose"; jest.unstable_mockModule("../../src/lib/sendEmail.js", () => ({ sendWelcomeEmail: jest.fn(), sendOtpEmail: jest.fn(), + sendVerificationOtpEmail: jest.fn(), })); import request from "supertest"; @@ -331,7 +332,7 @@ describe("GET /api/groups/:id - Get group info", () => { const user = await User.create(userPayload); const token = generateTestToken(user._id.toString()); - const fakeGroupId = new ObjectId(); + const fakeGroupId = new mongoose.Types.ObjectId(); const response = await request(app) .get(`/api/groups/${fakeGroupId}`) @@ -358,7 +359,7 @@ describe("GET /api/groups/:id - Get group info", () => { const group = await Group.create({ name: "Test", members: [], - createdBy: new ObjectId(), + createdBy: new mongoose.Types.ObjectId(), }); const response = await request(app).get( diff --git a/backend/test/integration/integration.test.js b/backend/test/integration/integration.test.js index a5cb486..c3fb7a5 100644 --- a/backend/test/integration/integration.test.js +++ b/backend/test/integration/integration.test.js @@ -4,11 +4,14 @@ */ import { jest } from "@jest/globals"; -import { ObjectId } from "mongodb"; +import mongoose from "mongoose"; + +const sendVerificationOtpEmailMock = jest.fn(); jest.unstable_mockModule("../../src/lib/sendEmail.js", () => ({ sendWelcomeEmail: jest.fn(), sendOtpEmail: jest.fn(), + sendVerificationOtpEmail: sendVerificationOtpEmailMock, })); jest.unstable_mockModule("../../src/lib/mlService.js", () => ({ @@ -29,7 +32,6 @@ import { cleanupRedis } from "../teardown.js"; import { createTestUser, createSignupPayload, - createLoginPayload, generateTestToken, } from "../utils/testHelpers.js"; @@ -42,6 +44,59 @@ const { default: Group } = await import( "../../src/models/group.model.js" ); +const waitForEmailMock = async () => { + for ( + let attempt = 0; + attempt < 10 && + sendVerificationOtpEmailMock.mock.calls.length === 0; + attempt += 1 + ) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + expect(sendVerificationOtpEmailMock).toHaveBeenCalled(); +}; + +const completeSignupVerificationAndLogin = async (payload) => { + const signupResponse = await request(app) + .post("/api/auth/signup") + .send(payload); + + expect(signupResponse.statusCode).toBe(201); + expect(signupResponse.body.message).toContain("verify your email"); + + await waitForEmailMock(); + + const sentOtp = + sendVerificationOtpEmailMock.mock.calls[ + sendVerificationOtpEmailMock.mock.calls.length - 1 + ][1]; + + const verifyResponse = await request(app) + .post("/api/auth/verify-email") + .send({ + email: payload.email, + otp: sentOtp, + }); + + expect(verifyResponse.statusCode).toBe(200); + expect(verifyResponse.body.message).toBe( + "Email verified successfully" + ); + + const loginResponse = await request(app) + .post("/api/auth/login") + .send({ + email: payload.email, + password: payload.password, + }); + + expect(loginResponse.statusCode).toBe(200); + expect(loginResponse.body.token).toBeDefined(); + + return loginResponse.body.token; +}; + beforeAll(async () => { await connectTestDB(); }); @@ -53,6 +108,7 @@ afterAll(async () => { afterEach(async () => { await clearAllCollections(); + sendVerificationOtpEmailMock.mockClear(); }); describe("Integration - Complete User Journey", () => { @@ -60,38 +116,16 @@ describe("Integration - Complete User Journey", () => { it("should complete signup -> login -> auth check flow", async () => { const payload = createSignupPayload(); - // Step 1: Signup - const signupResponse = await request(app) - .post("/api/auth/signup") - .send(payload); - - expect(signupResponse.statusCode).toBe(201); - expect(signupResponse.body.token).toBeDefined(); - const signupToken = signupResponse.body.token; + const loginToken = + await completeSignupVerificationAndLogin(payload); - // Step 2: Verify auth with signup token + // Step 2: Verify auth with login token const checkResponse = await request(app) .get("/api/auth/check") - .set("Authorization", `Bearer ${signupToken}`); + .set("Authorization", `Bearer ${loginToken}`); expect(checkResponse.statusCode).toBe(200); expect(checkResponse.body.email).toBe(payload.email); - - // Step 3: Login with credentials - const loginResponse = await request(app) - .post("/api/auth/login") - .send(createLoginPayload(payload)); - - expect(loginResponse.statusCode).toBe(200); - expect(loginResponse.body.token).toBeDefined(); - const loginToken = loginResponse.body.token; - - // Step 4: Verify auth with login token - const checkResponse2 = await request(app) - .get("/api/auth/check") - .set("Authorization", `Bearer ${loginToken}`); - - expect(checkResponse2.statusCode).toBe(200); }); it("should persist user data across requests", async () => { @@ -100,11 +134,8 @@ describe("Integration - Complete User Journey", () => { email: "integration@test.com", }); - const signupResponse = await request(app) - .post("/api/auth/signup") - .send(payload); - - const token = signupResponse.body.token; + const token = + await completeSignupVerificationAndLogin(payload); // Check multiple times - data should persist for (let i = 0; i < 3; i++) { @@ -285,7 +316,7 @@ describe("Integration - Complete User Journey", () => { describe("βœ“ Error recovery", () => { it("should handle and recover from invalid requests", async () => { const token = generateTestToken( - new ObjectId().toString() + new mongoose.Types.ObjectId().toString() ); // Make invalid request diff --git a/backend/test/message/message.test.js b/backend/test/message/message.test.js index db58b13..6a4bfa6 100644 --- a/backend/test/message/message.test.js +++ b/backend/test/message/message.test.js @@ -8,6 +8,7 @@ import { jest } from "@jest/globals"; jest.unstable_mockModule("../../src/lib/sendEmail.js", () => ({ sendWelcomeEmail: jest.fn(), sendOtpEmail: jest.fn(), + sendVerificationOtpEmail: jest.fn(), })); jest.unstable_mockModule("../../src/lib/mlService.js", () => ({ @@ -20,7 +21,6 @@ jest.unstable_mockModule("../../src/lib/mlService.js", () => ({ import request from "supertest"; import mongoose from "mongoose"; -import { ObjectId } from "mongodb"; import { connectTestDB, disconnectTestDB, @@ -219,7 +219,7 @@ describe("POST /api/messages/send/:id - Send message", () => { const sender = await User.create(userPayload); const token = generateTestToken(sender._id.toString()); - const fakeUserId = new ObjectId(); + const fakeUserId = new mongoose.Types.ObjectId(); const response = await request(app) .post(`/api/messages/send/${fakeUserId}`) diff --git a/backend/test/middleware/auth.middleware.test.js b/backend/test/middleware/auth.middleware.test.js index 7adaae2..bba4d3c 100644 --- a/backend/test/middleware/auth.middleware.test.js +++ b/backend/test/middleware/auth.middleware.test.js @@ -4,12 +4,13 @@ */ import { jest } from "@jest/globals"; -import { ObjectId } from "mongodb"; +import mongoose from "mongoose"; import jwt from "jsonwebtoken"; jest.unstable_mockModule("../../src/lib/sendEmail.js", () => ({ sendWelcomeEmail: jest.fn(), sendOtpEmail: jest.fn(), + sendVerificationOtpEmail: jest.fn(), })); import { @@ -230,7 +231,7 @@ describe("Auth Middleware - protectRoute", () => { describe("βœ— User not found", () => { it("should reject token for non-existent user", async () => { - const fakeUserId = new ObjectId(); + const fakeUserId = new mongoose.Types.ObjectId(); const token = generateTestToken(fakeUserId.toString()); const req = createMockRequest({ diff --git a/backend/test/security/security.test.js b/backend/test/security/security.test.js index 65092b7..a0a36b6 100644 --- a/backend/test/security/security.test.js +++ b/backend/test/security/security.test.js @@ -4,11 +4,12 @@ */ import { jest } from "@jest/globals"; -import { ObjectId } from "mongodb"; +import mongoose from "mongoose"; jest.unstable_mockModule("../../src/lib/sendEmail.js", () => ({ sendWelcomeEmail: jest.fn(), sendOtpEmail: jest.fn(), + sendVerificationOtpEmail: jest.fn(), })); import request from "supertest"; @@ -44,12 +45,19 @@ afterEach(async () => { describe("Security - JWT Token Tests", () => { describe("βœ“ Token format and structure", () => { it("should generate properly formatted JWT", async () => { - const payload = createSignupPayload(); + const payload = await createTestUser({ + email: "tokenformat@test.com", + }); + await User.create(payload); + const response = await request(app) - .post("/api/auth/signup") - .send(payload); + .post("/api/auth/login") + .send({ + email: payload.email, + password: "testPassword123", + }); - expect(response.statusCode).toBe(201); + expect(response.statusCode).toBe(200); const token = response.body.token; // JWT format: header.payload.signature @@ -62,10 +70,17 @@ describe("Security - JWT Token Tests", () => { }); it("should include userId in token payload", async () => { - const payload = createSignupPayload(); + const payload = await createTestUser({ + email: "tokenpayload@test.com", + }); + await User.create(payload); + const response = await request(app) - .post("/api/auth/signup") - .send(payload); + .post("/api/auth/login") + .send({ + email: payload.email, + password: "testPassword123", + }); const token = response.body.token; const decoded = jwt.decode(token); @@ -75,10 +90,17 @@ describe("Security - JWT Token Tests", () => { }); it("should use correct signing algorithm", async () => { - const payload = createSignupPayload(); + const payload = await createTestUser({ + email: "tokenalgo@test.com", + }); + await User.create(payload); + const response = await request(app) - .post("/api/auth/signup") - .send(payload); + .post("/api/auth/login") + .send({ + email: payload.email, + password: "testPassword123", + }); const token = response.body.token; const parts = token.split("."); @@ -90,10 +112,17 @@ describe("Security - JWT Token Tests", () => { }); it("should have expiration time", async () => { - const payload = createSignupPayload(); + const payload = await createTestUser({ + email: "tokenexp@test.com", + }); + await User.create(payload); + const response = await request(app) - .post("/api/auth/signup") - .send(payload); + .post("/api/auth/login") + .send({ + email: payload.email, + password: "testPassword123", + }); const token = response.body.token; const decoded = jwt.decode(token); @@ -165,7 +194,7 @@ describe("Security - JWT Token Tests", () => { it("should reject token with missing signature", async () => { const parts = generateTestToken( - new ObjectId().toString() + new mongoose.Types.ObjectId().toString() ).split("."); const modified = parts[0] + "." + parts[1]; diff --git a/backend/test/socket/socket.test.js b/backend/test/socket/socket.test.js index 5314a55..e480ac0 100644 --- a/backend/test/socket/socket.test.js +++ b/backend/test/socket/socket.test.js @@ -3,6 +3,7 @@ import { jest } from "@jest/globals"; jest.unstable_mockModule("../../src/lib/sendEmail.js", () => ({ sendWelcomeEmail: jest.fn(), sendOtpEmail: jest.fn(), + sendVerificationOtpEmail: jest.fn(), })); import { io as Client } from "socket.io-client"; @@ -91,4 +92,4 @@ describe("Socket.IO Connection", () => { true ); }); -}); \ No newline at end of file +}); diff --git a/backend/test/utils/testHelpers.js b/backend/test/utils/testHelpers.js index abe97f7..fd8bb50 100644 --- a/backend/test/utils/testHelpers.js +++ b/backend/test/utils/testHelpers.js @@ -5,6 +5,7 @@ import jwt from "jsonwebtoken"; import bcrypt from "bcryptjs"; +import mongoose from "mongoose"; // ============================================================================ // DATABASE FACTORIES @@ -31,8 +32,11 @@ export const createTestUser = async (overrides = {}) => { password: hashedPassword, securityQuestions, role: "user", + isVerified: true, isOnline: false, profilePic: "", + emailVerificationOtp: null, + emailVerificationOtpExpiry: null, ...overrides, }; }; @@ -256,8 +260,7 @@ export const assertValidGroupResponse = (group) => { * @returns {Object} MongoDB ObjectId */ export const createObjectId = () => { - const ObjectId = require("mongodb").ObjectId; - return new ObjectId(); + return new mongoose.Types.ObjectId(); }; /** @@ -266,8 +269,7 @@ export const createObjectId = () => { * @returns {boolean} True if valid ObjectId */ export const isValidObjectId = (id) => { - const ObjectId = require("mongodb").ObjectId; - return ObjectId.isValid(id); + return mongoose.Types.ObjectId.isValid(id); }; // ============================================================================ From 7fc97923e4eb52c5956512dabe2ddcaa1e438306 Mon Sep 17 00:00:00 2001 From: Nihar Date: Thu, 25 Jun 2026 15:01:41 +0530 Subject: [PATCH 08/13] fix: address CodeRabbit OTP security review comments --- backend/src/controllers/auth.controller.js | 111 ++++++++++--------- backend/src/routes/auth.route.js | 2 +- backend/test/auth/login.test.js | 20 ++++ backend/test/auth/otp.test.js | 56 +++++----- backend/test/auth/signup.test.js | 10 +- backend/test/integration/integration.test.js | 2 +- backend/test/security/security.test.js | 5 +- 7 files changed, 113 insertions(+), 93 deletions(-) diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index bd6506e..fc29dd1 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -85,10 +85,10 @@ export const signup = async (req, res) => { ); const verificationOtp = crypto.randomInt(100000, 1000000).toString(); - const hashedVerificationOtp = crypto - .createHash("sha256") - .update(verificationOtp) - .digest("hex"); + const hashedVerificationOtp = await bcrypt.hash( + verificationOtp, + 10 + ); const verificationOtpExpiry = new Date( Date.now() + 5 * 60 * 1000 @@ -105,11 +105,16 @@ export const signup = async (req, res) => { emailVerificationOtpExpiry: verificationOtpExpiry, }); - setImmediate(() => { - sendVerificationOtpEmail( - newUser.email, - verificationOtp - ); + // Asynchronously send the verification email to avoid blocking the HTTP response. + setImmediate(async () => { + try { + await sendVerificationOtpEmail( + newUser.email, + verificationOtp + ); + } catch (error) { + console.error("Verification email failed:", error); + } }); res.status(201).json({ @@ -165,12 +170,12 @@ export const verifyEmail = async (req, res) => { }); } - const hashedOtp = crypto - .createHash("sha256") - .update(otp) - .digest("hex"); + const isOtpValid = await bcrypt.compare( + otp, + user.emailVerificationOtp + ); - if (hashedOtp !== user.emailVerificationOtp) { + if (!isOtpValid) { return res.status(400).json({ message: "Invalid email or OTP", }); @@ -182,11 +187,16 @@ export const verifyEmail = async (req, res) => { await user.save(); - setImmediate(() => { - sendWelcomeEmail( - user.email, - user.fullName - ); + // Asynchronously send the welcome email to avoid blocking the HTTP response. + setImmediate(async () => { + try { + await sendWelcomeEmail( + user.email, + user.fullName + ); + } catch (error) { + console.error("Welcome email failed:", error); + } }); res.status(200).json({ @@ -245,7 +255,7 @@ export const login = async (req, res) => { }); } - if (!user.isVerified) { + if (user.isVerified === false && user.isInit("isVerified")) { return res.status(403).json({ message: "Please verify your email before logging in", }); @@ -896,47 +906,42 @@ export const resendVerificationOtp = async ( "+emailVerificationOtp +emailVerificationOtpExpiry" ); - if (!user) { - return res.status(400).json({ - message: "User not found", - }); - } - - if (user.isVerified) { - return res.status(400).json({ - message: "Email is already verified", - }); - } - - const verificationOtp = crypto - .randomInt(100000, 1000000) - .toString(); + if (user && user.isVerified === false) { + const verificationOtp = crypto + .randomInt(100000, 1000000) + .toString(); - const hashedVerificationOtp = crypto - .createHash("sha256") - .update(verificationOtp) - .digest("hex"); + const hashedVerificationOtp = await bcrypt.hash( + verificationOtp, + 10 + ); - user.emailVerificationOtp = - hashedVerificationOtp; + user.emailVerificationOtp = + hashedVerificationOtp; - user.emailVerificationOtpExpiry = - new Date( - Date.now() + 5 * 60 * 1000 - ); + user.emailVerificationOtpExpiry = + new Date( + Date.now() + 5 * 60 * 1000 + ); - await user.save(); + await user.save(); - setImmediate(() => { - sendVerificationOtpEmail( - user.email, - verificationOtp - ); - }); + // Asynchronously send the verification email to avoid blocking the HTTP response. + setImmediate(async () => { + try { + await sendVerificationOtpEmail( + user.email, + verificationOtp + ); + } catch (error) { + console.error("Verification email failed:", error); + } + }); + } return res.status(200).json({ message: - "Verification OTP sent successfully", + "If an account exists and requires verification, a verification OTP has been sent.", }); } catch (error) { console.error( diff --git a/backend/src/routes/auth.route.js b/backend/src/routes/auth.route.js index cf0ba36..e820d37 100644 --- a/backend/src/routes/auth.route.js +++ b/backend/src/routes/auth.route.js @@ -22,7 +22,7 @@ const router = express.Router(); router.post("/signup", signup); router.post("/verify-email", otpRateLimiter, verifyEmail); -router.post("/resend-verification-otp", resendVerificationOtp); +router.post("/resend-verification-otp", otpRateLimiter, resendVerificationOtp); router.post("/login", login); router.post("/logout", logout); router.get("/check", protectRoute, checkAuth); diff --git a/backend/test/auth/login.test.js b/backend/test/auth/login.test.js index e0323e2..90eba13 100644 --- a/backend/test/auth/login.test.js +++ b/backend/test/auth/login.test.js @@ -122,6 +122,26 @@ describe("POST /api/auth/login - Authenticate user", () => { expect(response.body.fullName).toBe("Test User Full"); expect(response.body.email).toBe("populate@test.com"); }); + + it("should allow legacy users (where isVerified is undefined) to log in", async () => { + const userPayload = await createTestUser({ + email: "legacy@test.com", + }); + delete userPayload.isVerified; + + const createdUser = await User.create(userPayload); + await User.updateOne({ _id: createdUser._id }, { $unset: { isVerified: "" } }); + + const response = await request(app) + .post("/api/auth/login") + .send({ + email: "legacy@test.com", + password: "testPassword123", + }); + + expect(response.statusCode).toBe(200); + expect(response.body.email).toBe("legacy@test.com"); + }); }); describe("βœ— Invalid credentials", () => { diff --git a/backend/test/auth/otp.test.js b/backend/test/auth/otp.test.js index d7c30a3..670ad90 100644 --- a/backend/test/auth/otp.test.js +++ b/backend/test/auth/otp.test.js @@ -22,7 +22,7 @@ import { createTestUser } from "../utils/testHelpers.js"; const { app } = await import("../../src/index.js"); const { default: User } = await import("../../src/models/user.model.js"); -const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const wait = () => new Promise((resolve) => setImmediate(resolve)); beforeAll(async () => { await connectTestDB(); @@ -238,12 +238,10 @@ describe("OTP Password Recovery Flow", () => { describe("POST /api/auth/verify-email", () => { const verificationEmail = "verify@test.com"; const verificationOtp = "654321"; - const hashedVerificationOtp = crypto - .createHash("sha256") - .update(verificationOtp) - .digest("hex"); + let hashedVerificationOtp; beforeEach(async () => { + hashedVerificationOtp = await bcrypt.hash(verificationOtp, 10); const userPayload = await createTestUser({ email: verificationEmail, isVerified: false, @@ -312,16 +310,15 @@ describe("OTP Password Recovery Flow", () => { describe("POST /api/auth/resend-verification-otp", () => { const resendEmail = "resend@test.com"; + let previousOtpHash; beforeEach(async () => { + previousOtpHash = await bcrypt.hash("111111", 10); const userPayload = await createTestUser({ email: resendEmail, isVerified: false, - emailVerificationOtp: crypto - .createHash("sha256") - .update("111111") - .digest("hex"), - emailVerificationOtpExpiry: new Date(Date.now() + 5 * 60 * 1000), + emailVerificationOtp: previousOtpHash, + emailVerificationOtpExpiry: new Date(Date.now() + 2 * 60 * 1000), }); await User.create(userPayload); }); @@ -330,17 +327,18 @@ describe("OTP Password Recovery Flow", () => { const previousUser = await User.findOne({ email: resendEmail }).select( "+emailVerificationOtp +emailVerificationOtpExpiry" ); - const previousOtpHash = previousUser.emailVerificationOtp; + const previousOtpHashVal = previousUser.emailVerificationOtp; + const previousExpiry = previousUser.emailVerificationOtpExpiry; const response = await request(app) .post("/api/auth/resend-verification-otp") .send({ email: resendEmail }); - await wait(20); + await wait(); expect(response.statusCode).toBe(200); expect(response.body.message).toBe( - "Verification OTP sent successfully" + "If an account exists and requires verification, a verification OTP has been sent." ); expect(global.sendVerificationOtpEmailMock).toHaveBeenCalledTimes(1); expect(global.sendVerificationOtpEmailMock.mock.calls[0][0]).toBe( @@ -353,17 +351,16 @@ describe("OTP Password Recovery Flow", () => { const user = await User.findOne({ email: resendEmail }).select( "+emailVerificationOtp +emailVerificationOtpExpiry" ); - expect(user.emailVerificationOtp).not.toBe(previousOtpHash); - expect(user.emailVerificationOtpExpiry).toBeInstanceOf(Date); - - const matches = crypto - .createHash("sha256") - .update(sentOtp) - .digest("hex"); - expect(user.emailVerificationOtp).toBe(matches); + expect(user.emailVerificationOtp).not.toBe(previousOtpHashVal); + expect(user.emailVerificationOtpExpiry.getTime()).toBeGreaterThan( + previousExpiry.getTime() + 60000 + ); + + const isMatch = await bcrypt.compare(sentOtp, user.emailVerificationOtp); + expect(isMatch).toBe(true); }); - it("should reject verified users", async () => { + it("should return generic success for verified users", async () => { await User.findOneAndUpdate( { email: resendEmail }, { isVerified: true } @@ -373,18 +370,23 @@ describe("OTP Password Recovery Flow", () => { .post("/api/auth/resend-verification-otp") .send({ email: resendEmail }); - expect(response.statusCode).toBe(400); - expect(response.body.message).toBe("Email is already verified"); + expect(response.statusCode).toBe(200); + expect(response.body.message).toBe( + "If an account exists and requires verification, a verification OTP has been sent." + ); expect(global.sendVerificationOtpEmailMock).not.toHaveBeenCalled(); }); - it("should reject nonexistent users", async () => { + it("should return generic success for nonexistent users", async () => { const response = await request(app) .post("/api/auth/resend-verification-otp") .send({ email: "missing@test.com" }); - expect(response.statusCode).toBe(400); - expect(response.body.message).toBe("User not found"); + expect(response.statusCode).toBe(200); + expect(response.body.message).toBe( + "If an account exists and requires verification, a verification OTP has been sent." + ); + expect(global.sendVerificationOtpEmailMock).not.toHaveBeenCalled(); }); }); }); diff --git a/backend/test/auth/signup.test.js b/backend/test/auth/signup.test.js index 029c6ee..727a4e4 100644 --- a/backend/test/auth/signup.test.js +++ b/backend/test/auth/signup.test.js @@ -51,7 +51,7 @@ describe("POST /api/auth/signup - Create new user account", () => { ); expect(response.body).not.toHaveProperty("token"); - await new Promise((resolve) => setTimeout(resolve, 20)); + await new Promise((resolve) => setImmediate(resolve)); expect(sendVerificationOtpEmailMock).toHaveBeenCalledTimes(1); expect(sendVerificationOtpEmailMock).toHaveBeenCalledWith( @@ -70,12 +70,8 @@ describe("POST /api/auth/signup - Create new user account", () => { expect(userInDB.isVerified).toBe(false); expect(userInDB.emailVerificationOtpExpiry).toBeInstanceOf(Date); - const otpHash = crypto - .createHash("sha256") - .update(sentOtp) - .digest("hex"); - -expect(userInDB.emailVerificationOtp).toBe(otpHash); + const isOtpCorrect = await bcrypt.compare(sentOtp, userInDB.emailVerificationOtp); + expect(isOtpCorrect).toBe(true); }); it("should require email verification in the response", async () => { diff --git a/backend/test/integration/integration.test.js b/backend/test/integration/integration.test.js index c3fb7a5..e6fae9a 100644 --- a/backend/test/integration/integration.test.js +++ b/backend/test/integration/integration.test.js @@ -51,7 +51,7 @@ const waitForEmailMock = async () => { sendVerificationOtpEmailMock.mock.calls.length === 0; attempt += 1 ) { - await new Promise((resolve) => setTimeout(resolve, 10)); + await new Promise((resolve) => setImmediate(resolve)); } expect(sendVerificationOtpEmailMock).toHaveBeenCalled(); diff --git a/backend/test/security/security.test.js b/backend/test/security/security.test.js index a0a36b6..fd6e1e1 100644 --- a/backend/test/security/security.test.js +++ b/backend/test/security/security.test.js @@ -232,12 +232,9 @@ describe("Security - JWT Token Tests", () => { const immediateExpireToken = jwt.sign( { userId: user._id.toString() }, process.env.JWT_SECRET, - { expiresIn: "0s" } + { expiresIn: "-1s" } ); - // Wait a bit and try to use - await new Promise((resolve) => setTimeout(resolve, 100)); - const response = await request(app) .get("/api/auth/check") .set("Authorization", `Bearer ${immediateExpireToken}`); From 866a336870079a1f5607aa6d8b806472a1337080 Mon Sep 17 00:00:00 2001 From: Nihar Date: Thu, 25 Jun 2026 15:25:42 +0530 Subject: [PATCH 09/13] test: add comprehensive backend test suite --- backend/src/controllers/auth.controller.js | 31 +++++++++++++++------- backend/test/auth/otp.test.js | 6 ++--- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index fc29dd1..29bea24 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -134,7 +134,13 @@ export const verifyEmail = async (req, res) => { try { const { email, otp } = req.body; - if (!email || !otp) { + // Validate input types + if ( + typeof email !== "string" || + typeof otp !== "string" || + !email.trim() || + !otp.trim() + ) { return res.status(400).json({ message: "Email and OTP are required", }); @@ -148,15 +154,19 @@ export const verifyEmail = async (req, res) => { "+emailVerificationOtp +emailVerificationOtpExpiry" ); + const genericErrorMessage = + "Invalid or expired verification request"; + + // Prevent user enumeration if (!user) { return res.status(400).json({ - message: "Invalid email or OTP", + message: genericErrorMessage, }); } if (user.isVerified) { return res.status(400).json({ - message: "Email is already verified", + message: genericErrorMessage, }); } @@ -166,7 +176,7 @@ export const verifyEmail = async (req, res) => { user.emailVerificationOtpExpiry < new Date() ) { return res.status(400).json({ - message: "OTP has expired or is invalid", + message: genericErrorMessage, }); } @@ -177,7 +187,7 @@ export const verifyEmail = async (req, res) => { if (!isOtpValid) { return res.status(400).json({ - message: "Invalid email or OTP", + message: genericErrorMessage, }); } @@ -187,7 +197,7 @@ export const verifyEmail = async (req, res) => { await user.save(); - // Asynchronously send the welcome email to avoid blocking the HTTP response. + // Send welcome email asynchronously setImmediate(async () => { try { await sendWelcomeEmail( @@ -195,11 +205,14 @@ export const verifyEmail = async (req, res) => { user.fullName ); } catch (error) { - console.error("Welcome email failed:", error); + console.error( + "Welcome email failed:", + error + ); } }); - res.status(200).json({ + return res.status(200).json({ message: "Email verified successfully", }); } catch (error) { @@ -208,7 +221,7 @@ export const verifyEmail = async (req, res) => { error ); - res.status(500).json({ + return res.status(500).json({ message: "Internal Server Error", }); } diff --git a/backend/test/auth/otp.test.js b/backend/test/auth/otp.test.js index 670ad90..81bd8e5 100644 --- a/backend/test/auth/otp.test.js +++ b/backend/test/auth/otp.test.js @@ -273,7 +273,7 @@ describe("OTP Password Recovery Flow", () => { .send({ email: verificationEmail, otp: "000000" }); expect(response.statusCode).toBe(400); - expect(response.body.message).toBe("Invalid email or OTP"); + expect(response.body.message).toBe("Invalid or expired verification request"); }); it("should reject an expired OTP", async () => { @@ -290,7 +290,7 @@ describe("OTP Password Recovery Flow", () => { .send({ email: verificationEmail, otp: verificationOtp }); expect(response.statusCode).toBe(400); - expect(response.body.message).toBe("OTP has expired or is invalid"); + expect(response.body.message).toBe("Invalid or expired verification request"); }); it("should reject an already verified account", async () => { @@ -304,7 +304,7 @@ describe("OTP Password Recovery Flow", () => { .send({ email: verificationEmail, otp: verificationOtp }); expect(response.statusCode).toBe(400); - expect(response.body.message).toBe("Email is already verified"); + expect(response.body.message).toBe("Invalid or expired verification request"); }); }); From abfcdf3a7314c4f9aec70e6d3754b44cf83d83ba Mon Sep 17 00:00:00 2001 From: Nihar Date: Fri, 26 Jun 2026 14:26:31 +0530 Subject: [PATCH 10/13] feat: add frontend email verification OTP flow --- backend/src/controllers/auth.controller.js | 1 + frontend/src/App.jsx | 8 +- frontend/src/pages/SignUpPage.jsx | 13 ++- frontend/src/pages/VerifyEmailPage.jsx | 101 +++++++++++++++++++++ frontend/src/store/useAuthStore.js | 53 ++++++++++- 5 files changed, 165 insertions(+), 11 deletions(-) create mode 100644 frontend/src/pages/VerifyEmailPage.jsx diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index 29bea24..9e4f0a2 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -120,6 +120,7 @@ export const signup = async (req, res) => { res.status(201).json({ message: "Account created successfully. Please verify your email using the OTP sent to your email address.", + email: newUser.email, }); } catch (error) { console.error("Signup error:", error); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 83fac96..2f6d63f 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -12,7 +12,7 @@ import { useChatStore } from "./store/useChatStore"; import { useThemeStore } from "./store/useThemeStore"; import { useEffect } from "react"; -import { Loader } from "lucide-react"; +import { Loader, Ribbon } from "lucide-react"; import { Toaster } from "react-hot-toast"; import IncomingCallModal from "./components/call/IncomingCallModal.jsx"; @@ -26,7 +26,7 @@ import AdminRoute from "./components/admin/AdminRoute"; import AdminLayout from "./components/admin/AdminLayout"; import ForgotPasswordPage from "./pages/ForgotPassword.jsx"; import ContributePage from "./components/ContributePage.jsx"; -// import ForgotPasswordPage from "./pages/ForgotPasswordPage"; +import VerifyEmailPage from "./pages/VerifyEmailPage.jsx"; const App = () => { const { authUser, checkAuth, isCheckingAuth } = useAuthStore(); @@ -84,6 +84,10 @@ const App = () => { path="/signup" element={!authUser ? : } /> + : } + /> : } diff --git a/frontend/src/pages/SignUpPage.jsx b/frontend/src/pages/SignUpPage.jsx index a0b52f5..3c44769 100644 --- a/frontend/src/pages/SignUpPage.jsx +++ b/frontend/src/pages/SignUpPage.jsx @@ -10,7 +10,7 @@ import { User, ShieldCheck, } from "lucide-react"; -import { Link } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import toast from "react-hot-toast"; const SignUpPage = () => { @@ -28,12 +28,13 @@ const SignUpPage = () => { }); const { signup, isSigningUp } = useAuthStore(); + const navigate = useNavigate(); const validateForm = () => { if (!formData.fullName.trim()) return toast.error("Full name is required"); if (!formData.email.trim()) return toast.error("Email is required"); if (!/\S+@\S+\.\S+/.test(formData.email)) - return toast.error("Invalid email format"); + return toast.error("Invalid email format") if (!formData.password) return toast.error("Password is required"); if (formData.password.length < 6) return toast.error("Password must be at least 6 characters"); @@ -46,9 +47,13 @@ const SignUpPage = () => { return true; }; - const handleSubmit = (e) => { + const handleSubmit = async (e) => { if (e) e.preventDefault(); - if (validateForm()) signup(formData); + if (!validateForm()) return; + const result = await signup(formData); + if(result){ + navigate("/verify-email",{state:{email: result.email}}); + } }; const handleAnswerChange = (index, value) => { diff --git a/frontend/src/pages/VerifyEmailPage.jsx b/frontend/src/pages/VerifyEmailPage.jsx new file mode 100644 index 0000000..f5773f0 --- /dev/null +++ b/frontend/src/pages/VerifyEmailPage.jsx @@ -0,0 +1,101 @@ +import { useState } from "react"; +import { Link, useNavigate, useLocation } from "react-router-dom"; +import { Mail, Loader2, ShieldCheck } from "lucide-react"; +import { useAuthStore } from "../store/useAuthStore"; + +const VerifyEmailPage = () => { + const navigate = useNavigate(); + const location = useLocation(); + + const email = location.state?.email || ""; + const [otp, setOtp] = useState(""); + const { verifyEmailOtp, resendVerificationOtp, isVerifyingOtp, isSendingOtp } = useAuthStore(); + + const handleVerify = async (e) => { + e.preventDefault(); + + if (otp.length !== 6) { + return; + } + + const success = await verifyEmailOtp({ email, otp }); + + if (success) { + navigate("/login"); + } + }; + + + const handleResend = async () => { + const success = await resendVerificationOtp(email); + + if (success) { + setOtp(""); + } + }; + if (!email) { + return ( +
+
+

Email not found

+ + Go back to Signup + +
+
+ ); + } + return ( +
+
+ +
+
+ +
+ +

Verify Email

+ +

Enter the OTP sent to

+ +

+ + {email} +

+
+ +
+ + setOtp(e.target.value)} + placeholder="Enter 6-digit OTP" + className="input input-bordered w-full text-center text-xl tracking-[8px]" + /> + + +
+ +
+
+ ); +}; + +export default VerifyEmailPage; \ No newline at end of file diff --git a/frontend/src/store/useAuthStore.js b/frontend/src/store/useAuthStore.js index 5a9dbda..843c23e 100644 --- a/frontend/src/store/useAuthStore.js +++ b/frontend/src/store/useAuthStore.js @@ -45,12 +45,11 @@ export const useAuthStore = create((set, get) => ({ set({ isSigningUp: true }); try { const res = await axiosInstance.post("/auth/signup", data); - localStorage.setItem("token", res.data.token); - set({ authUser: res.data }); - toast.success("Account created successfully"); - get().connectSocket(); + toast.success(res.data.message || "Account created. Please verify your Email."); + return res.data; } catch (error) { - toast.error(error.response?.data?.message); + toast.error(error.response?.data?.message || "Signup failed"); + return null; } finally { set({ isSigningUp: false }); } @@ -171,6 +170,50 @@ export const useAuthStore = create((set, get) => ({ } }, + verifyEmailOtp: async ({ email, otp }) => { + set({ isVerifyingOtp: true }); + + try { + const res = await axiosInstance.post("/auth/verify-email", { + email, + otp, + }); + + toast.success(res.data.message); + return true; + } catch (error) { + toast.error( + error.response?.data?.message || "Verification failed" + ); + return false; + } finally { + set({ isVerifyingOtp: false }); + } + }, + + resendVerificationOtp: async (email) => { + set({ isSendingOtp: true }); + + try { + const res = await axiosInstance.post( + "/auth/resend-verification-otp", + { email } + ); + + toast.success(res.data.message || "OTP sent successfully"); + + return true; + } catch (error) { + toast.error( + error.response?.data?.message || "Failed to resend OTP" + ); + + return false; + } finally { + set({ isSendingOtp: false }); + } + }, + fetchSecurityQuestions: async (email) => { set({ isFetchingQuestions: true }); From a58983c6a366f49c16dc1e215286cd1bdffda4ad Mon Sep 17 00:00:00 2001 From: Nihar Date: Fri, 26 Jun 2026 14:46:37 +0530 Subject: [PATCH 11/13] feat: add frontend email verification OTP flow --- frontend/src/pages/SignUpPage.jsx | 4 +++- frontend/src/pages/VerifyEmailPage.jsx | 15 ++++++++++----- frontend/src/store/useAuthStore.js | 2 +- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/frontend/src/pages/SignUpPage.jsx b/frontend/src/pages/SignUpPage.jsx index 3c44769..29e767a 100644 --- a/frontend/src/pages/SignUpPage.jsx +++ b/frontend/src/pages/SignUpPage.jsx @@ -52,7 +52,9 @@ const SignUpPage = () => { if (!validateForm()) return; const result = await signup(formData); if(result){ - navigate("/verify-email",{state:{email: result.email}}); + const email = result.email || formData.email.trim(); + sessionStorage.setItem("pendingVerificationEmail", email); + navigate("/verify-email",{state:{email}}); } }; diff --git a/frontend/src/pages/VerifyEmailPage.jsx b/frontend/src/pages/VerifyEmailPage.jsx index f5773f0..d8570d6 100644 --- a/frontend/src/pages/VerifyEmailPage.jsx +++ b/frontend/src/pages/VerifyEmailPage.jsx @@ -7,20 +7,22 @@ const VerifyEmailPage = () => { const navigate = useNavigate(); const location = useLocation(); - const email = location.state?.email || ""; + const email = location.state?.email || sessionStorage.getItem("pendingVerificationEmail") || ""; const [otp, setOtp] = useState(""); + const isOtpComplete = /^\d{6}$/.test(otp); const { verifyEmailOtp, resendVerificationOtp, isVerifyingOtp, isSendingOtp } = useAuthStore(); const handleVerify = async (e) => { e.preventDefault(); - if (otp.length !== 6) { + if (!isOtpComplete) { return; } const success = await verifyEmailOtp({ email, otp }); if (success) { + sessionStorage.removeItem("pendingVerificationEmail"); navigate("/login"); } }; @@ -68,15 +70,18 @@ const VerifyEmailPage = () => { setOtp(e.target.value)} + onChange={(e) => setOtp(e.target.value.replace(/\D/g, "").slice(0, 6))} placeholder="Enter 6-digit OTP" className="input input-bordered w-full text-center text-xl tracking-[8px]" /> diff --git a/frontend/src/store/useAuthStore.js b/frontend/src/store/useAuthStore.js index 843c23e..1de33cf 100644 --- a/frontend/src/store/useAuthStore.js +++ b/frontend/src/store/useAuthStore.js @@ -179,7 +179,7 @@ export const useAuthStore = create((set, get) => ({ otp, }); - toast.success(res.data.message); + toast.success(res.data.message || "Email verified successfully"); return true; } catch (error) { toast.error( From 5344c4049ac885f4d3d83064cb9c50ca742d40f1 Mon Sep 17 00:00:00 2001 From: Nihar Date: Fri, 26 Jun 2026 18:33:55 +0530 Subject: [PATCH 12/13] feat: auto-login users after successful email verification --- backend/src/controllers/auth.controller.js | 9 ++++++++- frontend/src/pages/VerifyEmailPage.jsx | 2 +- frontend/src/store/useAuthStore.js | 13 ++++++++++++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index 9e4f0a2..ce9a7cf 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -120,7 +120,7 @@ export const signup = async (req, res) => { res.status(201).json({ message: "Account created successfully. Please verify your email using the OTP sent to your email address.", - email: newUser.email, + email: newUser.email, }); } catch (error) { console.error("Signup error:", error); @@ -213,7 +213,14 @@ export const verifyEmail = async (req, res) => { } }); + const token = generateToken(user._id); return res.status(200).json({ + _id: user._id, + fullName: user.fullName, + email: user.email, + profilePic: user.profilePic, + role: user.role, + token, message: "Email verified successfully", }); } catch (error) { diff --git a/frontend/src/pages/VerifyEmailPage.jsx b/frontend/src/pages/VerifyEmailPage.jsx index d8570d6..6c7fe50 100644 --- a/frontend/src/pages/VerifyEmailPage.jsx +++ b/frontend/src/pages/VerifyEmailPage.jsx @@ -23,7 +23,7 @@ const VerifyEmailPage = () => { if (success) { sessionStorage.removeItem("pendingVerificationEmail"); - navigate("/login"); + navigate("/"); } }; diff --git a/frontend/src/store/useAuthStore.js b/frontend/src/store/useAuthStore.js index 1de33cf..80299cc 100644 --- a/frontend/src/store/useAuthStore.js +++ b/frontend/src/store/useAuthStore.js @@ -178,14 +178,25 @@ export const useAuthStore = create((set, get) => ({ email, otp, }); + localStorage.setItem("token", res.data.token); + set({ + authUser: { + _id: res.data._id, + fullName: res.data.fullName, + email: res.data.email, + profilePic: res.data.profilePic, + role: res.data.role, + }, + }); toast.success(res.data.message || "Email verified successfully"); + get().connectSocket(); return true; } catch (error) { toast.error( error.response?.data?.message || "Verification failed" ); - return false; + return false; } finally { set({ isVerifyingOtp: false }); } From bcc068b03a944506484c1bb110640191dcac6daf Mon Sep 17 00:00:00 2001 From: Akash Santra Date: Fri, 26 Jun 2026 22:56:17 +0530 Subject: [PATCH 13/13] ui: improve OTP verification page --- frontend/src/pages/VerifyEmailPage.jsx | 269 ++++++++++++++++++++----- 1 file changed, 215 insertions(+), 54 deletions(-) diff --git a/frontend/src/pages/VerifyEmailPage.jsx b/frontend/src/pages/VerifyEmailPage.jsx index 6c7fe50..dd14c58 100644 --- a/frontend/src/pages/VerifyEmailPage.jsx +++ b/frontend/src/pages/VerifyEmailPage.jsx @@ -1,103 +1,264 @@ import { useState } from "react"; import { Link, useNavigate, useLocation } from "react-router-dom"; -import { Mail, Loader2, ShieldCheck } from "lucide-react"; +import { + Mail, + Loader2, + ShieldCheck, + Sparkles, +} from "lucide-react"; import { useAuthStore } from "../store/useAuthStore"; const VerifyEmailPage = () => { const navigate = useNavigate(); const location = useLocation(); - const email = location.state?.email || sessionStorage.getItem("pendingVerificationEmail") || ""; - const [otp, setOtp] = useState(""); - const isOtpComplete = /^\d{6}$/.test(otp); - const { verifyEmailOtp, resendVerificationOtp, isVerifyingOtp, isSendingOtp } = useAuthStore(); + const email = + location.state?.email || + sessionStorage.getItem("pendingVerificationEmail") || + ""; - const handleVerify = async (e) => { - e.preventDefault(); + const [otp, setOtp] = useState(["", "", "", "", "", ""]); + + const otpValue = otp.join(""); + const isOtpComplete = /^\d{6}$/.test(otpValue); + + const { + verifyEmailOtp, + resendVerificationOtp, + isVerifyingOtp, + isSendingOtp, + } = useAuthStore(); + + const handleChange = (index, value) => { + const digit = value.replace(/\D/g, ""); - if (!isOtpComplete) { + if (!digit) { + const newOtp = [...otp]; + newOtp[index] = ""; + setOtp(newOtp); return; } - const success = await verifyEmailOtp({ email, otp }); + const newOtp = [...otp]; + newOtp[index] = digit[0]; + setOtp(newOtp); - if (success) { - sessionStorage.removeItem("pendingVerificationEmail"); - navigate("/"); + if (index < 5) { + document.getElementById(`otp-${index + 1}`)?.focus(); + } + }; + + const handleKeyDown = (index, e) => { + if (e.key === "Backspace") { + const newOtp = [...otp]; + + if (otp[index]) { + newOtp[index] = ""; + setOtp(newOtp); + } else if (index > 0) { + document.getElementById(`otp-${index - 1}`)?.focus(); + } } }; + const handlePaste = (e) => { + e.preventDefault(); + + const pastedData = e.clipboardData + .getData("text") + .replace(/\D/g, "") + .slice(0, 6); + + if (!pastedData) return; + + const newOtp = ["", "", "", "", "", ""]; + + pastedData.split("").forEach((digit, index) => { + newOtp[index] = digit; + }); + + setOtp(newOtp); + }; + + const handleVerify = async (e) => { + e.preventDefault(); + + if (!isOtpComplete) return; + + const success = await verifyEmailOtp({ + email, + otp: otpValue, + }); + + if (success) { + sessionStorage.removeItem( + "pendingVerificationEmail" + ); + navigate("/login"); + } + }; const handleResend = async () => { const success = await resendVerificationOtp(email); if (success) { - setOtp(""); + setOtp(["", "", "", "", "", ""]); + document.getElementById("otp-0")?.focus(); } }; + if (!email) { return ( -
+
-

Email not found

- +

+ Email not found +

+ + Go back to Signup
); } + return ( -
-
+
+
+
+ {/* Header */} +
+
+ +
+ +

+ Verify Your Email +

+ +

+ We've sent a 6-digit verification code + to +

-
-
- + {/* Email Badge */} +
+ + + {email} + +
+ +
+ + Check your inbox and spam folder +
-

Verify Email

+ {/* Form */} +
+
+ {otp.map((digit, index) => ( + + handleChange( + index, + e.target.value + ) + } + onKeyDown={(e) => + handleKeyDown( + index, + e + ) + } + className=" + w-14 + h-16 + rounded-2xl + border + border-base-300 + bg-base-200/60 + text-center + text-2xl + font-bold + transition-all + duration-200 + focus:border-primary + focus:ring-2 + focus:ring-primary/20 + focus:outline-none + " + /> + ))} +
-

Enter the OTP sent to

+ +
-

- - {email} -

-
+ {/* Divider */} +
+ Didn't receive the code? +
-
- - setOtp(e.target.value.replace(/\D/g, "").slice(0, 6))} - placeholder="Enter 6-digit OTP" - className="input input-bordered w-full text-center text-xl tracking-[8px]" - /> - - -
- +
);