email verfication after account creation, password component, added password special characters

This commit is contained in:
jackiettran
2025-10-10 14:36:09 -04:00
parent 513347e8b7
commit 0a9b875a9d
19 changed files with 1305 additions and 86 deletions

View File

@@ -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 };

View File

@@ -8,7 +8,7 @@ const purify = DOMPurify(window);
// Password strength validation // Password strength validation
const passwordStrengthRegex = const passwordStrengthRegex =
/^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).{8,}$/; /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z])(?=.*[-@$!%*?&#^]).{8,}$/; //-@$!%*?&#^
const commonPasswords = [ const commonPasswords = [
"password", "password",
"123456", "123456",

View File

@@ -72,6 +72,18 @@ const User = sequelize.define(
type: DataTypes.BOOLEAN, type: DataTypes.BOOLEAN,
defaultValue: false, defaultValue: false,
}, },
verificationToken: {
type: DataTypes.STRING,
allowNull: true,
},
verificationTokenExpiry: {
type: DataTypes.DATE,
allowNull: true,
},
verifiedAt: {
type: DataTypes.DATE,
allowNull: true,
},
defaultAvailableAfter: { defaultAvailableAfter: {
type: DataTypes.STRING, type: DataTypes.STRING,
defaultValue: "09:00", 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; module.exports = User;

View File

@@ -3,6 +3,7 @@ const jwt = require("jsonwebtoken");
const { OAuth2Client } = require("google-auth-library"); const { OAuth2Client } = require("google-auth-library");
const { User } = require("../models"); // Import from models/index.js to get models with associations const { User } = require("../models"); // Import from models/index.js to get models with associations
const logger = require("../utils/logger"); const logger = require("../utils/logger");
const emailService = require("../services/emailService");
const { const {
sanitizeInput, sanitizeInput,
validateRegistration, validateRegistration,
@@ -62,6 +63,24 @@ router.post(
phone, 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, { const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET, {
expiresIn: "15m", // Short-lived access token expiresIn: "15m", // Short-lived access token
}); });
@@ -103,7 +122,9 @@ router.post(
email: user.email, email: user.email,
firstName: user.firstName, firstName: user.firstName,
lastName: user.lastName, lastName: user.lastName,
isVerified: user.isVerified,
}, },
verificationEmailSent,
// Don't send token in response body for security // Don't send token in response body for security
}); });
} catch (error) { } catch (error) {
@@ -195,6 +216,7 @@ router.post(
email: user.email, email: user.email,
firstName: user.firstName, firstName: user.firstName,
lastName: user.lastName, lastName: user.lastName,
isVerified: user.isVerified,
}, },
// Don't send token in response body for security // 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({ user = await User.create({
email, email,
firstName, firstName,
@@ -275,6 +297,8 @@ router.post(
providerId: googleId, providerId: googleId,
profileImage: picture, profileImage: picture,
username: email.split("@")[0] + "_" + googleId.slice(-6), // Generate unique username username: email.split("@")[0] + "_" + googleId.slice(-6), // Generate unique username
isVerified: true,
verifiedAt: new Date(),
}); });
} }
@@ -321,6 +345,7 @@ router.post(
firstName: user.firstName, firstName: user.firstName,
lastName: user.lastName, lastName: user.lastName,
profileImage: user.profileImage, profileImage: user.profileImage,
isVerified: user.isVerified,
}, },
// Don't send token in response body for security // 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 // Refresh token endpoint
router.post("/refresh", async (req, res) => { router.post("/refresh", async (req, res) => {
try { try {
@@ -395,6 +571,7 @@ router.post("/refresh", async (req, res) => {
email: user.email, email: user.email,
firstName: user.firstName, firstName: user.firstName,
lastName: user.lastName, lastName: user.lastName,
isVerified: user.isVerified,
}, },
}); });
} catch (error) { } catch (error) {

View File

@@ -1,7 +1,7 @@
const express = require("express"); const express = require("express");
const { Op } = require("sequelize"); const { Op } = require("sequelize");
const { Item, User, Rental } = require("../models"); // Import from models/index.js to get models with associations 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 logger = require("../utils/logger");
const router = express.Router(); 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 { try {
const item = await Item.create({ const item = await Item.create({
...req.body, ...req.body,

View File

@@ -1,7 +1,7 @@
const express = require("express"); const express = require("express");
const { Op } = require("sequelize"); const { Op } = require("sequelize");
const { Rental, Item, User } = require("../models"); // Import from models/index.js to get models with associations 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 FeeCalculator = require("../utils/feeCalculator");
const RefundService = require("../services/refundService"); const RefundService = require("../services/refundService");
const LateReturnService = require("../services/lateReturnService"); 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 { try {
const { const {
itemId, itemId,

View File

@@ -1,5 +1,5 @@
const express = require("express"); const express = require("express");
const { authenticateToken } = require("../middleware/auth"); const { authenticateToken, requireVerifiedEmail } = require("../middleware/auth");
const { User, Item } = require("../models"); const { User, Item } = require("../models");
const StripeService = require("../services/stripeService"); const StripeService = require("../services/stripeService");
const logger = require("../utils/logger"); const logger = require("../utils/logger");
@@ -39,7 +39,7 @@ router.get("/checkout-session/:sessionId", async (req, res) => {
}); });
// Create connected account // Create connected account
router.post("/accounts", authenticateToken, async (req, res) => { router.post("/accounts", authenticateToken, requireVerifiedEmail, async (req, res) => {
try { try {
const user = await User.findByPk(req.user.id); const user = await User.findByPk(req.user.id);
@@ -87,7 +87,7 @@ router.post("/accounts", authenticateToken, async (req, res) => {
}); });
// Generate onboarding link // Generate onboarding link
router.post("/account-links", authenticateToken, async (req, res) => { router.post("/account-links", authenticateToken, requireVerifiedEmail, async (req, res) => {
try { try {
const user = await User.findByPk(req.user.id); const user = await User.findByPk(req.user.id);
@@ -176,6 +176,7 @@ router.get("/account-status", authenticateToken, async (req, res) => {
router.post( router.post(
"/create-setup-checkout-session", "/create-setup-checkout-session",
authenticateToken, authenticateToken,
requireVerifiedEmail,
async (req, res) => { async (req, res) => {
try { try {
const { rentalData } = req.body; const { rentalData } = req.body;

View File

@@ -35,6 +35,7 @@ class EmailService {
const templateFiles = [ const templateFiles = [
"conditionCheckReminder.html", "conditionCheckReminder.html",
"rentalConfirmation.html", "rentalConfirmation.html",
"emailVerification.html",
"lateReturnCS.html", "lateReturnCS.html",
"damageReportCS.html", "damageReportCS.html",
"lostItemCS.html", "lostItemCS.html",
@@ -231,6 +232,18 @@ class EmailService {
<p>Thank you for using RentAll!</p> <p>Thank you for using RentAll!</p>
` `
), ),
emailVerification: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{recipientName}},</p>
<h2>Verify Your Email Address</h2>
<p>Thank you for registering with RentAll! Please verify your email address by clicking the button below.</p>
<p><a href="{{verificationUrl}}" class="button">Verify Email Address</a></p>
<p>If the button doesn't work, copy and paste this link into your browser: {{verificationUrl}}</p>
<p><strong>This link will expire in 24 hours.</strong></p>
`
),
}; };
return ( 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 = {}) { async sendTemplateEmail(toEmail, subject, templateName, variables = {}) {
const htmlContent = this.renderTemplate(templateName, variables); const htmlContent = this.renderTemplate(templateName, variables);
return await this.sendEmail(toEmail, subject, htmlContent); return await this.sendEmail(toEmail, subject, htmlContent);

View File

@@ -0,0 +1,229 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Verify Your Email - RentAll</title>
<style>
/* Reset styles */
body, table, td, p, a, li, blockquote {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table, td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
-ms-interpolation-mode: bicubic;
}
/* Base styles */
body {
margin: 0;
padding: 0;
width: 100% !important;
min-width: 100%;
height: 100%;
background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #212529;
}
/* Container */
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
/* Header */
.header {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
padding: 40px 30px;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #d4edda;
font-size: 14px;
margin-top: 8px;
}
/* Content */
.content {
padding: 40px 30px;
}
.content h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 20px 0;
color: #212529;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Button */
.button {
display: inline-block;
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
color: #ffffff !important;
text-decoration: none;
padding: 16px 32px;
border-radius: 6px;
font-weight: 600;
margin: 20px 0;
text-align: center;
transition: all 0.3s ease;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(40, 167, 69, 0.4);
}
/* Info box */
.info-box {
background-color: #e7f3ff;
border-left: 4px solid #0066cc;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0;
color: #004085;
font-size: 14px;
}
/* Warning box */
.warning-box {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 15px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.warning-box p {
margin: 0;
color: #856404;
font-size: 14px;
}
/* Footer */
.footer {
background-color: #f8f9fa;
padding: 30px;
text-align: center;
border-top: 1px solid #e9ecef;
}
.footer p {
margin: 0 0 10px 0;
font-size: 14px;
color: #6c757d;
}
.footer a {
color: #667eea;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
/* Responsive */
@media only screen and (max-width: 600px) {
.email-container {
margin: 0;
border-radius: 0;
}
.header, .content, .footer {
padding: 20px;
}
.logo {
font-size: 28px;
}
.content h1 {
font-size: 22px;
}
.button {
display: block;
width: 100%;
box-sizing: border-box;
}
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">RentAll</div>
<div class="tagline">Email Verification</div>
</div>
<div class="content">
<p>Hi {{recipientName}},</p>
<h1>Verify Your Email Address</h1>
<p>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.</p>
<div style="text-align: center;">
<a href="{{verificationUrl}}" class="button">Verify Email Address</a>
</div>
<div class="info-box">
<p><strong>Why verify?</strong> Email verification helps us ensure account security and allows you to create listings, make rentals, and process payments.</p>
</div>
<p>If the button doesn't work, you can copy and paste this link into your browser:</p>
<p style="word-break: break-all; color: #667eea;">{{verificationUrl}}</p>
<div class="warning-box">
<p><strong>This link will expire in 24 hours.</strong> If you need a new verification link, you can request one from your account settings.</p>
</div>
<p><strong>Didn't create an account?</strong> If you didn't register for a RentAll account, you can safely ignore this email.</p>
<p>Welcome to the RentAll community!</p>
</div>
<div class="footer">
<p><strong>RentAll</strong></p>
<p>This is a transactional email to verify your account. You received this message because an account was created with this email address.</p>
<p>If you have any questions, please contact our support team.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p>
</div>
</div>
</body>
</html>

View File

@@ -1,4 +1,4 @@
const { authenticateToken } = require('../../../middleware/auth'); const { authenticateToken, requireVerifiedEmail } = require('../../../middleware/auth');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
jest.mock('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'
});
});
});
}); });

View File

@@ -38,11 +38,17 @@ jest.mock('../../../middleware/rateLimiter', () => ({
registerLimiter: (req, res, next) => next(), registerLimiter: (req, res, next) => next(),
})); }));
jest.mock('../../../services/emailService', () => ({
sendVerificationEmail: jest.fn()
}));
const { User } = require('../../../models'); const { User } = require('../../../models');
const emailService = require('../../../services/emailService');
// Set up OAuth2Client mock before requiring authRoutes // Set up OAuth2Client mock before requiring authRoutes
const mockGoogleClient = { const mockGoogleClient = {
verifyIdToken: jest.fn() verifyIdToken: jest.fn(),
getToken: jest.fn()
}; };
OAuth2Client.mockImplementation(() => mockGoogleClient); OAuth2Client.mockImplementation(() => mockGoogleClient);
@@ -90,10 +96,13 @@ describe('Auth Routes', () => {
username: 'testuser', username: 'testuser',
email: 'test@example.com', email: 'test@example.com',
firstName: 'Test', firstName: 'Test',
lastName: 'User' lastName: 'User',
isVerified: false,
generateVerificationToken: jest.fn().mockResolvedValue()
}; };
User.create.mockResolvedValue(newUser); User.create.mockResolvedValue(newUser);
emailService.sendVerificationEmail.mockResolvedValue();
const response = await request(app) const response = await request(app)
.post('/auth/register') .post('/auth/register')
@@ -112,8 +121,12 @@ describe('Auth Routes', () => {
username: 'testuser', username: 'testuser',
email: 'test@example.com', email: 'test@example.com',
firstName: 'Test', 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 // Check that cookies are set
expect(response.headers['set-cookie']).toEqual( expect(response.headers['set-cookie']).toEqual(
@@ -571,8 +584,17 @@ describe('Auth Routes', () => {
process.env.NODE_ENV = 'prod'; process.env.NODE_ENV = 'prod';
User.findOne.mockResolvedValue(null); 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); User.create.mockResolvedValue(newUser);
emailService.sendVerificationEmail.mockResolvedValue();
jwt.sign.mockReturnValueOnce('access').mockReturnValueOnce('refresh'); jwt.sign.mockReturnValueOnce('access').mockReturnValueOnce('refresh');
const response = await request(app) const response = await request(app)
@@ -629,7 +651,17 @@ describe('Auth Routes', () => {
describe('Token management', () => { describe('Token management', () => {
it('should generate both access and refresh tokens on registration', async () => { it('should generate both access and refresh tokens on registration', async () => {
User.findOne.mockResolvedValue(null); 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 jwt.sign
.mockReturnValueOnce('access-token') .mockReturnValueOnce('access-token')
@@ -659,7 +691,17 @@ describe('Auth Routes', () => {
it('should set correct cookie options', async () => { it('should set correct cookie options', async () => {
User.findOne.mockResolvedValue(null); 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'); jwt.sign.mockReturnValueOnce('access').mockReturnValueOnce('refresh');
const response = await request(app) const response = await request(app)
@@ -679,4 +721,304 @@ describe('Auth Routes', () => {
expect(cookies[1]).toContain('SameSite=Strict'); 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();
});
});
});
}); });

View File

@@ -7,6 +7,7 @@ import Home from './pages/Home';
import Login from './pages/Login'; import Login from './pages/Login';
import Register from './pages/Register'; import Register from './pages/Register';
import GoogleCallback from './pages/GoogleCallback'; import GoogleCallback from './pages/GoogleCallback';
import VerifyEmail from './pages/VerifyEmail';
import ItemList from './pages/ItemList'; import ItemList from './pages/ItemList';
import ItemDetail from './pages/ItemDetail'; import ItemDetail from './pages/ItemDetail';
import EditItem from './pages/EditItem'; import EditItem from './pages/EditItem';
@@ -38,6 +39,7 @@ function App() {
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} /> <Route path="/register" element={<Register />} />
<Route path="/auth/google/callback" element={<GoogleCallback />} /> <Route path="/auth/google/callback" element={<GoogleCallback />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/items" element={<ItemList />} /> <Route path="/items" element={<ItemList />} />
<Route path="/items/:id" element={<ItemDetail />} /> <Route path="/items/:id" element={<ItemDetail />} />
<Route path="/users/:id" element={<PublicProfile />} /> <Route path="/users/:id" element={<PublicProfile />} />

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect, useRef, useCallback } from "react"; import React, { useState, useEffect, useRef, useCallback } from "react";
import { useAuth } from "../contexts/AuthContext"; import { useAuth } from "../contexts/AuthContext";
import PasswordStrengthMeter from "./PasswordStrengthMeter"; import PasswordStrengthMeter from "./PasswordStrengthMeter";
import PasswordInput from "./PasswordInput";
interface AuthModalProps { interface AuthModalProps {
show: boolean; show: boolean;
@@ -154,19 +155,18 @@ const AuthModal: React.FC<AuthModalProps> = ({
/> />
</div> </div>
<div className="mb-3"> <PasswordInput
<label className="form-label">Password</label> id="password"
<input label="Password"
type="password" value={password}
className="form-control" onChange={(e) => setPassword(e.target.value)}
value={password} required
onChange={(e) => setPassword(e.target.value)} />
required {mode === "signup" && (
/> <div style={{ marginTop: '-0.75rem', marginBottom: '1rem' }}>
{mode === "signup" && (
<PasswordStrengthMeter password={password} /> <PasswordStrengthMeter password={password} />
)} </div>
</div> )}
<button <button
type="submit" type="submit"

View File

@@ -0,0 +1,58 @@
import React, { useState } from 'react';
interface PasswordInputProps {
id: string;
name?: string;
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
required?: boolean;
placeholder?: string;
className?: string;
label?: string;
}
const PasswordInput: React.FC<PasswordInputProps> = ({
id,
name,
value,
onChange,
required = false,
placeholder,
className = 'form-control',
label
}) => {
const [showPassword, setShowPassword] = useState(false);
return (
<div className="mb-3">
{label && (
<label htmlFor={id} className="form-label">
{label}
</label>
)}
<div className="position-relative">
<input
type={showPassword ? 'text' : 'password'}
className={className}
id={id}
name={name || id}
value={value}
onChange={onChange}
required={required}
placeholder={placeholder}
/>
<button
type="button"
className="btn btn-link position-absolute end-0 top-50 translate-middle-y text-secondary p-0 pe-2"
onClick={() => setShowPassword(!showPassword)}
style={{ zIndex: 10, textDecoration: 'none' }}
tabIndex={-1}
>
<i className={`bi ${showPassword ? 'bi-eye' : 'bi-eye-slash'}`}></i>
</button>
</div>
</div>
);
};
export default PasswordInput;

View File

@@ -1,4 +1,4 @@
import React from 'react'; import React from "react";
interface PasswordStrengthMeterProps { interface PasswordStrengthMeterProps {
password: string; password: string;
@@ -13,60 +13,71 @@ interface PasswordRequirement {
const PasswordStrengthMeter: React.FC<PasswordStrengthMeterProps> = ({ const PasswordStrengthMeter: React.FC<PasswordStrengthMeterProps> = ({
password, password,
showRequirements = true showRequirements = true,
}) => { }) => {
const requirements: PasswordRequirement[] = [ const requirements: PasswordRequirement[] = [
{ {
regex: /.{8,}/, regex: /.{8,}/,
text: "At least 8 characters", text: "At least 8 characters",
met: /.{8,}/.test(password) met: /.{8,}/.test(password),
}, },
{ {
regex: /[a-z]/, regex: /[a-z]/,
text: "One lowercase letter", text: "One lowercase letter",
met: /[a-z]/.test(password) met: /[a-z]/.test(password),
}, },
{ {
regex: /[A-Z]/, regex: /[A-Z]/,
text: "One uppercase letter", text: "One uppercase letter",
met: /[A-Z]/.test(password) met: /[A-Z]/.test(password),
}, },
{ {
regex: /\d/, regex: /\d/,
text: "One number", text: "One number",
met: /\d/.test(password) met: /\d/.test(password),
}, },
{ {
regex: /[@$!%*?&]/, regex: /[-@$!%*?&#^]/,
text: "One special character (@$!%*?&)", text: "One special character (-@$!%*?&#^)",
met: /[@$!%*?&]/.test(password) met: /[-@$!%*?&#^]/.test(password),
} },
]; ];
const getPasswordStrength = (): { score: number; label: string; color: string } => { const getPasswordStrength = (): {
if (!password) return { score: 0, label: '', color: '' }; 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) { if (hasCommonPassword) {
return { score: 0, label: 'Too Common', color: 'danger' }; return { score: 0, label: "Too Common", color: "danger" };
} }
switch (metRequirements) { switch (metRequirements) {
case 0: case 0:
case 1: case 1:
return { score: 1, label: 'Very Weak', color: 'danger' }; return { score: 1, label: "Very Weak", color: "danger" };
case 2: case 2:
return { score: 2, label: 'Weak', color: 'warning' }; return { score: 2, label: "Weak", color: "warning" };
case 3: case 3:
return { score: 3, label: 'Fair', color: 'info' }; return { score: 3, label: "Fair", color: "info" };
case 4: case 4:
return { score: 4, label: 'Good', color: 'primary' }; return { score: 4, label: "Good", color: "primary" };
case 5: case 5:
return { score: 5, label: 'Strong', color: 'success' }; return { score: 5, label: "Strong", color: "success" };
default: default:
return { score: 0, label: '', color: '' }; return { score: 0, label: "", color: "" };
} }
}; };
@@ -87,7 +98,7 @@ const PasswordStrengthMeter: React.FC<PasswordStrengthMeterProps> = ({
</small> </small>
)} )}
</div> </div>
<div className="progress" style={{ height: '4px' }}> <div className="progress" style={{ height: "4px" }}>
<div <div
className={`progress-bar bg-${strength.color}`} className={`progress-bar bg-${strength.color}`}
role="progressbar" role="progressbar"
@@ -102,17 +113,23 @@ const PasswordStrengthMeter: React.FC<PasswordStrengthMeterProps> = ({
{/* Requirements List */} {/* Requirements List */}
{showRequirements && ( {showRequirements && (
<div className="password-requirements"> <div className="password-requirements">
<small className="text-muted d-block mb-1">Password must contain:</small> <small className="text-muted d-block mb-1">
<ul className="list-unstyled mb-0" style={{ fontSize: '0.75rem' }}> Password must contain:
</small>
<ul className="list-unstyled mb-0" style={{ fontSize: "0.75rem" }}>
{requirements.map((requirement, index) => ( {requirements.map((requirement, index) => (
<li key={index} className="d-flex align-items-center mb-1"> <li key={index} className="d-flex align-items-center mb-1">
<i <i
className={`bi ${ className={`bi ${
requirement.met ? 'bi-check-circle-fill text-success' : 'bi-circle text-muted' requirement.met
? "bi-check-circle-fill text-success"
: "bi-circle text-muted"
} me-2`} } me-2`}
style={{ fontSize: '0.75rem' }} style={{ fontSize: "0.75rem" }}
/> />
<span className={requirement.met ? 'text-success' : 'text-muted'}> <span
className={requirement.met ? "text-success" : "text-muted"}
>
{requirement.text} {requirement.text}
</span> </span>
</li> </li>
@@ -124,4 +141,4 @@ const PasswordStrengthMeter: React.FC<PasswordStrengthMeterProps> = ({
); );
}; };
export default PasswordStrengthMeter; export default PasswordStrengthMeter;

View File

@@ -1,6 +1,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom'; import { Link, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import PasswordInput from '../components/PasswordInput';
const Login: React.FC = () => { const Login: React.FC = () => {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
@@ -54,19 +55,13 @@ const Login: React.FC = () => {
required required
/> />
</div> </div>
<div className="mb-3"> <PasswordInput
<label htmlFor="password" className="form-label"> id="password"
Password label="Password"
</label> value={password}
<input onChange={(e) => setPassword(e.target.value)}
type="password" required
className="form-control" />
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<button <button
type="submit" type="submit"
className="btn btn-primary w-100" className="btn btn-primary w-100"

View File

@@ -1,6 +1,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom'; import { Link, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import PasswordInput from '../components/PasswordInput';
const Register: React.FC = () => { const Register: React.FC = () => {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
@@ -125,20 +126,14 @@ const Register: React.FC = () => {
onChange={handleChange} onChange={handleChange}
/> />
</div> </div>
<div className="mb-3"> <PasswordInput
<label htmlFor="password" className="form-label"> id="password"
Password name="password"
</label> label="Password"
<input value={formData.password}
type="password" onChange={handleChange}
className="form-control" required
id="password" />
name="password"
value={formData.password}
onChange={handleChange}
required
/>
</div>
<button <button
type="submit" type="submit"
className="btn btn-primary w-100" className="btn btn-primary w-100"

View File

@@ -0,0 +1,145 @@
import React, { useEffect, useState, useRef } from 'react';
import { useNavigate, useSearchParams, Link } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { authAPI } from '../services/api';
const VerifyEmail: React.FC = () => {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { checkAuth, user } = useAuth();
const [error, setError] = useState<string>('');
const [success, setSuccess] = useState(false);
const [processing, setProcessing] = useState(true);
const [resending, setResending] = useState(false);
const hasProcessed = useRef(false);
useEffect(() => {
const handleVerification = async () => {
// Prevent double execution in React StrictMode
if (hasProcessed.current) {
return;
}
hasProcessed.current = true;
try {
const token = searchParams.get('token');
if (!token) {
setError('No verification token provided.');
setProcessing(false);
return;
}
// Verify the email with the token
await authAPI.verifyEmail(token);
setSuccess(true);
setProcessing(false);
// Refresh user data to update isVerified status
await checkAuth();
// Redirect to home after 3 seconds
setTimeout(() => {
navigate('/', { replace: true });
}, 3000);
} catch (err: any) {
console.error('Email verification error:', err);
const errorData = err.response?.data;
if (errorData?.code === 'VERIFICATION_TOKEN_EXPIRED') {
setError('Your verification link has expired. Please request a new one.');
} else if (errorData?.code === 'VERIFICATION_TOKEN_INVALID') {
setError('Invalid verification link. The link may have already been used or is incorrect.');
} else if (errorData?.code === 'ALREADY_VERIFIED') {
setError('Your email is already verified.');
} else {
setError(errorData?.error || 'Failed to verify email. Please try again.');
}
setProcessing(false);
}
};
handleVerification();
}, [searchParams, navigate, checkAuth]);
const handleResendVerification = async () => {
setResending(true);
setError('');
try {
await authAPI.resendVerification();
setError('');
alert('Verification email sent! Please check your inbox.');
} catch (err: any) {
console.error('Resend verification error:', err);
const errorData = err.response?.data;
if (errorData?.code === 'ALREADY_VERIFIED') {
setError('Your email is already verified.');
} else if (errorData?.code === 'NO_TOKEN') {
setError('You must be logged in to resend the verification email.');
} else {
setError(errorData?.error || 'Failed to resend verification email. Please try again.');
}
} finally {
setResending(false);
}
};
return (
<div className="container">
<div className="row justify-content-center mt-5">
<div className="col-md-6">
<div className="card">
<div className="card-body text-center py-5">
{processing ? (
<>
<div className="spinner-border text-primary mb-3" role="status">
<span className="visually-hidden">Loading...</span>
</div>
<h5>Verifying Your Email...</h5>
<p className="text-muted">Please wait while we verify your email address.</p>
</>
) : success ? (
<>
<i className="bi bi-check-circle text-success" style={{ fontSize: '3rem' }}></i>
<h5 className="mt-3">Email Verified Successfully!</h5>
<p className="text-muted">
Your email has been verified. You will be redirected to the home page shortly.
</p>
<Link to="/" className="btn btn-primary mt-3">
Go to Home
</Link>
</>
) : error ? (
<>
<i className="bi bi-exclamation-circle text-danger" style={{ fontSize: '3rem' }}></i>
<h5 className="mt-3">Verification Failed</h5>
<p className="text-danger">{error}</p>
<div className="mt-3">
{user && !error.includes('already verified') && (
<button
className="btn btn-primary me-2"
onClick={handleResendVerification}
disabled={resending}
>
{resending ? 'Sending...' : 'Resend Verification Email'}
</button>
)}
<Link to="/" className="btn btn-outline-secondary">
Return to Home
</Link>
</div>
</>
) : null}
</div>
</div>
</div>
</div>
</div>
);
};
export default VerifyEmail;

View File

@@ -166,6 +166,8 @@ export const authAPI = {
refresh: () => api.post("/auth/refresh"), refresh: () => api.post("/auth/refresh"),
getCSRFToken: () => api.get("/auth/csrf-token"), getCSRFToken: () => api.get("/auth/csrf-token"),
getStatus: () => api.get("/auth/status"), getStatus: () => api.get("/auth/status"),
verifyEmail: (token: string) => api.post("/auth/verify-email", { token }),
resendVerification: () => api.post("/auth/resend-verification"),
}; };
export const userAPI = { export const userAPI = {