diff --git a/backend/config/database.js b/backend/config/database.js
index 1056288..ece1e33 100644
--- a/backend/config/database.js
+++ b/backend/config/database.js
@@ -34,7 +34,6 @@ const dbConfig = {
// Configuration for Sequelize CLI (supports multiple environments)
// All environments use the same configuration from environment variables
const cliConfig = {
- development: dbConfig,
dev: dbConfig,
test: dbConfig,
qa: dbConfig,
diff --git a/backend/middleware/rateLimiter.js b/backend/middleware/rateLimiter.js
index a4ba3eb..c06ffaf 100644
--- a/backend/middleware/rateLimiter.js
+++ b/backend/middleware/rateLimiter.js
@@ -162,6 +162,18 @@ const authRateLimiters = {
legacyHeaders: false,
}),
+ // Email verification rate limiter - protect against brute force on 6-digit codes
+ emailVerification: rateLimit({
+ windowMs: 15 * 60 * 1000, // 15 minutes
+ max: 10, // 10 verification attempts per 15 minutes per IP
+ message: {
+ error: "Too many verification attempts. Please try again later.",
+ retryAfter: 900,
+ },
+ standardHeaders: true,
+ legacyHeaders: false,
+ }),
+
// General API rate limiter
general: rateLimit({
windowMs: 60 * 1000, // 1 minute
@@ -186,6 +198,7 @@ module.exports = {
registerLimiter: authRateLimiters.register,
passwordResetLimiter: authRateLimiters.passwordReset,
alphaCodeValidationLimiter: authRateLimiters.alphaCodeValidation,
+ emailVerificationLimiter: authRateLimiters.emailVerification,
generalLimiter: authRateLimiters.general,
// Burst protection
diff --git a/backend/migrations/20251215175806-add-verification-attempts.js b/backend/migrations/20251215175806-add-verification-attempts.js
new file mode 100644
index 0000000..a40b5fb
--- /dev/null
+++ b/backend/migrations/20251215175806-add-verification-attempts.js
@@ -0,0 +1,15 @@
+"use strict";
+
+module.exports = {
+ up: async (queryInterface, Sequelize) => {
+ await queryInterface.addColumn("Users", "verificationAttempts", {
+ type: Sequelize.INTEGER,
+ defaultValue: 0,
+ allowNull: true,
+ });
+ },
+
+ down: async (queryInterface, Sequelize) => {
+ await queryInterface.removeColumn("Users", "verificationAttempts");
+ },
+};
diff --git a/backend/models/User.js b/backend/models/User.js
index e2e7895..bb62342 100644
--- a/backend/models/User.js
+++ b/backend/models/User.js
@@ -146,6 +146,11 @@ const User = sequelize.define(
max: 100,
},
},
+ verificationAttempts: {
+ type: DataTypes.INTEGER,
+ defaultValue: 0,
+ allowNull: true,
+ },
},
{
hooks: {
@@ -208,31 +213,64 @@ User.prototype.resetLoginAttempts = async function () {
};
// Email verification methods
+// Maximum verification attempts before requiring a new code
+const MAX_VERIFICATION_ATTEMPTS = 5;
+
User.prototype.generateVerificationToken = async function () {
const crypto = require("crypto");
- const token = crypto.randomBytes(32).toString("hex");
+ // Generate 6-digit numeric code (100000-999999)
+ const code = crypto.randomInt(100000, 999999).toString();
const expiry = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
return this.update({
- verificationToken: token,
+ verificationToken: code,
verificationTokenExpiry: expiry,
+ verificationAttempts: 0, // Reset attempts on new code
});
};
User.prototype.isVerificationTokenValid = function (token) {
+ const crypto = require("crypto");
+
if (!this.verificationToken || !this.verificationTokenExpiry) {
return false;
}
- if (this.verificationToken !== token) {
- return false;
- }
-
+ // Check if token is expired
if (new Date() > new Date(this.verificationTokenExpiry)) {
return false;
}
- return true;
+ // Validate 6-digit format
+ if (!/^\d{6}$/.test(token)) {
+ return false;
+ }
+
+ // Use timing-safe comparison to prevent timing attacks
+ try {
+ const inputBuffer = Buffer.from(token);
+ const storedBuffer = Buffer.from(this.verificationToken);
+
+ if (inputBuffer.length !== storedBuffer.length) {
+ return false;
+ }
+
+ return crypto.timingSafeEqual(inputBuffer, storedBuffer);
+ } catch {
+ return false;
+ }
+};
+
+// Check if too many verification attempts
+User.prototype.isVerificationLocked = function () {
+ return (this.verificationAttempts || 0) >= MAX_VERIFICATION_ATTEMPTS;
+};
+
+// Increment verification attempts
+User.prototype.incrementVerificationAttempts = async function () {
+ const newAttempts = (this.verificationAttempts || 0) + 1;
+ await this.update({ verificationAttempts: newAttempts });
+ return newAttempts;
};
User.prototype.verifyEmail = async function () {
@@ -241,6 +279,7 @@ User.prototype.verifyEmail = async function () {
verifiedAt: new Date(),
verificationToken: null,
verificationTokenExpiry: null,
+ verificationAttempts: 0,
});
};
diff --git a/backend/routes/auth.js b/backend/routes/auth.js
index 67ff12a..dcc42f9 100644
--- a/backend/routes/auth.js
+++ b/backend/routes/auth.js
@@ -20,7 +20,9 @@ const {
loginLimiter,
registerLimiter,
passwordResetLimiter,
+ emailVerificationLimiter,
} = require("../middleware/rateLimiter");
+const { authenticateToken } = require("../middleware/auth");
const router = express.Router();
const googleClient = new OAuth2Client(
@@ -43,8 +45,7 @@ router.post(
validateRegistration,
async (req, res) => {
try {
- const { email, password, firstName, lastName, phone } =
- req.body;
+ const { email, password, firstName, lastName, phone } = req.body;
const existingUser = await User.findOne({
where: { email },
@@ -64,7 +65,7 @@ router.post(
// Alpha access validation
let alphaInvitation = null;
- if (process.env.ALPHA_TESTING_ENABLED === 'true') {
+ if (process.env.ALPHA_TESTING_ENABLED === "true") {
if (req.cookies && req.cookies.alphaAccessCode) {
const { code } = req.cookies.alphaAccessCode;
if (code) {
@@ -88,7 +89,8 @@ router.post(
if (!alphaInvitation) {
return res.status(403).json({
- error: "Alpha access required. Please enter your invitation code first.",
+ error:
+ "Alpha access required. Please enter your invitation code first.",
});
}
}
@@ -116,7 +118,10 @@ router.post(
// Send verification email (don't block registration if email fails)
let verificationEmailSent = false;
try {
- await emailServices.auth.sendVerificationEmail(user, user.verificationToken);
+ await emailServices.auth.sendVerificationEmail(
+ user,
+ user.verificationToken
+ );
verificationEmailSent = true;
} catch (emailError) {
const reqLogger = logger.withRequestId(req.id);
@@ -200,7 +205,10 @@ router.post(
const user = await User.findOne({ where: { email } });
if (!user) {
- return res.status(401).json({ error: "Invalid credentials" });
+ return res.status(401).json({
+ error:
+ "Unable to log in. Please check your email and password, or create an account.",
+ });
}
// Check if account is locked
@@ -217,7 +225,10 @@ router.post(
if (!isPasswordValid) {
// Increment login attempts
await user.incLoginAttempts();
- return res.status(401).json({ error: "Invalid credentials" });
+ return res.status(401).json({
+ error:
+ "Unable to log in. Please check your email and password, or create an account.",
+ });
}
// Reset login attempts on successful login
@@ -322,7 +333,8 @@ router.post(
if (!email) {
return res.status(400).json({
- error: "Email permission is required to continue. Please grant email access when signing in with Google and try again."
+ error:
+ "Email permission is required to continue. Please grant email access when signing in with Google and try again.",
});
}
@@ -332,18 +344,22 @@ router.post(
let lastName = familyName;
if (!firstName || !lastName) {
- const emailUsername = email.split('@')[0];
+ const emailUsername = email.split("@")[0];
// Try to split email username by common separators
const nameParts = emailUsername.split(/[._-]/);
if (!firstName) {
- firstName = nameParts[0] ? nameParts[0].charAt(0).toUpperCase() + nameParts[0].slice(1) : 'Google';
+ firstName = nameParts[0]
+ ? nameParts[0].charAt(0).toUpperCase() + nameParts[0].slice(1)
+ : "Google";
}
if (!lastName) {
- lastName = nameParts.length > 1
- ? nameParts[nameParts.length - 1].charAt(0).toUpperCase() + nameParts[nameParts.length - 1].slice(1)
- : 'User';
+ lastName =
+ nameParts.length > 1
+ ? nameParts[nameParts.length - 1].charAt(0).toUpperCase() +
+ nameParts[nameParts.length - 1].slice(1)
+ : "User";
}
}
@@ -375,7 +391,7 @@ router.post(
});
// Check if there's an alpha invitation for this email
- if (process.env.ALPHA_TESTING_ENABLED === 'true') {
+ if (process.env.ALPHA_TESTING_ENABLED === "true") {
const alphaInvitation = await AlphaInvitation.findOne({
where: { email: email.toLowerCase().trim() },
});
@@ -467,73 +483,121 @@ router.post(
);
// Email verification endpoint
-router.post("/verify-email", sanitizeInput, async (req, res) => {
- try {
- const { token } = req.body;
+router.post(
+ "/verify-email",
+ emailVerificationLimiter,
+ authenticateToken,
+ sanitizeInput,
+ async (req, res) => {
+ try {
+ const { code } = req.body;
- if (!token) {
- return res.status(400).json({
- error: "Verification token required",
- code: "TOKEN_REQUIRED",
- });
- }
+ if (!code) {
+ return res.status(400).json({
+ error: "Verification code required",
+ code: "CODE_REQUIRED",
+ });
+ }
- // Find user with this verification token
- const user = await User.findOne({
- where: { verificationToken: token },
- });
+ // Validate 6-digit format
+ if (!/^\d{6}$/.test(code)) {
+ return res.status(400).json({
+ error: "Verification code must be 6 digits",
+ code: "INVALID_CODE_FORMAT",
+ });
+ }
- if (!user) {
- return res.status(400).json({
- error: "Invalid verification token",
- code: "VERIFICATION_TOKEN_INVALID",
- });
- }
+ // Get the authenticated user
+ const user = await User.findByPk(req.user.id);
- // Check if already verified
- if (user.isVerified) {
- return res.status(400).json({
- error: "Email already verified",
- code: "ALREADY_VERIFIED",
- });
- }
+ if (!user) {
+ return res.status(404).json({
+ error: "User not found",
+ code: "USER_NOT_FOUND",
+ });
+ }
- // 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",
- });
- }
+ // Check if already verified
+ if (user.isVerified) {
+ return res.status(400).json({
+ error: "Email already verified",
+ code: "ALREADY_VERIFIED",
+ });
+ }
- // Verify the email
- await user.verifyEmail();
+ // Check if too many failed attempts
+ if (user.isVerificationLocked()) {
+ return res.status(429).json({
+ error: "Too many verification attempts. Please request a new code.",
+ code: "TOO_MANY_ATTEMPTS",
+ });
+ }
- const reqLogger = logger.withRequestId(req.id);
- reqLogger.info("Email verified successfully", {
- userId: user.id,
- email: user.email,
- });
+ // Check if user has a verification token
+ if (!user.verificationToken) {
+ return res.status(400).json({
+ error: "No verification code found. Please request a new one.",
+ code: "NO_CODE",
+ });
+ }
- res.json({
- message: "Email verified successfully",
- user: {
- id: user.id,
+ // Check if code is expired
+ if (
+ user.verificationTokenExpiry &&
+ new Date() > new Date(user.verificationTokenExpiry)
+ ) {
+ return res.status(400).json({
+ error: "Verification code has expired. Please request a new one.",
+ code: "VERIFICATION_EXPIRED",
+ });
+ }
+
+ // Validate the code
+ if (!user.isVerificationTokenValid(input)) {
+ // Increment failed attempts
+ await user.incrementVerificationAttempts();
+
+ const reqLogger = logger.withRequestId(req.id);
+ reqLogger.warn("Invalid verification code attempt", {
+ userId: user.id,
+ attempts: user.verificationAttempts + 1,
+ });
+
+ return res.status(400).json({
+ error: "Invalid verification code",
+ code: "VERIFICATION_INVALID",
+ });
+ }
+
+ // Verify the email
+ await user.verifyEmail();
+
+ const reqLogger = logger.withRequestId(req.id);
+ reqLogger.info("Email verified successfully", {
+ userId: 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.",
- });
+ });
+
+ 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(
@@ -575,7 +639,10 @@ router.post(
// Send verification email
try {
- await emailServices.auth.sendVerificationEmail(user, user.verificationToken);
+ await emailServices.auth.sendVerificationEmail(
+ user,
+ user.verificationToken
+ );
} catch (emailError) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Failed to resend verification email", {
diff --git a/backend/routes/forum.js b/backend/routes/forum.js
index 4f376a2..a758f6b 100644
--- a/backend/routes/forum.js
+++ b/backend/routes/forum.js
@@ -241,6 +241,14 @@ router.get('/posts/:id', optionalAuth, async (req, res, next) => {
// POST /api/forum/posts - Create new post
router.post('/posts', authenticateToken, async (req, res, next) => {
try {
+ // Require email verification
+ if (!req.user.isVerified) {
+ return res.status(403).json({
+ error: "Please verify your email address before creating forum posts.",
+ code: "EMAIL_NOT_VERIFIED"
+ });
+ }
+
let { title, content, category, tags, zipCode, latitude: providedLat, longitude: providedLng, imageFilenames: rawImageFilenames } = req.body;
// Ensure imageFilenames is an array and validate S3 keys
@@ -490,6 +498,14 @@ router.post('/posts', authenticateToken, async (req, res, next) => {
// PUT /api/forum/posts/:id - Update post
router.put('/posts/:id', authenticateToken, async (req, res, next) => {
try {
+ // Require email verification
+ if (!req.user.isVerified) {
+ return res.status(403).json({
+ error: "Please verify your email address before editing forum posts.",
+ code: "EMAIL_NOT_VERIFIED"
+ });
+ }
+
const post = await ForumPost.findByPk(req.params.id);
if (!post) {
@@ -934,6 +950,14 @@ router.patch('/posts/:id/accept-answer', authenticateToken, async (req, res, nex
// POST /api/forum/posts/:id/comments - Add comment/reply
router.post('/posts/:id/comments', authenticateToken, async (req, res, next) => {
try {
+ // Require email verification
+ if (!req.user.isVerified) {
+ return res.status(403).json({
+ error: "Please verify your email address before commenting.",
+ code: "EMAIL_NOT_VERIFIED"
+ });
+ }
+
// Support both parentId (new) and parentCommentId (legacy) for backwards compatibility
const { content, parentId, parentCommentId, imageFilenames: rawImageFilenames } = req.body;
const parentIdResolved = parentId || parentCommentId;
@@ -1111,6 +1135,14 @@ router.post('/posts/:id/comments', authenticateToken, async (req, res, next) =>
// PUT /api/forum/comments/:id - Edit comment
router.put('/comments/:id', authenticateToken, async (req, res, next) => {
try {
+ // Require email verification
+ if (!req.user.isVerified) {
+ return res.status(403).json({
+ error: "Please verify your email address before editing comments.",
+ code: "EMAIL_NOT_VERIFIED"
+ });
+ }
+
const { content, imageFilenames: rawImageFilenames } = req.body;
const comment = await ForumComment.findByPk(req.params.id);
diff --git a/backend/routes/rentals.js b/backend/routes/rentals.js
index 5b64883..120bac6 100644
--- a/backend/routes/rentals.js
+++ b/backend/routes/rentals.js
@@ -1,7 +1,10 @@
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, requireVerifiedEmail } = require("../middleware/auth");
+const {
+ authenticateToken,
+ requireVerifiedEmail,
+} = require("../middleware/auth");
const FeeCalculator = require("../utils/feeCalculator");
const RentalDurationCalculator = require("../utils/rentalDurationCalculator");
const RefundService = require("../services/refundService");
@@ -304,7 +307,11 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => {
// Send rental request notification to owner
try {
- await emailServices.rentalFlow.sendRentalRequestEmail(rentalWithDetails.owner, rentalWithDetails.renter, rentalWithDetails);
+ await emailServices.rentalFlow.sendRentalRequestEmail(
+ rentalWithDetails.owner,
+ rentalWithDetails.renter,
+ rentalWithDetails
+ );
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Rental request notification sent to owner", {
rentalId: rental.id,
@@ -322,7 +329,10 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => {
// Send rental request confirmation to renter
try {
- await emailServices.rentalFlow.sendRentalRequestConfirmationEmail(rentalWithDetails.renter, rentalWithDetails);
+ await emailServices.rentalFlow.sendRentalRequestConfirmationEmail(
+ rentalWithDetails.renter,
+ rentalWithDetails
+ );
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Rental request confirmation sent to renter", {
rentalId: rental.id,
@@ -358,12 +368,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
{
model: User,
as: "renter",
- attributes: [
- "id",
- "firstName",
- "lastName",
- "stripeCustomerId",
- ],
+ attributes: ["id", "firstName", "lastName", "stripeCustomerId"],
},
],
});
@@ -445,7 +450,11 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
// Send confirmation emails
// Send approval confirmation to owner with Stripe reminder
try {
- await emailServices.rentalFlow.sendRentalApprovalConfirmationEmail(updatedRental.owner, updatedRental.renter, updatedRental);
+ await emailServices.rentalFlow.sendRentalApprovalConfirmationEmail(
+ updatedRental.owner,
+ updatedRental.renter,
+ updatedRental
+ );
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Rental approval confirmation sent to owner", {
rentalId: updatedRental.id,
@@ -453,11 +462,14 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
});
} catch (emailError) {
const reqLogger = logger.withRequestId(req.id);
- reqLogger.error("Failed to send rental approval confirmation email to owner", {
- error: emailError.message,
- rentalId: updatedRental.id,
- ownerId: updatedRental.ownerId,
- });
+ reqLogger.error(
+ "Failed to send rental approval confirmation email to owner",
+ {
+ error: emailError.message,
+ rentalId: updatedRental.id,
+ ownerId: updatedRental.ownerId,
+ }
+ );
}
// Send rental confirmation to renter with payment receipt
@@ -489,11 +501,14 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
}
} catch (emailError) {
const reqLogger = logger.withRequestId(req.id);
- reqLogger.error("Failed to send rental confirmation email to renter", {
- error: emailError.message,
- rentalId: updatedRental.id,
- renterId: updatedRental.renterId,
- });
+ reqLogger.error(
+ "Failed to send rental confirmation email to renter",
+ {
+ error: emailError.message,
+ rentalId: updatedRental.id,
+ renterId: updatedRental.renterId,
+ }
+ );
}
res.json(updatedRental);
@@ -537,7 +552,11 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
// Send confirmation emails
// Send approval confirmation to owner (for free rentals, no Stripe reminder shown)
try {
- await emailServices.rentalFlow.sendRentalApprovalConfirmationEmail(updatedRental.owner, updatedRental.renter, updatedRental);
+ await emailServices.rentalFlow.sendRentalApprovalConfirmationEmail(
+ updatedRental.owner,
+ updatedRental.renter,
+ updatedRental
+ );
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Rental approval confirmation sent to owner", {
rentalId: updatedRental.id,
@@ -545,11 +564,14 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
});
} catch (emailError) {
const reqLogger = logger.withRequestId(req.id);
- reqLogger.error("Failed to send rental approval confirmation email to owner", {
- error: emailError.message,
- rentalId: updatedRental.id,
- ownerId: updatedRental.ownerId,
- });
+ reqLogger.error(
+ "Failed to send rental approval confirmation email to owner",
+ {
+ error: emailError.message,
+ rentalId: updatedRental.id,
+ ownerId: updatedRental.ownerId,
+ }
+ );
}
// Send rental confirmation to renter
@@ -581,11 +603,14 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
}
} catch (emailError) {
const reqLogger = logger.withRequestId(req.id);
- reqLogger.error("Failed to send rental confirmation email to renter", {
- error: emailError.message,
- rentalId: updatedRental.id,
- renterId: updatedRental.renterId,
- });
+ reqLogger.error(
+ "Failed to send rental confirmation email to renter",
+ {
+ error: emailError.message,
+ rentalId: updatedRental.id,
+ renterId: updatedRental.renterId,
+ }
+ );
}
res.json(updatedRental);
@@ -687,7 +712,11 @@ router.put("/:id/decline", authenticateToken, async (req, res) => {
// Send decline notification email to renter
try {
- await emailServices.rentalFlow.sendRentalDeclinedEmail(updatedRental.renter, updatedRental, reason);
+ await emailServices.rentalFlow.sendRentalDeclinedEmail(
+ updatedRental.renter,
+ updatedRental,
+ reason
+ );
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Rental decline notification sent to renter", {
rentalId: rental.id,
@@ -904,7 +933,7 @@ router.post("/cost-preview", authenticateToken, async (req, res) => {
// Validate date range
if (rentalEndDateTime <= rentalStartDateTime) {
return res.status(400).json({
- error: "End date/time must be after start date/time",
+ error: "End must be after start",
});
}
@@ -987,44 +1016,50 @@ router.get("/:id/refund-preview", authenticateToken, async (req, res, next) => {
});
// Get late fee preview
-router.get("/:id/late-fee-preview", authenticateToken, async (req, res, next) => {
- try {
- const { actualReturnDateTime } = req.query;
+router.get(
+ "/:id/late-fee-preview",
+ authenticateToken,
+ async (req, res, next) => {
+ try {
+ const { actualReturnDateTime } = req.query;
- if (!actualReturnDateTime) {
- return res.status(400).json({ error: "actualReturnDateTime is required" });
+ if (!actualReturnDateTime) {
+ return res
+ .status(400)
+ .json({ error: "actualReturnDateTime is required" });
+ }
+
+ const rental = await Rental.findByPk(req.params.id, {
+ include: [{ model: Item, as: "item" }],
+ });
+
+ if (!rental) {
+ return res.status(404).json({ error: "Rental not found" });
+ }
+
+ // Check authorization
+ if (rental.ownerId !== req.user.id && rental.renterId !== req.user.id) {
+ return res.status(403).json({ error: "Unauthorized" });
+ }
+
+ const lateCalculation = LateReturnService.calculateLateFee(
+ rental,
+ actualReturnDateTime
+ );
+
+ res.json(lateCalculation);
+ } catch (error) {
+ const reqLogger = logger.withRequestId(req.id);
+ reqLogger.error("Error getting late fee preview", {
+ error: error.message,
+ stack: error.stack,
+ rentalId: req.params.id,
+ userId: req.user.id,
+ });
+ next(error);
}
-
- const rental = await Rental.findByPk(req.params.id, {
- include: [{ model: Item, as: "item" }],
- });
-
- if (!rental) {
- return res.status(404).json({ error: "Rental not found" });
- }
-
- // Check authorization
- if (rental.ownerId !== req.user.id && rental.renterId !== req.user.id) {
- return res.status(403).json({ error: "Unauthorized" });
- }
-
- const lateCalculation = LateReturnService.calculateLateFee(
- rental,
- actualReturnDateTime
- );
-
- res.json(lateCalculation);
- } catch (error) {
- const reqLogger = logger.withRequestId(req.id);
- reqLogger.error("Error getting late fee preview", {
- error: error.message,
- stack: error.stack,
- rentalId: req.params.id,
- userId: req.user.id,
- });
- next(error);
}
-});
+);
// Cancel rental with refund processing
router.post("/:id/cancel", authenticateToken, async (req, res, next) => {
@@ -1156,7 +1191,11 @@ router.post("/:id/mark-return", authenticateToken, async (req, res, next) => {
// Send completion emails to both renter and owner
try {
- await emailServices.rentalFlow.sendRentalCompletionEmails(rentalWithDetails.owner, rentalWithDetails.renter, rentalWithDetails);
+ await emailServices.rentalFlow.sendRentalCompletionEmails(
+ rentalWithDetails.owner,
+ rentalWithDetails.renter,
+ rentalWithDetails
+ );
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Rental completion emails sent", {
rentalId,
@@ -1224,7 +1263,11 @@ router.post("/:id/mark-return", authenticateToken, async (req, res, next) => {
// Send notification to customer service
const owner = await User.findByPk(rental.ownerId);
const renter = await User.findByPk(rental.renterId);
- await emailServices.customerService.sendLostItemToCustomerService(updatedRental, owner, renter);
+ await emailServices.customerService.sendLostItemToCustomerService(
+ updatedRental,
+ owner,
+ renter
+ );
break;
default:
@@ -1261,14 +1304,14 @@ router.post("/:id/mark-return", authenticateToken, async (req, res, next) => {
// Allowed fields for damage report (prevents mass assignment)
const ALLOWED_DAMAGE_REPORT_FIELDS = [
- 'description',
- 'canBeFixed',
- 'repairCost',
- 'needsReplacement',
- 'replacementCost',
- 'proofOfOwnership',
- 'actualReturnDateTime',
- 'imageFilenames',
+ "description",
+ "canBeFixed",
+ "repairCost",
+ "needsReplacement",
+ "replacementCost",
+ "proofOfOwnership",
+ "actualReturnDateTime",
+ "imageFilenames",
];
function extractAllowedDamageFields(body) {
@@ -1295,9 +1338,13 @@ router.post("/:id/report-damage", authenticateToken, async (req, res, next) => {
? damageInfo.imageFilenames
: [];
- const keyValidation = validateS3Keys(imageFilenamesArray, 'damage-reports', {
- maxKeys: IMAGE_LIMITS.damageReports,
- });
+ const keyValidation = validateS3Keys(
+ imageFilenamesArray,
+ "damage-reports",
+ {
+ maxKeys: IMAGE_LIMITS.damageReports,
+ }
+ );
if (!keyValidation.valid) {
return res.status(400).json({
error: keyValidation.error,
diff --git a/backend/services/email/domain/AuthEmailService.js b/backend/services/email/domain/AuthEmailService.js
index d358b37..6decd18 100644
--- a/backend/services/email/domain/AuthEmailService.js
+++ b/backend/services/email/domain/AuthEmailService.js
@@ -50,6 +50,7 @@ class AuthEmailService {
const variables = {
recipientName: user.firstName || "there",
verificationUrl: verificationUrl,
+ verificationCode: verificationToken, // 6-digit code for display in email
};
const htmlContent = await this.templateManager.renderTemplate(
diff --git a/backend/templates/emails/emailVerificationToUser.html b/backend/templates/emails/emailVerificationToUser.html
index 797d09b..03304a7 100644
--- a/backend/templates/emails/emailVerificationToUser.html
+++ b/backend/templates/emails/emailVerificationToUser.html
@@ -196,9 +196,29 @@
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.
+ Thank you for registering with RentAll! Use the verification code below to complete your account setup.
-
+
+
+
Your verification code is:
+
+ {{verificationCode}}
+
+
+ Enter this code in the app to verify your email
+
+
+
+
@@ -210,7 +230,7 @@
{{verificationUrl}}
-
This link will expire in 24 hours. If you need a new verification link, you can request one from your account settings.
+
This code will expire in 24 hours. If you need a new verification code, 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.
diff --git a/frontend/src/components/AuthModal.tsx b/frontend/src/components/AuthModal.tsx
index 414e484..b590311 100644
--- a/frontend/src/components/AuthModal.tsx
+++ b/frontend/src/components/AuthModal.tsx
@@ -3,6 +3,7 @@ import { useAuth } from "../contexts/AuthContext";
import PasswordStrengthMeter from "./PasswordStrengthMeter";
import PasswordInput from "./PasswordInput";
import ForgotPasswordModal from "./ForgotPasswordModal";
+import VerificationCodeModal from "./VerificationCodeModal";
interface AuthModalProps {
show: boolean;
@@ -23,6 +24,7 @@ const AuthModal: React.FC
= ({
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [showForgotPassword, setShowForgotPassword] = useState(false);
+ const [showVerificationModal, setShowVerificationModal] = useState(false);
const { login, register } = useAuth();
@@ -39,6 +41,7 @@ const AuthModal: React.FC = ({
setPassword("");
setFirstName("");
setLastName("");
+ setShowVerificationModal(false);
};
const handleGoogleLogin = () => {
@@ -68,28 +71,48 @@ const AuthModal: React.FC = ({
await login(email, password);
onHide();
} else {
- await register({
+ const response = await register({
email,
password,
firstName,
lastName,
username: email.split("@")[0], // Generate username from email
});
- onHide();
+ // Show verification modal after successful registration
+ setShowVerificationModal(true);
+ // Don't call onHide() - keep modal context for verification
}
} catch (err: any) {
- setError(err.response?.data?.message || "An error occurred");
+ setError(err.response?.data?.error || "An error occurred");
} finally {
setLoading(false);
}
};
- if (!show && !showForgotPassword) return null;
+ if (!show && !showForgotPassword && !showVerificationModal) return null;
return (
<>
- {!showForgotPassword && (
+ {/* Verification Code Modal - shown after signup */}
+ {showVerificationModal && (
+ {
+ setShowVerificationModal(false);
+ resetModal();
+ onHide();
+ }}
+ email={email}
+ onVerified={() => {
+ setShowVerificationModal(false);
+ resetModal();
+ onHide();
+ }}
+ />
+ )}
+
+ {!showForgotPassword && !showVerificationModal && (
Promise
;
onAdminRestore?: (commentId: string) => Promise;
+ canReply?: boolean;
}
const CommentThread: React.FC = ({
@@ -33,6 +34,7 @@ const CommentThread: React.FC = ({
isAdmin = false,
onAdminDelete,
onAdminRestore,
+ canReply = true,
}) => {
const [showReplyForm, setShowReplyForm] = useState(false);
const [isEditing, setIsEditing] = useState(false);
@@ -299,7 +301,7 @@ const CommentThread: React.FC = ({
)}
- {!isEditing && canNest && (
+ {!isEditing && canNest && canReply && (
setShowReplyForm(!showReplyForm)}
@@ -394,6 +396,7 @@ const CommentThread: React.FC = ({
isAdmin={isAdmin}
onAdminDelete={onAdminDelete}
onAdminRestore={onAdminRestore}
+ canReply={canReply}
/>
))}
diff --git a/frontend/src/components/VerificationCodeModal.tsx b/frontend/src/components/VerificationCodeModal.tsx
new file mode 100644
index 0000000..be401c0
--- /dev/null
+++ b/frontend/src/components/VerificationCodeModal.tsx
@@ -0,0 +1,249 @@
+import React, { useState, useRef, useEffect } from "react";
+import { authAPI } from "../services/api";
+import { useAuth } from "../contexts/AuthContext";
+
+interface VerificationCodeModalProps {
+ show: boolean;
+ onHide: () => void;
+ email: string;
+ onVerified: () => void;
+}
+
+const VerificationCodeModal: React.FC = ({
+ show,
+ onHide,
+ email,
+ onVerified,
+}) => {
+ const [code, setCode] = useState(["", "", "", "", "", ""]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+ const [resending, setResending] = useState(false);
+ const [resendCooldown, setResendCooldown] = useState(0);
+ const [resendSuccess, setResendSuccess] = useState(false);
+ const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
+ const { checkAuth } = useAuth();
+
+ // Handle resend cooldown timer
+ useEffect(() => {
+ if (resendCooldown > 0) {
+ const timer = setTimeout(
+ () => setResendCooldown(resendCooldown - 1),
+ 1000
+ );
+ return () => clearTimeout(timer);
+ }
+ }, [resendCooldown]);
+
+ // Focus first input on mount
+ useEffect(() => {
+ if (show && inputRefs.current[0]) {
+ inputRefs.current[0].focus();
+ }
+ }, [show]);
+
+ // Clear resend success message after 3 seconds
+ useEffect(() => {
+ if (resendSuccess) {
+ const timer = setTimeout(() => setResendSuccess(false), 3000);
+ return () => clearTimeout(timer);
+ }
+ }, [resendSuccess]);
+
+ const handleInputChange = (index: number, value: string) => {
+ // Only allow digits
+ if (value && !/^\d$/.test(value)) return;
+
+ const newCode = [...code];
+ newCode[index] = value;
+ setCode(newCode);
+ setError("");
+
+ // Auto-advance to next input
+ if (value && index < 5) {
+ inputRefs.current[index + 1]?.focus();
+ }
+
+ // Auto-submit when all 6 digits entered
+ if (value && index === 5 && newCode.every((d) => d !== "")) {
+ handleVerify(newCode.join(""));
+ }
+ };
+
+ const handleKeyDown = (index: number, e: React.KeyboardEvent) => {
+ if (e.key === "Backspace" && !code[index] && index > 0) {
+ inputRefs.current[index - 1]?.focus();
+ }
+ };
+
+ const handlePaste = (e: React.ClipboardEvent) => {
+ e.preventDefault();
+ const pastedData = e.clipboardData
+ .getData("text")
+ .replace(/\D/g, "")
+ .slice(0, 6);
+ if (pastedData.length === 6) {
+ const newCode = pastedData.split("");
+ setCode(newCode);
+ handleVerify(pastedData);
+ }
+ };
+
+ const handleVerify = async (verificationCode: string) => {
+ setLoading(true);
+ setError("");
+
+ try {
+ await authAPI.verifyEmail(verificationCode);
+ await checkAuth(); // Refresh user data
+ onVerified();
+ onHide();
+ } catch (err: any) {
+ const errorData = err.response?.data;
+ if (errorData?.code === "TOO_MANY_ATTEMPTS") {
+ setError("Too many attempts. Please request a new code.");
+ } else if (errorData?.code === "VERIFICATION_EXPIRED") {
+ setError("Code expired. Please request a new one.");
+ } else if (errorData?.code === "VERIFICATION_INVALID") {
+ setError("Invalid code. Please check and try again.");
+ } else {
+ setError(errorData?.error || "Verification failed. Please try again.");
+ }
+ // Clear code on error
+ setCode(["", "", "", "", "", ""]);
+ inputRefs.current[0]?.focus();
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleResend = async () => {
+ setResending(true);
+ setError("");
+ setResendSuccess(false);
+
+ try {
+ await authAPI.resendVerification();
+ setResendCooldown(60); // 60 second cooldown
+ setResendSuccess(true);
+ setCode(["", "", "", "", "", ""]);
+ inputRefs.current[0]?.focus();
+ } catch (err: any) {
+ if (err.response?.status === 429) {
+ setError("Please wait before requesting another code.");
+ } else {
+ setError("Failed to resend code. Please try again.");
+ }
+ } finally {
+ setResending(false);
+ }
+ };
+
+ if (!show) return null;
+
+ return (
+
+
+
+
+
+
+
+
+
Verify Your Email
+
+ We sent a 6-digit code to {email}
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {resendSuccess && (
+
+ New code sent! Check your email.
+
+ )}
+
+ {/* 6-digit code input */}
+
+ {code.map((digit, index) => (
+ { inputRefs.current[index] = el; }}
+ type="text"
+ inputMode="numeric"
+ maxLength={1}
+ value={digit}
+ onChange={(e) => handleInputChange(index, e.target.value)}
+ onKeyDown={(e) => handleKeyDown(index, e)}
+ className="form-control text-center"
+ style={{
+ width: "50px",
+ height: "60px",
+ fontSize: "24px",
+ fontWeight: "bold",
+ }}
+ disabled={loading}
+ />
+ ))}
+
+
+
handleVerify(code.join(""))}
+ disabled={loading || code.some((d) => d === "")}
+ >
+ {loading ? (
+ <>
+
+ Verifying...
+ >
+ ) : (
+ "Verify Email"
+ )}
+
+
+
+
Didn't receive the code?
+
0}
+ >
+ {resendCooldown > 0
+ ? `Resend in ${resendCooldown}s`
+ : resending
+ ? "Sending..."
+ : "Resend Code"}
+
+
+
+
+ Check your spam folder if you don't see the email.
+
+
+
+
+
+ );
+};
+
+export default VerificationCodeModal;
diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx
index 3e1b769..87f0356 100644
--- a/frontend/src/contexts/AuthContext.tsx
+++ b/frontend/src/contexts/AuthContext.tsx
@@ -13,11 +13,15 @@ import {
resetCSRFToken,
} from "../services/api";
+interface RegisterResponse {
+ verificationEmailSent?: boolean;
+}
+
interface AuthContextType {
user: User | null;
loading: boolean;
login: (email: string, password: string) => Promise;
- register: (data: any) => Promise;
+ register: (data: any) => Promise;
googleLogin: (code: string) => Promise;
logout: () => Promise;
updateUser: (user: User) => void;
@@ -98,11 +102,14 @@ export const AuthProvider: React.FC = ({ children }) => {
await fetchCSRFToken();
};
- const register = async (data: any) => {
+ const register = async (data: any): Promise => {
const response = await authAPI.register(data);
setUser(response.data.user);
// Fetch new CSRF token after registration
await fetchCSRFToken();
+ return {
+ verificationEmailSent: response.data.verificationEmailSent,
+ };
};
const googleLogin = async (code: string) => {
diff --git a/frontend/src/pages/CreateForumPost.tsx b/frontend/src/pages/CreateForumPost.tsx
index ffe735d..85d5682 100644
--- a/frontend/src/pages/CreateForumPost.tsx
+++ b/frontend/src/pages/CreateForumPost.tsx
@@ -5,18 +5,21 @@ import { forumAPI, addressAPI } from "../services/api";
import { uploadFiles, getPublicImageUrl } from "../services/uploadService";
import TagInput from "../components/TagInput";
import ForumImageUpload from "../components/ForumImageUpload";
+import VerificationCodeModal from "../components/VerificationCodeModal";
import { Address, ForumPost } from "../types";
const CreateForumPost: React.FC = () => {
const { id } = useParams<{ id: string }>();
const isEditMode = !!id;
- const { user } = useAuth();
+ const { user, checkAuth } = useAuth();
const navigate = useNavigate();
const [loading, setLoading] = useState(isEditMode);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState(null);
const [userAddresses, setUserAddresses] = useState([]);
const [existingImageKeys, setExistingImageKeys] = useState([]);
+ const [showVerificationModal, setShowVerificationModal] = useState(false);
+ const [pendingSubmit, setPendingSubmit] = useState(false);
const [formData, setFormData] = useState({
title: "",
@@ -245,11 +248,32 @@ const CreateForumPost: React.FC = () => {
navigate(`/forum/${response.data.id}`);
}
} catch (err: any) {
+ // Check for email verification required error
+ if (
+ err.response?.status === 403 &&
+ err.response?.data?.code === "EMAIL_NOT_VERIFIED"
+ ) {
+ setPendingSubmit(true);
+ setShowVerificationModal(true);
+ setIsSubmitting(false);
+ return;
+ }
setError(err.response?.data?.error || err.message || `Failed to ${isEditMode ? 'update' : 'create'} post`);
setIsSubmitting(false);
}
};
+ const handleVerificationSuccess = async () => {
+ setShowVerificationModal(false);
+ await checkAuth(); // Refresh user data
+ if (pendingSubmit) {
+ setPendingSubmit(false);
+ // Create a synthetic form event to retry submission
+ const syntheticEvent = { preventDefault: () => {} } as React.FormEvent;
+ handleSubmit(syntheticEvent);
+ }
+ };
+
if (loading) {
return (
@@ -310,6 +334,23 @@ const CreateForumPost: React.FC = () => {
+ {/* Email Verification Warning Banner */}
+ {user && !user.isVerified && (
+
+
+
+ Email verification required. Verify your email to
+ create forum posts.
+
+
setShowVerificationModal(true)}
+ >
+ Verify Now
+
+
+ )}
+
{/* Guidelines Card - only show for new posts */}
@@ -542,6 +583,19 @@ const CreateForumPost: React.FC = () => {
+
+ {/* Email Verification Modal */}
+ {user && (
+ {
+ setShowVerificationModal(false);
+ setPendingSubmit(false);
+ }}
+ email={user.email || ""}
+ onVerified={handleVerificationSuccess}
+ />
+ )}
);
};
diff --git a/frontend/src/pages/CreateItem.tsx b/frontend/src/pages/CreateItem.tsx
index bb2545e..b9247b8 100644
--- a/frontend/src/pages/CreateItem.tsx
+++ b/frontend/src/pages/CreateItem.tsx
@@ -10,6 +10,7 @@ import LocationForm from "../components/LocationForm";
import DeliveryOptions from "../components/DeliveryOptions";
import PricingForm from "../components/PricingForm";
import RulesForm from "../components/RulesForm";
+import VerificationCodeModal from "../components/VerificationCodeModal";
import { Address } from "../types";
import { IMAGE_LIMITS } from "../config/imageLimits";
@@ -48,9 +49,11 @@ interface ItemFormData {
const CreateItem: React.FC = () => {
const navigate = useNavigate();
- const { user } = useAuth();
+ const { user, checkAuth } = useAuth();
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
+ const [showVerificationModal, setShowVerificationModal] = useState(false);
+ const [pendingSubmit, setPendingSubmit] = useState(false);
const [formData, setFormData] = useState({
name: "",
description: "",
@@ -265,12 +268,38 @@ const CreateItem: React.FC = () => {
navigate(`/items/${response.data.id}`);
} catch (err: any) {
- setError(err.response?.data?.error || err.message || "Failed to create listing");
+ // Check if it's a 403 verification required error
+ if (
+ err.response?.status === 403 &&
+ err.response?.data?.code === "EMAIL_NOT_VERIFIED"
+ ) {
+ setPendingSubmit(true);
+ setShowVerificationModal(true);
+ setError("");
+ } else {
+ setError(
+ err.response?.data?.error || err.message || "Failed to create listing"
+ );
+ }
} finally {
setLoading(false);
}
};
+ // Handle successful verification - retry form submission
+ const handleVerificationSuccess = async () => {
+ setShowVerificationModal(false);
+ await checkAuth(); // Refresh user data
+ if (pendingSubmit) {
+ setPendingSubmit(false);
+ // Create a synthetic event to trigger handleSubmit
+ const syntheticEvent = {
+ preventDefault: () => {},
+ } as React.FormEvent;
+ handleSubmit(syntheticEvent);
+ }
+ };
+
const handleChange = (
e: React.ChangeEvent<
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
@@ -401,6 +430,26 @@ const CreateItem: React.FC = () => {
List an Item for Rent
+ {/* Email verification warning banner */}
+ {user && !user.isVerified && (
+
+
+
+ Verify your email to create a listing.
+
+
setShowVerificationModal(true)}
+ >
+ Verify Now
+
+
+ )}
+
{error && (
{error}
@@ -484,10 +533,7 @@ const CreateItem: React.FC = () => {
onTierToggle={handleTierToggle}
/>
-
+
{
+
+ {/* Verification Code Modal */}
+ {showVerificationModal && user && (
+
{
+ setShowVerificationModal(false);
+ setPendingSubmit(false);
+ }}
+ email={user.email || ""}
+ onVerified={handleVerificationSuccess}
+ />
+ )}
);
};
diff --git a/frontend/src/pages/ForumPostDetail.tsx b/frontend/src/pages/ForumPostDetail.tsx
index 96d5776..d82ccbb 100644
--- a/frontend/src/pages/ForumPostDetail.tsx
+++ b/frontend/src/pages/ForumPostDetail.tsx
@@ -9,10 +9,11 @@ import PostStatusBadge from '../components/PostStatusBadge';
import CommentThread from '../components/CommentThread';
import CommentForm from '../components/CommentForm';
import AuthButton from '../components/AuthButton';
+import VerificationCodeModal from '../components/VerificationCodeModal';
const ForumPostDetail: React.FC = () => {
const { id } = useParams<{ id: string }>();
- const { user } = useAuth();
+ const { user, checkAuth } = useAuth();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [post, setPost] = useState(null);
@@ -20,6 +21,7 @@ const ForumPostDetail: React.FC = () => {
const [error, setError] = useState(null);
const [actionLoading, setActionLoading] = useState(false);
const [showAdminModal, setShowAdminModal] = useState(false);
+ const [showVerificationModal, setShowVerificationModal] = useState(false);
const [adminAction, setAdminAction] = useState<{
type: 'deletePost' | 'deleteComment' | 'restorePost' | 'restoreComment' | 'closePost' | 'reopenPost';
id?: string;
@@ -68,6 +70,14 @@ const ForumPostDetail: React.FC = () => {
});
await fetchPost(); // Refresh to get new comment
} catch (err: any) {
+ // Check for email verification required error
+ if (
+ err.response?.status === 403 &&
+ err.response?.data?.code === "EMAIL_NOT_VERIFIED"
+ ) {
+ setShowVerificationModal(true);
+ throw new Error('Email verification required to comment.');
+ }
throw new Error(err.response?.data?.error || err.message || 'Failed to post comment');
}
};
@@ -93,10 +103,23 @@ const ForumPostDetail: React.FC = () => {
});
await fetchPost(); // Refresh to get new reply
} catch (err: any) {
+ // Check for email verification required error
+ if (
+ err.response?.status === 403 &&
+ err.response?.data?.code === "EMAIL_NOT_VERIFIED"
+ ) {
+ setShowVerificationModal(true);
+ throw new Error('Email verification required to reply.');
+ }
throw new Error(err.response?.data?.error || err.message || 'Failed to post reply');
}
};
+ const handleVerificationSuccess = async () => {
+ setShowVerificationModal(false);
+ await checkAuth(); // Refresh user data
+ };
+
const handleEditComment = async (
commentId: string,
content: string,
@@ -490,6 +513,7 @@ const ForumPostDetail: React.FC = () => {
isAdmin={isAdmin}
onAdminDelete={handleAdminDeleteComment}
onAdminRestore={handleAdminRestoreComment}
+ canReply={!!user}
/>
))}
@@ -529,6 +553,21 @@ const ForumPostDetail: React.FC = () => {
{post.status !== 'closed' && user ? (
+ {/* Email Verification Warning for unverified users */}
+ {!user.isVerified && (
+
+
+
+ Email verification required. Verify your email to comment.
+
+
setShowVerificationModal(true)}
+ >
+ Verify Now
+
+
+ )}
Add a comment
{
)}
+ {/* Email Verification Modal */}
+ {user && (
+ setShowVerificationModal(false)}
+ email={user.email || ""}
+ onVerified={handleVerificationSuccess}
+ />
+ )}
+
);
diff --git a/frontend/src/pages/RentItem.tsx b/frontend/src/pages/RentItem.tsx
index d5b834a..14e71e5 100644
--- a/frontend/src/pages/RentItem.tsx
+++ b/frontend/src/pages/RentItem.tsx
@@ -5,15 +5,18 @@ import { useAuth } from "../contexts/AuthContext";
import { itemAPI, rentalAPI } from "../services/api";
import { getPublicImageUrl } from "../services/uploadService";
import EmbeddedStripeCheckout from "../components/EmbeddedStripeCheckout";
+import VerificationCodeModal from "../components/VerificationCodeModal";
const RentItem: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
- const { user } = useAuth();
+ const { user, checkAuth } = useAuth();
const [searchParams] = useSearchParams();
const [item, setItem] = useState- (null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState
(null);
+ const [showVerificationModal, setShowVerificationModal] = useState(false);
+ const [pendingSubmit, setPendingSubmit] = useState(false);
const [formData, setFormData] = useState({
deliveryMethod: "pickup" as "pickup" | "delivery",
@@ -163,12 +166,31 @@ const RentItem: React.FC = () => {
await rentalAPI.createRental(rentalData);
setCompleted(true);
} catch (error: any) {
+ // Check for email verification required error
+ if (
+ error.response?.status === 403 &&
+ error.response?.data?.code === "EMAIL_NOT_VERIFIED"
+ ) {
+ setPendingSubmit(true);
+ setShowVerificationModal(true);
+ return;
+ }
setError(
error.response?.data?.error || "Failed to create rental request"
);
}
};
+ const handleVerificationSuccess = async () => {
+ setShowVerificationModal(false);
+ await checkAuth(); // Refresh user data
+ if (pendingSubmit) {
+ setPendingSubmit(false);
+ // Retry the rental submission
+ handleFreeBorrow();
+ }
+ };
+
const handleChange = (
e: React.ChangeEvent<
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
@@ -213,6 +235,23 @@ const RentItem: React.FC = () => {
Renting {item.name}
+ {/* Email Verification Warning Banner */}
+ {user && !user.isVerified && (
+
+
+
+ Email verification required. Verify your email
+ to book rentals.
+
+
setShowVerificationModal(true)}
+ >
+ Verify Now
+
+
+ )}
+
{error && (
{error}
@@ -426,6 +465,19 @@ const RentItem: React.FC = () => {
+
+ {/* Email Verification Modal */}
+ {user && (
+ {
+ setShowVerificationModal(false);
+ setPendingSubmit(false);
+ }}
+ email={user.email || ""}
+ onVerified={handleVerificationSuccess}
+ />
+ )}
);
};
diff --git a/frontend/src/pages/VerifyEmail.tsx b/frontend/src/pages/VerifyEmail.tsx
index f135cd2..1ce78b0 100644
--- a/frontend/src/pages/VerifyEmail.tsx
+++ b/frontend/src/pages/VerifyEmail.tsx
@@ -1,93 +1,234 @@
-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';
+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('');
+ const { checkAuth, user, loading: authLoading } = useAuth();
+ const [error, setError] = useState("");
+ const [errorCode, setErrorCode] = useState("");
const [success, setSuccess] = useState(false);
const [processing, setProcessing] = useState(true);
const [resending, setResending] = useState(false);
+ const [resendSuccess, setResendSuccess] = useState(false);
+ const [resendCooldown, setResendCooldown] = useState(0);
+ const [showManualEntry, setShowManualEntry] = useState(false);
+ const [code, setCode] = useState(["", "", "", "", "", ""]);
+ const [verifying, setVerifying] = useState(false);
const hasProcessed = useRef(false);
+ const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
+ // Handle resend cooldown timer
useEffect(() => {
+ if (resendCooldown > 0) {
+ const timer = setTimeout(
+ () => setResendCooldown(resendCooldown - 1),
+ 1000
+ );
+ return () => clearTimeout(timer);
+ }
+ }, [resendCooldown]);
+
+ // Clear resend success after 3 seconds
+ useEffect(() => {
+ if (resendSuccess) {
+ const timer = setTimeout(() => setResendSuccess(false), 3000);
+ return () => clearTimeout(timer);
+ }
+ }, [resendSuccess]);
+
+ // Check authentication and handle verification
+ useEffect(() => {
+ // Wait for auth to finish loading
+ if (authLoading) return;
+
const handleVerification = async () => {
// Prevent double execution in React StrictMode
- if (hasProcessed.current) {
- return;
- }
+ if (hasProcessed.current) return;
hasProcessed.current = true;
- try {
- const token = searchParams.get('token');
+ 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);
+ // If not logged in, redirect to login with return URL
+ if (!user) {
+ const returnUrl = token
+ ? `/verify-email?token=${token}`
+ : "/verify-email";
+ navigate(`/?login=true&redirect=${encodeURIComponent(returnUrl)}`, {
+ replace: true,
+ });
+ return;
+ }
+ // If user is already verified, show success
+ if (user.isVerified) {
setSuccess(true);
setProcessing(false);
+ setTimeout(() => navigate("/", { replace: true }), 3000);
+ return;
+ }
- // Refresh user data to update isVerified status
+ // If no token in URL, show manual entry form
+ if (!token) {
+ setShowManualEntry(true);
+ setProcessing(false);
+ return;
+ }
+
+ // Auto-verify with token from URL
+ try {
+ await authAPI.verifyEmail(token);
+ setSuccess(true);
+ setProcessing(false);
await checkAuth();
-
- // Redirect to home after 3 seconds
- setTimeout(() => {
- navigate('/', { replace: true });
- }, 3000);
+ 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.');
- }
-
+ handleVerificationError(err);
setProcessing(false);
}
};
handleVerification();
- }, [searchParams, navigate, checkAuth]);
+ }, [searchParams, navigate, checkAuth, user, authLoading]);
+
+ const handleVerificationError = (err: any) => {
+ const errorData = err.response?.data;
+ const code = errorData?.code || "";
+ setErrorCode(code);
+
+ switch (code) {
+ case "VERIFICATION_EXPIRED":
+ setError("Your verification code has expired. Please request a new one.");
+ setShowManualEntry(true);
+ break;
+ case "VERIFICATION_INVALID":
+ setError("Invalid verification code. Please check and try again.");
+ setShowManualEntry(true);
+ break;
+ case "TOO_MANY_ATTEMPTS":
+ setError("Too many attempts. Please request a new code.");
+ setShowManualEntry(true);
+ break;
+ case "ALREADY_VERIFIED":
+ setError("Your email is already verified.");
+ break;
+ case "NO_CODE":
+ setError("No verification code found. Please request a new one.");
+ setShowManualEntry(true);
+ break;
+ default:
+ setError(errorData?.error || "Failed to verify email. Please try again.");
+ setShowManualEntry(true);
+ }
+ };
+
+ const handleInputChange = (index: number, value: string) => {
+ if (value && !/^\d$/.test(value)) return;
+
+ const newCode = [...code];
+ newCode[index] = value;
+ setCode(newCode);
+ setError("");
+ setErrorCode("");
+
+ if (value && index < 5) {
+ inputRefs.current[index + 1]?.focus();
+ }
+
+ if (value && index === 5 && newCode.every((d) => d !== "")) {
+ handleManualVerify(newCode.join(""));
+ }
+ };
+
+ const handleKeyDown = (index: number, e: React.KeyboardEvent) => {
+ if (e.key === "Backspace" && !code[index] && index > 0) {
+ inputRefs.current[index - 1]?.focus();
+ }
+ };
+
+ const handlePaste = (e: React.ClipboardEvent) => {
+ e.preventDefault();
+ const pastedData = e.clipboardData
+ .getData("text")
+ .replace(/\D/g, "")
+ .slice(0, 6);
+ if (pastedData.length === 6) {
+ const newCode = pastedData.split("");
+ setCode(newCode);
+ handleManualVerify(pastedData);
+ }
+ };
+
+ const handleManualVerify = async (verificationCode: string) => {
+ setVerifying(true);
+ setError("");
+ setErrorCode("");
+
+ try {
+ await authAPI.verifyEmail(verificationCode);
+ setSuccess(true);
+ await checkAuth();
+ setTimeout(() => navigate("/", { replace: true }), 3000);
+ } catch (err: any) {
+ handleVerificationError(err);
+ setCode(["", "", "", "", "", ""]);
+ inputRefs.current[0]?.focus();
+ } finally {
+ setVerifying(false);
+ }
+ };
const handleResendVerification = async () => {
setResending(true);
- setError('');
+ setError("");
+ setErrorCode("");
+ setResendSuccess(false);
try {
await authAPI.resendVerification();
- setError('');
- alert('Verification email sent! Please check your inbox.');
+ setResendSuccess(true);
+ setResendCooldown(60);
+ setCode(["", "", "", "", "", ""]);
+ inputRefs.current[0]?.focus();
} 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.');
+ if (errorData?.code === "ALREADY_VERIFIED") {
+ setError("Your email is already verified.");
+ } else if (err.response?.status === 429) {
+ setError("Please wait before requesting another code.");
} else {
- setError(errorData?.error || 'Failed to resend verification email. Please try again.');
+ setError(
+ errorData?.error ||
+ "Failed to resend verification email. Please try again."
+ );
}
} finally {
setResending(false);
}
};
+ // Show loading while auth is initializing
+ if (authLoading) {
+ return (
+
+
+
+
+
+
+ Loading...
+
+
Loading...
+
+
+
+
+
+ );
+ }
+
return (
@@ -96,38 +237,136 @@ const VerifyEmail: React.FC = () => {
{processing ? (
<>
-
+
Loading...
Verifying Your Email...
-
Please wait while we verify your email address.
+
+ Please wait while we verify your email address.
+
>
) : success ? (
<>
-
+
Email Verified Successfully!
- Your email has been verified. You will be redirected to the home page shortly.
+ Your email has been verified. You will be redirected
+ shortly.
-
+
Go to Home
>
+ ) : showManualEntry ? (
+ <>
+
+
Enter Verification Code
+
+ Enter the 6-digit code sent to your email
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {resendSuccess && (
+
+ New code sent! Check your email.
+
+ )}
+
+ {/* 6-digit code input */}
+
+ {code.map((digit, index) => (
+ { inputRefs.current[index] = el; }}
+ type="text"
+ inputMode="numeric"
+ maxLength={1}
+ value={digit}
+ onChange={(e) =>
+ handleInputChange(index, e.target.value)
+ }
+ onKeyDown={(e) => handleKeyDown(index, e)}
+ className="form-control text-center"
+ style={{
+ width: "50px",
+ height: "60px",
+ fontSize: "24px",
+ fontWeight: "bold",
+ }}
+ disabled={verifying}
+ />
+ ))}
+
+
+
handleManualVerify(code.join(""))}
+ disabled={verifying || code.some((d) => d === "")}
+ >
+ {verifying ? (
+ <>
+
+ Verifying...
+ >
+ ) : (
+ "Verify Email"
+ )}
+
+
+
+
+ Didn't receive the code?
+
+
0}
+ >
+ {resendCooldown > 0
+ ? `Resend in ${resendCooldown}s`
+ : resending
+ ? "Sending..."
+ : "Resend Code"}
+
+
+
+
+ Check your spam folder if you don't see the email.
+
+
+
+ Return to Home
+
+ >
) : error ? (
<>
-
+
Verification Failed
{error}
- {user && !error.includes('already verified') && (
-
- {resending ? 'Sending...' : 'Resend Verification Email'}
-
- )}
Return to Home
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index ebcc69f..0584333 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -162,7 +162,7 @@ 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 }),
+ verifyEmail: (code: string) => api.post("/auth/verify-email", { code }),
resendVerification: () => api.post("/auth/resend-verification"),
forgotPassword: (email: string) =>
api.post("/auth/forgot-password", { email }),