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 +

+
+ +
+

Or click the button below:

Verify Email Address
@@ -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 && (
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} + /> + ))} +
+ + + +
+

Didn't receive the 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. +
+ +
+ )} +
{/* 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. +
+ +
+ )} + {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. +
+ +
+ )}
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. +
+ +
+ )} + {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} + /> + ))} +
+ + + +
+

+ Didn't receive the code? +

+ +
+ +

+ Check your spam folder if you don't see the email. +

+ + + Return to Home + + ) : error ? ( <> - +
Verification Failed

{error}

- {user && !error.includes('already verified') && ( - - )} 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 }),