From 0a9b875a9dc542b5c6188b0c446339b520ec9e38 Mon Sep 17 00:00:00 2001 From: jackiettran <41605212+jackiettran@users.noreply.github.com> Date: Fri, 10 Oct 2025 14:36:09 -0400 Subject: [PATCH] email verfication after account creation, password component, added password special characters --- backend/middleware/auth.js | 21 +- backend/middleware/validation.js | 2 +- backend/models/User.js | 49 +++ backend/routes/auth.js | 179 ++++++++- backend/routes/items.js | 4 +- backend/routes/rentals.js | 4 +- backend/routes/stripe.js | 7 +- backend/services/emailService.js | 31 ++ .../templates/emails/emailVerification.html | 229 +++++++++++ backend/tests/unit/middleware/auth.test.js | 159 +++++++- backend/tests/unit/routes/auth.test.js | 354 +++++++++++++++++- frontend/src/App.tsx | 2 + frontend/src/components/AuthModal.tsx | 24 +- frontend/src/components/PasswordInput.tsx | 58 +++ .../src/components/PasswordStrengthMeter.tsx | 77 ++-- frontend/src/pages/Login.tsx | 21 +- frontend/src/pages/Register.tsx | 23 +- frontend/src/pages/VerifyEmail.tsx | 145 +++++++ frontend/src/services/api.ts | 2 + 19 files changed, 1305 insertions(+), 86 deletions(-) create mode 100644 backend/templates/emails/emailVerification.html create mode 100644 frontend/src/components/PasswordInput.tsx create mode 100644 frontend/src/pages/VerifyEmail.tsx diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index b0f9d4e..fa61c85 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -94,4 +94,23 @@ const optionalAuth = async (req, res, next) => { } }; -module.exports = { authenticateToken, optionalAuth }; +// Require verified email middleware - must be used after authenticateToken +const requireVerifiedEmail = (req, res, next) => { + if (!req.user) { + return res.status(401).json({ + error: "Authentication required", + code: "NO_AUTH", + }); + } + + if (!req.user.isVerified) { + return res.status(403).json({ + error: "Email verification required. Please verify your email address to perform this action.", + code: "EMAIL_NOT_VERIFIED", + }); + } + + next(); +}; + +module.exports = { authenticateToken, optionalAuth, requireVerifiedEmail }; diff --git a/backend/middleware/validation.js b/backend/middleware/validation.js index 447beeb..05d6855 100644 --- a/backend/middleware/validation.js +++ b/backend/middleware/validation.js @@ -8,7 +8,7 @@ const purify = DOMPurify(window); // Password strength validation const passwordStrengthRegex = - /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).{8,}$/; + /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z])(?=.*[-@$!%*?&#^]).{8,}$/; //-@$!%*?&#^ const commonPasswords = [ "password", "123456", diff --git a/backend/models/User.js b/backend/models/User.js index a3d137f..a6eafcc 100644 --- a/backend/models/User.js +++ b/backend/models/User.js @@ -72,6 +72,18 @@ const User = sequelize.define( type: DataTypes.BOOLEAN, defaultValue: false, }, + verificationToken: { + type: DataTypes.STRING, + allowNull: true, + }, + verificationTokenExpiry: { + type: DataTypes.DATE, + allowNull: true, + }, + verifiedAt: { + type: DataTypes.DATE, + allowNull: true, + }, defaultAvailableAfter: { type: DataTypes.STRING, defaultValue: "09:00", @@ -173,4 +185,41 @@ User.prototype.resetLoginAttempts = async function () { }); }; +// Email verification methods +User.prototype.generateVerificationToken = async function () { + const crypto = require("crypto"); + const token = crypto.randomBytes(32).toString("hex"); + const expiry = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours + + return this.update({ + verificationToken: token, + verificationTokenExpiry: expiry, + }); +}; + +User.prototype.isVerificationTokenValid = function (token) { + if (!this.verificationToken || !this.verificationTokenExpiry) { + return false; + } + + if (this.verificationToken !== token) { + return false; + } + + if (new Date() > new Date(this.verificationTokenExpiry)) { + return false; + } + + return true; +}; + +User.prototype.verifyEmail = async function () { + return this.update({ + isVerified: true, + verifiedAt: new Date(), + verificationToken: null, + verificationTokenExpiry: null, + }); +}; + module.exports = User; diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 7bb7142..8b7773c 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -3,6 +3,7 @@ const jwt = require("jsonwebtoken"); const { OAuth2Client } = require("google-auth-library"); const { User } = require("../models"); // Import from models/index.js to get models with associations const logger = require("../utils/logger"); +const emailService = require("../services/emailService"); const { sanitizeInput, validateRegistration, @@ -62,6 +63,24 @@ router.post( phone, }); + // Generate verification token and send email + await user.generateVerificationToken(); + + // Send verification email (don't block registration if email fails) + let verificationEmailSent = false; + try { + await emailService.sendVerificationEmail(user, user.verificationToken); + verificationEmailSent = true; + } catch (emailError) { + const reqLogger = logger.withRequestId(req.id); + reqLogger.error("Failed to send verification email", { + error: emailError.message, + userId: user.id, + email: user.email + }); + // Continue with registration even if email fails + } + const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET, { expiresIn: "15m", // Short-lived access token }); @@ -103,7 +122,9 @@ router.post( email: user.email, firstName: user.firstName, lastName: user.lastName, + isVerified: user.isVerified, }, + verificationEmailSent, // Don't send token in response body for security }); } catch (error) { @@ -195,6 +216,7 @@ router.post( email: user.email, firstName: user.firstName, lastName: user.lastName, + isVerified: user.isVerified, }, // Don't send token in response body for security }); @@ -266,7 +288,7 @@ router.post( }); } - // Create new user + // Create new user (Google OAuth users are auto-verified) user = await User.create({ email, firstName, @@ -275,6 +297,8 @@ router.post( providerId: googleId, profileImage: picture, username: email.split("@")[0] + "_" + googleId.slice(-6), // Generate unique username + isVerified: true, + verifiedAt: new Date(), }); } @@ -321,6 +345,7 @@ router.post( firstName: user.firstName, lastName: user.lastName, profileImage: user.profileImage, + isVerified: user.isVerified, }, // Don't send token in response body for security }); @@ -348,6 +373,157 @@ router.post( } ); +// Email verification endpoint +router.post("/verify-email", sanitizeInput, async (req, res) => { + try { + const { token } = req.body; + + if (!token) { + return res.status(400).json({ + error: "Verification token required", + code: "TOKEN_REQUIRED", + }); + } + + // Find user with this verification token + const user = await User.findOne({ + where: { verificationToken: token }, + }); + + if (!user) { + return res.status(400).json({ + error: "Invalid verification token", + code: "VERIFICATION_TOKEN_INVALID", + }); + } + + // Check if already verified + if (user.isVerified) { + return res.status(400).json({ + error: "Email already verified", + code: "ALREADY_VERIFIED", + }); + } + + // Check if token is valid (not expired) + if (!user.isVerificationTokenValid(token)) { + return res.status(400).json({ + error: "Verification token has expired. Please request a new one.", + code: "VERIFICATION_TOKEN_EXPIRED", + }); + } + + // Verify the email + await user.verifyEmail(); + + const reqLogger = logger.withRequestId(req.id); + reqLogger.info("Email verified successfully", { + userId: user.id, + email: user.email, + }); + + res.json({ + message: "Email verified successfully", + user: { + id: user.id, + email: user.email, + isVerified: true, + }, + }); + } catch (error) { + const reqLogger = logger.withRequestId(req.id); + reqLogger.error("Email verification error", { + error: error.message, + stack: error.stack, + }); + res.status(500).json({ + error: "Email verification failed. Please try again.", + }); + } +}); + +// Resend verification email endpoint +router.post( + "/resend-verification", + loginLimiter, // Use login limiter for rate limiting (max 3 per hour) + sanitizeInput, + async (req, res) => { + try { + // Get user from cookies + const { accessToken } = req.cookies; + + if (!accessToken) { + return res.status(401).json({ + error: "Authentication required", + code: "NO_TOKEN", + }); + } + + const decoded = jwt.verify(accessToken, process.env.JWT_SECRET); + const user = await User.findByPk(decoded.id); + + if (!user) { + return res.status(404).json({ + error: "User not found", + code: "USER_NOT_FOUND", + }); + } + + // Check if already verified + if (user.isVerified) { + return res.status(400).json({ + error: "Email already verified", + code: "ALREADY_VERIFIED", + }); + } + + // Generate new verification token + await user.generateVerificationToken(); + + // Send verification email + try { + await emailService.sendVerificationEmail(user, user.verificationToken); + } catch (emailError) { + const reqLogger = logger.withRequestId(req.id); + reqLogger.error("Failed to resend verification email", { + error: emailError.message, + userId: user.id, + email: user.email, + }); + return res.status(500).json({ + error: "Failed to send verification email. Please try again.", + }); + } + + const reqLogger = logger.withRequestId(req.id); + reqLogger.info("Verification email resent", { + userId: user.id, + email: user.email, + }); + + res.json({ + message: "Verification email sent successfully", + }); + } catch (error) { + if (error.name === "TokenExpiredError") { + return res.status(401).json({ + error: "Session expired. Please log in again.", + code: "TOKEN_EXPIRED", + }); + } + + const reqLogger = logger.withRequestId(req.id); + reqLogger.error("Resend verification error", { + error: error.message, + stack: error.stack, + }); + res.status(500).json({ + error: "Failed to resend verification email. Please try again.", + }); + } + } +); + // Refresh token endpoint router.post("/refresh", async (req, res) => { try { @@ -395,6 +571,7 @@ router.post("/refresh", async (req, res) => { email: user.email, firstName: user.firstName, lastName: user.lastName, + isVerified: user.isVerified, }, }); } catch (error) { diff --git a/backend/routes/items.js b/backend/routes/items.js index 6fe8fd5..615dceb 100644 --- a/backend/routes/items.js +++ b/backend/routes/items.js @@ -1,7 +1,7 @@ const express = require("express"); const { Op } = require("sequelize"); const { Item, User, Rental } = require("../models"); // Import from models/index.js to get models with associations -const { authenticateToken } = require("../middleware/auth"); +const { authenticateToken, requireVerifiedEmail } = require("../middleware/auth"); const logger = require("../utils/logger"); const router = express.Router(); @@ -213,7 +213,7 @@ router.get("/:id", async (req, res) => { } }); -router.post("/", authenticateToken, async (req, res) => { +router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => { try { const item = await Item.create({ ...req.body, diff --git a/backend/routes/rentals.js b/backend/routes/rentals.js index 199cb55..c67f39b 100644 --- a/backend/routes/rentals.js +++ b/backend/routes/rentals.js @@ -1,7 +1,7 @@ const express = require("express"); const { Op } = require("sequelize"); const { Rental, Item, User } = require("../models"); // Import from models/index.js to get models with associations -const { authenticateToken } = require("../middleware/auth"); +const { authenticateToken, requireVerifiedEmail } = require("../middleware/auth"); const FeeCalculator = require("../utils/feeCalculator"); const RefundService = require("../services/refundService"); const LateReturnService = require("../services/lateReturnService"); @@ -152,7 +152,7 @@ router.get("/:id", authenticateToken, async (req, res) => { } }); -router.post("/", authenticateToken, async (req, res) => { +router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => { try { const { itemId, diff --git a/backend/routes/stripe.js b/backend/routes/stripe.js index 320d9a8..36b32ca 100644 --- a/backend/routes/stripe.js +++ b/backend/routes/stripe.js @@ -1,5 +1,5 @@ const express = require("express"); -const { authenticateToken } = require("../middleware/auth"); +const { authenticateToken, requireVerifiedEmail } = require("../middleware/auth"); const { User, Item } = require("../models"); const StripeService = require("../services/stripeService"); const logger = require("../utils/logger"); @@ -39,7 +39,7 @@ router.get("/checkout-session/:sessionId", async (req, res) => { }); // Create connected account -router.post("/accounts", authenticateToken, async (req, res) => { +router.post("/accounts", authenticateToken, requireVerifiedEmail, async (req, res) => { try { const user = await User.findByPk(req.user.id); @@ -87,7 +87,7 @@ router.post("/accounts", authenticateToken, async (req, res) => { }); // Generate onboarding link -router.post("/account-links", authenticateToken, async (req, res) => { +router.post("/account-links", authenticateToken, requireVerifiedEmail, async (req, res) => { try { const user = await User.findByPk(req.user.id); @@ -176,6 +176,7 @@ router.get("/account-status", authenticateToken, async (req, res) => { router.post( "/create-setup-checkout-session", authenticateToken, + requireVerifiedEmail, async (req, res) => { try { const { rentalData } = req.body; diff --git a/backend/services/emailService.js b/backend/services/emailService.js index f66fa91..45d3fe1 100644 --- a/backend/services/emailService.js +++ b/backend/services/emailService.js @@ -35,6 +35,7 @@ class EmailService { const templateFiles = [ "conditionCheckReminder.html", "rentalConfirmation.html", + "emailVerification.html", "lateReturnCS.html", "damageReportCS.html", "lostItemCS.html", @@ -231,6 +232,18 @@ class EmailService {

