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 */} +
+
+ {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 + " + /> + ))} +
+ + +
+ + {/* 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 });