From 372ab093ef2a0a74e1b109a1d6de90a556d5e728 Mon Sep 17 00:00:00 2001 From: jackiettran <41605212+jackiettran@users.noreply.github.com> Date: Mon, 15 Dec 2025 22:45:55 -0500 Subject: [PATCH] email verification flow updated --- backend/config/database.js | 1 - backend/middleware/rateLimiter.js | 13 + ...0251215175806-add-verification-attempts.js | 15 + backend/models/User.js | 53 ++- backend/routes/auth.js | 211 ++++++---- backend/routes/forum.js | 32 ++ backend/routes/rentals.js | 209 ++++++---- .../services/email/domain/AuthEmailService.js | 1 + .../emails/emailVerificationToUser.html | 26 +- frontend/src/components/AuthModal.tsx | 33 +- frontend/src/components/CommentThread.tsx | 5 +- .../src/components/VerificationCodeModal.tsx | 249 ++++++++++++ frontend/src/contexts/AuthContext.tsx | 11 +- frontend/src/pages/CreateForumPost.tsx | 56 ++- frontend/src/pages/CreateItem.tsx | 71 +++- frontend/src/pages/ForumPostDetail.tsx | 51 ++- frontend/src/pages/RentItem.tsx | 54 ++- frontend/src/pages/VerifyEmail.tsx | 367 +++++++++++++++--- frontend/src/services/api.ts | 2 +- 19 files changed, 1214 insertions(+), 246 deletions(-) create mode 100644 backend/migrations/20251215175806-add-verification-attempts.js create mode 100644 frontend/src/components/VerificationCodeModal.tsx 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 @@
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:
++ Enter this code in the app to verify your email +
+Or click the button below:
Verify Email Address{{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+ We sent a 6-digit code to {email} +
+ + {error && ( +Didn't receive the code?
+ ++ Check your spam folder if you don't see the email. +
+Please wait while we verify your email address.
++ Please wait while we verify your email address. +
> ) : success ? ( <> - +- 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 the 6-digit code sent to your email +
+ + {error && ( ++ Didn't receive the code? +
+ ++ Check your spam folder if you don't see the email. +
+ + + Return to Home + + > ) : error ? ( <> - +{error}