diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js
index 6b5d675..ce9a7cf 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 { sendWelcomeEmail, 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,44 @@ export const signup = async (req, res) => {
),
}))
);
+ const verificationOtp = crypto.randomInt(100000, 1000000).toString();
+
+ const hashedVerificationOtp = await bcrypt.hash(
+ verificationOtp,
+ 10
+ );
- // Create user (NO verification fields)
+ 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(
- newUser.email,
- newUser.fullName
- );
+ // 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);
+ }
});
- // Send user response
res.status(201).json({
- _id: newUser._id,
- fullName: newUser.fullName,
+ message:
+ "Account created successfully. Please verify your email using the OTP sent to your email address.",
email: newUser.email,
- profilePic: newUser.profilePic,
- role: newUser.role,
- token,
});
} catch (error) {
console.error("Signup error:", error);
@@ -120,6 +131,110 @@ export const signup = async (req, res) => {
}
};
+export const verifyEmail = async (req, res) => {
+ try {
+ const { email, otp } = req.body;
+
+ // 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",
+ });
+ }
+
+ const normalizedEmail = email.toLowerCase().trim();
+
+ const user = await User.findOne({
+ email: normalizedEmail,
+ }).select(
+ "+emailVerificationOtp +emailVerificationOtpExpiry"
+ );
+
+ const genericErrorMessage =
+ "Invalid or expired verification request";
+
+ // Prevent user enumeration
+ if (!user) {
+ return res.status(400).json({
+ message: genericErrorMessage,
+ });
+ }
+
+ if (user.isVerified) {
+ return res.status(400).json({
+ message: genericErrorMessage,
+ });
+ }
+
+ if (
+ !user.emailVerificationOtp ||
+ !user.emailVerificationOtpExpiry ||
+ user.emailVerificationOtpExpiry < new Date()
+ ) {
+ return res.status(400).json({
+ message: genericErrorMessage,
+ });
+ }
+
+ const isOtpValid = await bcrypt.compare(
+ otp,
+ user.emailVerificationOtp
+ );
+
+ if (!isOtpValid) {
+ return res.status(400).json({
+ message: genericErrorMessage,
+ });
+ }
+
+ user.isVerified = true;
+ user.emailVerificationOtp = null;
+ user.emailVerificationOtpExpiry = null;
+
+ await user.save();
+
+ // Send welcome email asynchronously
+ setImmediate(async () => {
+ try {
+ await sendWelcomeEmail(
+ user.email,
+ user.fullName
+ );
+ } catch (error) {
+ console.error(
+ "Welcome email failed:",
+ error
+ );
+ }
+ });
+
+ 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) {
+ console.error(
+ "Verify email error:",
+ error
+ );
+
+ return res.status(500).json({
+ message: "Internal Server Error",
+ });
+ }
+};
+
//login
// Get email, password from req.body
// Convert email to lowercase
@@ -161,11 +276,11 @@ export const login = async (req, res) => {
});
}
- // if (!user.isVerified) {
- // return res.status(401).json({
- // message: "Please verify your email first",
- // });
- // }
+ if (user.isVerified === false && user.isInit("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) {
@@ -788,4 +903,75 @@ 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 && user.isVerified === false) {
+ const verificationOtp = crypto
+ .randomInt(100000, 1000000)
+ .toString();
+
+ const hashedVerificationOtp = await bcrypt.hash(
+ verificationOtp,
+ 10
+ );
+
+ user.emailVerificationOtp =
+ hashedVerificationOtp;
+
+ user.emailVerificationOtpExpiry =
+ new Date(
+ Date.now() + 5 * 60 * 1000
+ );
+
+ await user.save();
+
+ // 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:
+ "If an account exists and requires verification, a verification OTP has been sent.",
+ });
+ } 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/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
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 },
);
diff --git a/backend/src/routes/auth.route.js b/backend/src/routes/auth.route.js
index fdbae6a..e820d37 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", otpRateLimiter, resendVerificationOtp);
router.post("/login", login);
router.post("/logout", logout);
router.get("/check", protectRoute, checkAuth);
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..90eba13 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";
@@ -122,9 +122,49 @@ 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", () => {
+ 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 +360,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 +386,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 +419,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..81bd8e5 100644
--- a/backend/test/auth/otp.test.js
+++ b/backend/test/auth/otp.test.js
@@ -2,23 +2,27 @@ 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");
-const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+const wait = () => new Promise((resolve) => setImmediate(resolve));
beforeAll(async () => {
await connectTestDB();
@@ -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,159 @@ 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";
+ let hashedVerificationOtp;
+
+ beforeEach(async () => {
+ hashedVerificationOtp = await bcrypt.hash(verificationOtp, 10);
+ 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 or expired verification request");
+ });
+
+ 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("Invalid or expired verification request");
+ });
+
+ 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("Invalid or expired verification request");
+ });
+ });
+
+ 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: previousOtpHash,
+ emailVerificationOtpExpiry: new Date(Date.now() + 2 * 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 previousOtpHashVal = previousUser.emailVerificationOtp;
+ const previousExpiry = previousUser.emailVerificationOtpExpiry;
+
+ const response = await request(app)
+ .post("/api/auth/resend-verification-otp")
+ .send({ email: resendEmail });
+
+ await wait();
+
+ 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).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(previousOtpHashVal);
+ expect(user.emailVerificationOtpExpiry.getTime()).toBeGreaterThan(
+ previousExpiry.getTime() + 60000
+ );
+
+ const isMatch = await bcrypt.compare(sentOtp, user.emailVerificationOtp);
+ expect(isMatch).toBe(true);
+ });
+
+ it("should return generic success for 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(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 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(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 aea33b4..727a4e4 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,42 @@ 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) => setImmediate(resolve));
+
+ 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 isOtpCorrect = await bcrypt.compare(sentOtp, userInDB.emailVerificationOtp);
+ expect(isOtpCorrect).toBe(true);
});
- 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 +118,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 +130,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 +344,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 +386,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 +430,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 +454,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..e6fae9a 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) => setImmediate(resolve));
+ }
+
+ 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..fd6e1e1 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];
@@ -203,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}`);
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);
};
// ============================================================================
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..29e767a 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,15 @@ 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){
+ const email = result.email || formData.email.trim();
+ sessionStorage.setItem("pendingVerificationEmail", email);
+ navigate("/verify-email",{state:{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..dd14c58
--- /dev/null
+++ b/frontend/src/pages/VerifyEmailPage.jsx
@@ -0,0 +1,267 @@
+import { useState } from "react";
+import { Link, useNavigate, useLocation } from "react-router-dom";
+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 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 (!digit) {
+ const newOtp = [...otp];
+ newOtp[index] = "";
+ setOtp(newOtp);
+ return;
+ }
+
+ const newOtp = [...otp];
+ newOtp[index] = digit[0];
+ setOtp(newOtp);
+
+ 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(["", "", "", "", "", ""]);
+ document.getElementById("otp-0")?.focus();
+ }
+ };
+
+ if (!email) {
+ return (
+
+
+
+ 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
+
+
+
+ {/* Form */}
+
+
+ {/* Divider */}
+
+ Didn't receive the code?
+
+
+
+
+
+
+ );
+};
+
+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..80299cc 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,61 @@ export const useAuthStore = create((set, get) => ({
}
},
+ verifyEmailOtp: async ({ email, otp }) => {
+ set({ isVerifyingOtp: true });
+
+ try {
+ const res = await axiosInstance.post("/auth/verify-email", {
+ 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;
+ } 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 });