Thank you for using RentAll!

` ), + + emailVerification: baseTemplate.replace( + "{{content}}", + ` +

Hi {{recipientName}},

+

Verify Your Email Address

+

Thank you for registering with RentAll! Please verify your email address by clicking the button below.

+

Verify Email Address

+

If the button doesn't work, copy and paste this link into your browser: {{verificationUrl}}

+

This link will expire in 24 hours.

+ ` + ), }; return ( @@ -295,6 +308,24 @@ class EmailService { ); } + async sendVerificationEmail(user, verificationToken) { + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + const verificationUrl = `${frontendUrl}/verify-email?token=${verificationToken}`; + + const variables = { + recipientName: user.firstName || "there", + verificationUrl: verificationUrl, + }; + + const htmlContent = this.renderTemplate("emailVerification", variables); + + return await this.sendEmail( + user.email, + "Verify Your Email - RentAll", + htmlContent + ); + } + async sendTemplateEmail(toEmail, subject, templateName, variables = {}) { const htmlContent = this.renderTemplate(templateName, variables); return await this.sendEmail(toEmail, subject, htmlContent); diff --git a/backend/templates/emails/emailVerification.html b/backend/templates/emails/emailVerification.html new file mode 100644 index 0000000..797d09b --- /dev/null +++ b/backend/templates/emails/emailVerification.html @@ -0,0 +1,229 @@ + + + + + + + Verify Your Email - RentAll + + + +
+
+ +
Email Verification
+
+ +
+

Hi {{recipientName}},

+ +

Verify Your Email Address

+ +

Thank you for registering with RentAll! To complete your account setup and start renting items, please verify your email address by clicking the button below.

+ +
+ Verify Email Address +
+ +
+

Why verify? Email verification helps us ensure account security and allows you to create listings, make rentals, and process payments.

+
+ +

If the button doesn't work, you can copy and paste this link into your browser:

+

{{verificationUrl}}

+ +
+

This link will expire in 24 hours. If you need a new verification link, you can request one from your account settings.

+
+ +

Didn't create an account? If you didn't register for a RentAll account, you can safely ignore this email.

+ +

Welcome to the RentAll community!

+
+ + +
+ + diff --git a/backend/tests/unit/middleware/auth.test.js b/backend/tests/unit/middleware/auth.test.js index a2120e9..31022ab 100644 --- a/backend/tests/unit/middleware/auth.test.js +++ b/backend/tests/unit/middleware/auth.test.js @@ -1,4 +1,4 @@ -const { authenticateToken } = require('../../../middleware/auth'); +const { authenticateToken, requireVerifiedEmail } = require('../../../middleware/auth'); const jwt = require('jsonwebtoken'); jest.mock('jsonwebtoken'); @@ -191,4 +191,161 @@ describe('Auth Middleware', () => { }); }); }); +}); + +describe('requireVerifiedEmail Middleware', () => { + let req, res, next; + + beforeEach(() => { + req = { + user: null + }; + res = { + status: jest.fn().mockReturnThis(), + json: jest.fn() + }; + next = jest.fn(); + jest.clearAllMocks(); + }); + + describe('Verified users', () => { + it('should call next for verified user', () => { + req.user = { + id: 1, + email: 'verified@test.com', + isVerified: true + }; + + requireVerifiedEmail(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + expect(res.json).not.toHaveBeenCalled(); + }); + + it('should call next for verified OAuth user', () => { + req.user = { + id: 2, + email: 'google@test.com', + authProvider: 'google', + isVerified: true + }; + + requireVerifiedEmail(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + }); + + describe('Unverified users', () => { + it('should return 403 for unverified user', () => { + req.user = { + id: 1, + email: 'unverified@test.com', + isVerified: false + }; + + requireVerifiedEmail(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ + error: 'Email verification required. Please verify your email address to perform this action.', + code: 'EMAIL_NOT_VERIFIED' + }); + expect(next).not.toHaveBeenCalled(); + }); + + it('should return 403 when isVerified is null', () => { + req.user = { + id: 1, + email: 'test@test.com', + isVerified: null + }; + + requireVerifiedEmail(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ + error: 'Email verification required. Please verify your email address to perform this action.', + code: 'EMAIL_NOT_VERIFIED' + }); + expect(next).not.toHaveBeenCalled(); + }); + + it('should return 403 when isVerified is undefined', () => { + req.user = { + id: 1, + email: 'test@test.com' + // isVerified is undefined + }; + + requireVerifiedEmail(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ + error: 'Email verification required. Please verify your email address to perform this action.', + code: 'EMAIL_NOT_VERIFIED' + }); + expect(next).not.toHaveBeenCalled(); + }); + }); + + describe('No user', () => { + it('should return 401 when user is not set', () => { + req.user = null; + + requireVerifiedEmail(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ + error: 'Authentication required', + code: 'NO_AUTH' + }); + expect(next).not.toHaveBeenCalled(); + }); + + it('should return 401 when user is undefined', () => { + req.user = undefined; + + requireVerifiedEmail(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ + error: 'Authentication required', + code: 'NO_AUTH' + }); + expect(next).not.toHaveBeenCalled(); + }); + }); + + describe('Edge cases', () => { + it('should handle user object with extra fields', () => { + req.user = { + id: 1, + email: 'test@test.com', + isVerified: true, + firstName: 'Test', + lastName: 'User', + phone: '1234567890' + }; + + requireVerifiedEmail(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + + it('should prioritize missing user over unverified user', () => { + // If called without authenticateToken first + req.user = null; + + requireVerifiedEmail(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ + error: 'Authentication required', + code: 'NO_AUTH' + }); + }); + }); }); \ No newline at end of file diff --git a/backend/tests/unit/routes/auth.test.js b/backend/tests/unit/routes/auth.test.js index c325450..13e4576 100644 --- a/backend/tests/unit/routes/auth.test.js +++ b/backend/tests/unit/routes/auth.test.js @@ -38,11 +38,17 @@ jest.mock('../../../middleware/rateLimiter', () => ({ registerLimiter: (req, res, next) => next(), })); +jest.mock('../../../services/emailService', () => ({ + sendVerificationEmail: jest.fn() +})); + const { User } = require('../../../models'); +const emailService = require('../../../services/emailService'); // Set up OAuth2Client mock before requiring authRoutes const mockGoogleClient = { - verifyIdToken: jest.fn() + verifyIdToken: jest.fn(), + getToken: jest.fn() }; OAuth2Client.mockImplementation(() => mockGoogleClient); @@ -90,10 +96,13 @@ describe('Auth Routes', () => { username: 'testuser', email: 'test@example.com', firstName: 'Test', - lastName: 'User' + lastName: 'User', + isVerified: false, + generateVerificationToken: jest.fn().mockResolvedValue() }; User.create.mockResolvedValue(newUser); + emailService.sendVerificationEmail.mockResolvedValue(); const response = await request(app) .post('/auth/register') @@ -112,8 +121,12 @@ describe('Auth Routes', () => { username: 'testuser', email: 'test@example.com', firstName: 'Test', - lastName: 'User' + lastName: 'User', + isVerified: false }); + expect(response.body.verificationEmailSent).toBe(true); + expect(newUser.generateVerificationToken).toHaveBeenCalled(); + expect(emailService.sendVerificationEmail).toHaveBeenCalledWith(newUser, newUser.verificationToken); // Check that cookies are set expect(response.headers['set-cookie']).toEqual( @@ -571,8 +584,17 @@ describe('Auth Routes', () => { process.env.NODE_ENV = 'prod'; User.findOne.mockResolvedValue(null); - const newUser = { id: 1, username: 'test', email: 'test@example.com', firstName: 'Test', lastName: 'User' }; + const newUser = { + id: 1, + username: 'test', + email: 'test@example.com', + firstName: 'Test', + lastName: 'User', + isVerified: false, + generateVerificationToken: jest.fn().mockResolvedValue() + }; User.create.mockResolvedValue(newUser); + emailService.sendVerificationEmail.mockResolvedValue(); jwt.sign.mockReturnValueOnce('access').mockReturnValueOnce('refresh'); const response = await request(app) @@ -629,7 +651,17 @@ describe('Auth Routes', () => { describe('Token management', () => { it('should generate both access and refresh tokens on registration', async () => { User.findOne.mockResolvedValue(null); - User.create.mockResolvedValue({ id: 1, username: 'test', email: 'test@example.com', firstName: 'Test', lastName: 'User' }); + const newUser = { + id: 1, + username: 'test', + email: 'test@example.com', + firstName: 'Test', + lastName: 'User', + isVerified: false, + generateVerificationToken: jest.fn().mockResolvedValue() + }; + User.create.mockResolvedValue(newUser); + emailService.sendVerificationEmail.mockResolvedValue(); jwt.sign .mockReturnValueOnce('access-token') @@ -659,7 +691,17 @@ describe('Auth Routes', () => { it('should set correct cookie options', async () => { User.findOne.mockResolvedValue(null); - User.create.mockResolvedValue({ id: 1, username: 'test', email: 'test@example.com', firstName: 'Test', lastName: 'User' }); + const newUser = { + id: 1, + username: 'test', + email: 'test@example.com', + firstName: 'Test', + lastName: 'User', + isVerified: false, + generateVerificationToken: jest.fn().mockResolvedValue() + }; + User.create.mockResolvedValue(newUser); + emailService.sendVerificationEmail.mockResolvedValue(); jwt.sign.mockReturnValueOnce('access').mockReturnValueOnce('refresh'); const response = await request(app) @@ -679,4 +721,304 @@ describe('Auth Routes', () => { expect(cookies[1]).toContain('SameSite=Strict'); }); }); + + describe('Email Verification', () => { + describe('Registration with verification', () => { + it('should continue registration even if verification email fails', async () => { + User.findOne.mockResolvedValue(null); + + const newUser = { + id: 1, + username: 'testuser', + email: 'test@example.com', + firstName: 'Test', + lastName: 'User', + isVerified: false, + generateVerificationToken: jest.fn().mockResolvedValue() + }; + + User.create.mockResolvedValue(newUser); + emailService.sendVerificationEmail.mockRejectedValue(new Error('Email service down')); + + const response = await request(app) + .post('/auth/register') + .send({ + username: 'testuser', + email: 'test@example.com', + password: 'StrongPass123!', + firstName: 'Test', + lastName: 'User' + }); + + expect(response.status).toBe(201); + expect(response.body.user.id).toBe(1); + expect(response.body.verificationEmailSent).toBe(false); + expect(newUser.generateVerificationToken).toHaveBeenCalled(); + }); + }); + + describe('Google OAuth auto-verification', () => { + it('should auto-verify Google OAuth users', async () => { + const mockPayload = { + sub: 'google456', + email: 'oauth@gmail.com', + given_name: 'OAuth', + family_name: 'User', + picture: 'pic.jpg' + }; + + mockGoogleClient.getToken.mockResolvedValue({ + tokens: { id_token: 'google-id-token' } + }); + + mockGoogleClient.verifyIdToken.mockResolvedValue({ + getPayload: () => mockPayload + }); + + User.findOne + .mockResolvedValueOnce(null) // No Google user + .mockResolvedValueOnce(null); // No email user + + const createdUser = { + id: 1, + username: 'oauth_gle456', + email: 'oauth@gmail.com', + firstName: 'OAuth', + lastName: 'User', + profileImage: 'pic.jpg', + isVerified: true + }; + + User.create.mockResolvedValue(createdUser); + jwt.sign.mockReturnValueOnce('access-token').mockReturnValueOnce('refresh-token'); + + const response = await request(app) + .post('/auth/google') + .send({ + code: 'google-auth-code' + }); + + expect(response.status).toBe(200); + expect(User.create).toHaveBeenCalledWith( + expect.objectContaining({ + isVerified: true, + verifiedAt: expect.any(Date) + }) + ); + }); + }); + + describe('POST /auth/verify-email', () => { + it('should verify email with valid token', async () => { + const mockUser = { + id: 1, + email: 'test@example.com', + verificationToken: 'valid-token-123', + verificationTokenExpiry: new Date(Date.now() + 60 * 60 * 1000), + isVerified: false, + isVerificationTokenValid: function(token) { + return this.verificationToken === token && + new Date() < new Date(this.verificationTokenExpiry); + }, + verifyEmail: jest.fn().mockResolvedValue({ + id: 1, + email: 'test@example.com', + isVerified: true + }) + }; + + User.findOne.mockResolvedValue(mockUser); + + const response = await request(app) + .post('/auth/verify-email') + .send({ token: 'valid-token-123' }); + + expect(response.status).toBe(200); + expect(response.body.message).toBe('Email verified successfully'); + expect(response.body.user).toEqual({ + id: 1, + email: 'test@example.com', + isVerified: true + }); + expect(mockUser.verifyEmail).toHaveBeenCalled(); + }); + + it('should reject missing token', async () => { + const response = await request(app) + .post('/auth/verify-email') + .send({}); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Verification token required'); + expect(response.body.code).toBe('TOKEN_REQUIRED'); + }); + + it('should reject invalid token', async () => { + User.findOne.mockResolvedValue(null); + + const response = await request(app) + .post('/auth/verify-email') + .send({ token: 'invalid-token' }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Invalid verification token'); + expect(response.body.code).toBe('VERIFICATION_TOKEN_INVALID'); + }); + + it('should reject already verified user', async () => { + const mockUser = { + id: 1, + email: 'verified@example.com', + isVerified: true + }; + + User.findOne.mockResolvedValue(mockUser); + + const response = await request(app) + .post('/auth/verify-email') + .send({ token: 'some-token' }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Email already verified'); + expect(response.body.code).toBe('ALREADY_VERIFIED'); + }); + + it('should reject expired token', async () => { + const mockUser = { + id: 1, + email: 'test@example.com', + verificationToken: 'expired-token', + verificationTokenExpiry: new Date(Date.now() - 60 * 60 * 1000), + isVerified: false, + isVerificationTokenValid: jest.fn().mockReturnValue(false) + }; + + User.findOne.mockResolvedValue(mockUser); + + const response = await request(app) + .post('/auth/verify-email') + .send({ token: 'expired-token' }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Verification token has expired. Please request a new one.'); + expect(response.body.code).toBe('VERIFICATION_TOKEN_EXPIRED'); + }); + + it('should handle verification errors', async () => { + User.findOne.mockRejectedValue(new Error('Database error')); + + const response = await request(app) + .post('/auth/verify-email') + .send({ token: 'valid-token' }); + + expect(response.status).toBe(500); + expect(response.body.error).toBe('Email verification failed. Please try again.'); + }); + }); + + describe('POST /auth/resend-verification', () => { + it('should resend verification email for authenticated unverified user', async () => { + const mockUser = { + id: 1, + email: 'test@example.com', + firstName: 'Test', + isVerified: false, + verificationToken: 'new-token', + generateVerificationToken: jest.fn().mockResolvedValue() + }; + + jwt.verify.mockReturnValue({ id: 1 }); + User.findByPk.mockResolvedValue(mockUser); + emailService.sendVerificationEmail.mockResolvedValue(); + + const response = await request(app) + .post('/auth/resend-verification') + .set('Cookie', ['accessToken=valid-token']); + + expect(response.status).toBe(200); + expect(response.body.message).toBe('Verification email sent successfully'); + expect(mockUser.generateVerificationToken).toHaveBeenCalled(); + expect(emailService.sendVerificationEmail).toHaveBeenCalledWith(mockUser, mockUser.verificationToken); + }); + + it('should reject when no access token provided', async () => { + const response = await request(app) + .post('/auth/resend-verification'); + + expect(response.status).toBe(401); + expect(response.body.error).toBe('Authentication required'); + expect(response.body.code).toBe('NO_TOKEN'); + }); + + it('should reject expired access token', async () => { + const error = new Error('jwt expired'); + error.name = 'TokenExpiredError'; + jwt.verify.mockImplementation(() => { + throw error; + }); + + const response = await request(app) + .post('/auth/resend-verification') + .set('Cookie', ['accessToken=expired-token']); + + expect(response.status).toBe(401); + expect(response.body.error).toBe('Session expired. Please log in again.'); + expect(response.body.code).toBe('TOKEN_EXPIRED'); + }); + + it('should reject when user not found', async () => { + jwt.verify.mockReturnValue({ id: 999 }); + User.findByPk.mockResolvedValue(null); + + const response = await request(app) + .post('/auth/resend-verification') + .set('Cookie', ['accessToken=valid-token']); + + expect(response.status).toBe(404); + expect(response.body.error).toBe('User not found'); + expect(response.body.code).toBe('USER_NOT_FOUND'); + }); + + it('should reject when user already verified', async () => { + const mockUser = { + id: 1, + email: 'verified@example.com', + isVerified: true + }; + + jwt.verify.mockReturnValue({ id: 1 }); + User.findByPk.mockResolvedValue(mockUser); + + const response = await request(app) + .post('/auth/resend-verification') + .set('Cookie', ['accessToken=valid-token']); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Email already verified'); + expect(response.body.code).toBe('ALREADY_VERIFIED'); + }); + + it('should handle email service failure', async () => { + const mockUser = { + id: 1, + email: 'test@example.com', + isVerified: false, + verificationToken: 'new-token', + generateVerificationToken: jest.fn().mockResolvedValue() + }; + + jwt.verify.mockReturnValue({ id: 1 }); + User.findByPk.mockResolvedValue(mockUser); + emailService.sendVerificationEmail.mockRejectedValue(new Error('Email service down')); + + const response = await request(app) + .post('/auth/resend-verification') + .set('Cookie', ['accessToken=valid-token']); + + expect(response.status).toBe(500); + expect(response.body.error).toBe('Failed to send verification email. Please try again.'); + expect(mockUser.generateVerificationToken).toHaveBeenCalled(); + }); + }); + }); }); \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0c04ec0..72c3ede 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,7 @@ import Home from './pages/Home'; import Login from './pages/Login'; import Register from './pages/Register'; import GoogleCallback from './pages/GoogleCallback'; +import VerifyEmail from './pages/VerifyEmail'; import ItemList from './pages/ItemList'; import ItemDetail from './pages/ItemDetail'; import EditItem from './pages/EditItem'; @@ -38,6 +39,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/AuthModal.tsx b/frontend/src/components/AuthModal.tsx index e13de3b..41d8d9d 100644 --- a/frontend/src/components/AuthModal.tsx +++ b/frontend/src/components/AuthModal.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useRef, useCallback } from "react"; import { useAuth } from "../contexts/AuthContext"; import PasswordStrengthMeter from "./PasswordStrengthMeter"; +import PasswordInput from "./PasswordInput"; interface AuthModalProps { show: boolean; @@ -154,19 +155,18 @@ const AuthModal: React.FC = ({ /> -
- - setPassword(e.target.value)} - required - /> - {mode === "signup" && ( + setPassword(e.target.value)} + required + /> + {mode === "signup" && ( +
- )} -
+
+ )} + + + ); +}; + +export default PasswordInput; diff --git a/frontend/src/components/PasswordStrengthMeter.tsx b/frontend/src/components/PasswordStrengthMeter.tsx index c9ad897..e5ba2aa 100644 --- a/frontend/src/components/PasswordStrengthMeter.tsx +++ b/frontend/src/components/PasswordStrengthMeter.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React from "react"; interface PasswordStrengthMeterProps { password: string; @@ -13,60 +13,71 @@ interface PasswordRequirement { const PasswordStrengthMeter: React.FC = ({ password, - showRequirements = true + showRequirements = true, }) => { const requirements: PasswordRequirement[] = [ { regex: /.{8,}/, text: "At least 8 characters", - met: /.{8,}/.test(password) + met: /.{8,}/.test(password), }, { regex: /[a-z]/, text: "One lowercase letter", - met: /[a-z]/.test(password) + met: /[a-z]/.test(password), }, { regex: /[A-Z]/, - text: "One uppercase letter", - met: /[A-Z]/.test(password) + text: "One uppercase letter", + met: /[A-Z]/.test(password), }, { regex: /\d/, text: "One number", - met: /\d/.test(password) + met: /\d/.test(password), }, { - regex: /[@$!%*?&]/, - text: "One special character (@$!%*?&)", - met: /[@$!%*?&]/.test(password) - } + regex: /[-@$!%*?&#^]/, + text: "One special character (-@$!%*?&#^)", + met: /[-@$!%*?&#^]/.test(password), + }, ]; - const getPasswordStrength = (): { score: number; label: string; color: string } => { - if (!password) return { score: 0, label: '', color: '' }; + const getPasswordStrength = (): { + score: number; + label: string; + color: string; + } => { + if (!password) return { score: 0, label: "", color: "" }; + + const metRequirements = requirements.filter((req) => req.met).length; + const hasCommonPassword = [ + "password", + "123456", + "123456789", + "qwerty", + "abc123", + "password123", + ].includes(password.toLowerCase()); - const metRequirements = requirements.filter(req => req.met).length; - const hasCommonPassword = ['password', '123456', '123456789', 'qwerty', 'abc123', 'password123'].includes(password.toLowerCase()); - if (hasCommonPassword) { - return { score: 0, label: 'Too Common', color: 'danger' }; + return { score: 0, label: "Too Common", color: "danger" }; } switch (metRequirements) { case 0: case 1: - return { score: 1, label: 'Very Weak', color: 'danger' }; + return { score: 1, label: "Very Weak", color: "danger" }; case 2: - return { score: 2, label: 'Weak', color: 'warning' }; + return { score: 2, label: "Weak", color: "warning" }; case 3: - return { score: 3, label: 'Fair', color: 'info' }; + return { score: 3, label: "Fair", color: "info" }; case 4: - return { score: 4, label: 'Good', color: 'primary' }; + return { score: 4, label: "Good", color: "primary" }; case 5: - return { score: 5, label: 'Strong', color: 'success' }; + return { score: 5, label: "Strong", color: "success" }; default: - return { score: 0, label: '', color: '' }; + return { score: 0, label: "", color: "" }; } }; @@ -87,7 +98,7 @@ const PasswordStrengthMeter: React.FC = ({ )} -
+
= ({ {/* Requirements List */} {showRequirements && (
- Password must contain: -
    + + Password must contain: + +
      {requirements.map((requirement, index) => (
    • - + {requirement.text}
    • @@ -124,4 +141,4 @@ const PasswordStrengthMeter: React.FC = ({ ); }; -export default PasswordStrengthMeter; \ No newline at end of file +export default PasswordStrengthMeter; diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index fab1096..c7bc228 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -1,6 +1,7 @@ import React, { useState } from 'react'; import { Link, useNavigate, useLocation } from 'react-router-dom'; import { useAuth } from '../contexts/AuthContext'; +import PasswordInput from '../components/PasswordInput'; const Login: React.FC = () => { const [email, setEmail] = useState(''); @@ -54,19 +55,13 @@ const Login: React.FC = () => { required />
-
- - setPassword(e.target.value)} - required - /> -
+ setPassword(e.target.value)} + required + />
-
- - -
+ + )} + + Return to Home + +
+ + ) : null} +
+ + + + + ); +}; + +export default VerifyEmail; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 8b906c1..f40d0b1 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -166,6 +166,8 @@ export const authAPI = { refresh: () => api.post("/auth/refresh"), getCSRFToken: () => api.get("/auth/csrf-token"), getStatus: () => api.get("/auth/status"), + verifyEmail: (token: string) => api.post("/auth/verify-email", { token }), + resendVerification: () => api.post("/auth/resend-verification"), }; export const userAPI = {