email verification flow updated
This commit is contained in:
@@ -34,7 +34,6 @@ const dbConfig = {
|
|||||||
// Configuration for Sequelize CLI (supports multiple environments)
|
// Configuration for Sequelize CLI (supports multiple environments)
|
||||||
// All environments use the same configuration from environment variables
|
// All environments use the same configuration from environment variables
|
||||||
const cliConfig = {
|
const cliConfig = {
|
||||||
development: dbConfig,
|
|
||||||
dev: dbConfig,
|
dev: dbConfig,
|
||||||
test: dbConfig,
|
test: dbConfig,
|
||||||
qa: dbConfig,
|
qa: dbConfig,
|
||||||
|
|||||||
@@ -162,6 +162,18 @@ const authRateLimiters = {
|
|||||||
legacyHeaders: false,
|
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 API rate limiter
|
||||||
general: rateLimit({
|
general: rateLimit({
|
||||||
windowMs: 60 * 1000, // 1 minute
|
windowMs: 60 * 1000, // 1 minute
|
||||||
@@ -186,6 +198,7 @@ module.exports = {
|
|||||||
registerLimiter: authRateLimiters.register,
|
registerLimiter: authRateLimiters.register,
|
||||||
passwordResetLimiter: authRateLimiters.passwordReset,
|
passwordResetLimiter: authRateLimiters.passwordReset,
|
||||||
alphaCodeValidationLimiter: authRateLimiters.alphaCodeValidation,
|
alphaCodeValidationLimiter: authRateLimiters.alphaCodeValidation,
|
||||||
|
emailVerificationLimiter: authRateLimiters.emailVerification,
|
||||||
generalLimiter: authRateLimiters.general,
|
generalLimiter: authRateLimiters.general,
|
||||||
|
|
||||||
// Burst protection
|
// Burst protection
|
||||||
|
|||||||
@@ -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");
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -146,6 +146,11 @@ const User = sequelize.define(
|
|||||||
max: 100,
|
max: 100,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
verificationAttempts: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
defaultValue: 0,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
hooks: {
|
hooks: {
|
||||||
@@ -208,31 +213,64 @@ User.prototype.resetLoginAttempts = async function () {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Email verification methods
|
// Email verification methods
|
||||||
|
// Maximum verification attempts before requiring a new code
|
||||||
|
const MAX_VERIFICATION_ATTEMPTS = 5;
|
||||||
|
|
||||||
User.prototype.generateVerificationToken = async function () {
|
User.prototype.generateVerificationToken = async function () {
|
||||||
const crypto = require("crypto");
|
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
|
const expiry = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
|
||||||
|
|
||||||
return this.update({
|
return this.update({
|
||||||
verificationToken: token,
|
verificationToken: code,
|
||||||
verificationTokenExpiry: expiry,
|
verificationTokenExpiry: expiry,
|
||||||
|
verificationAttempts: 0, // Reset attempts on new code
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
User.prototype.isVerificationTokenValid = function (token) {
|
User.prototype.isVerificationTokenValid = function (token) {
|
||||||
|
const crypto = require("crypto");
|
||||||
|
|
||||||
if (!this.verificationToken || !this.verificationTokenExpiry) {
|
if (!this.verificationToken || !this.verificationTokenExpiry) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.verificationToken !== token) {
|
// Check if token is expired
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (new Date() > new Date(this.verificationTokenExpiry)) {
|
if (new Date() > new Date(this.verificationTokenExpiry)) {
|
||||||
return false;
|
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 () {
|
User.prototype.verifyEmail = async function () {
|
||||||
@@ -241,6 +279,7 @@ User.prototype.verifyEmail = async function () {
|
|||||||
verifiedAt: new Date(),
|
verifiedAt: new Date(),
|
||||||
verificationToken: null,
|
verificationToken: null,
|
||||||
verificationTokenExpiry: null,
|
verificationTokenExpiry: null,
|
||||||
|
verificationAttempts: 0,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ const {
|
|||||||
loginLimiter,
|
loginLimiter,
|
||||||
registerLimiter,
|
registerLimiter,
|
||||||
passwordResetLimiter,
|
passwordResetLimiter,
|
||||||
|
emailVerificationLimiter,
|
||||||
} = require("../middleware/rateLimiter");
|
} = require("../middleware/rateLimiter");
|
||||||
|
const { authenticateToken } = require("../middleware/auth");
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const googleClient = new OAuth2Client(
|
const googleClient = new OAuth2Client(
|
||||||
@@ -43,8 +45,7 @@ router.post(
|
|||||||
validateRegistration,
|
validateRegistration,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { email, password, firstName, lastName, phone } =
|
const { email, password, firstName, lastName, phone } = req.body;
|
||||||
req.body;
|
|
||||||
|
|
||||||
const existingUser = await User.findOne({
|
const existingUser = await User.findOne({
|
||||||
where: { email },
|
where: { email },
|
||||||
@@ -64,7 +65,7 @@ router.post(
|
|||||||
|
|
||||||
// Alpha access validation
|
// Alpha access validation
|
||||||
let alphaInvitation = null;
|
let alphaInvitation = null;
|
||||||
if (process.env.ALPHA_TESTING_ENABLED === 'true') {
|
if (process.env.ALPHA_TESTING_ENABLED === "true") {
|
||||||
if (req.cookies && req.cookies.alphaAccessCode) {
|
if (req.cookies && req.cookies.alphaAccessCode) {
|
||||||
const { code } = req.cookies.alphaAccessCode;
|
const { code } = req.cookies.alphaAccessCode;
|
||||||
if (code) {
|
if (code) {
|
||||||
@@ -88,7 +89,8 @@ router.post(
|
|||||||
|
|
||||||
if (!alphaInvitation) {
|
if (!alphaInvitation) {
|
||||||
return res.status(403).json({
|
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)
|
// Send verification email (don't block registration if email fails)
|
||||||
let verificationEmailSent = false;
|
let verificationEmailSent = false;
|
||||||
try {
|
try {
|
||||||
await emailServices.auth.sendVerificationEmail(user, user.verificationToken);
|
await emailServices.auth.sendVerificationEmail(
|
||||||
|
user,
|
||||||
|
user.verificationToken
|
||||||
|
);
|
||||||
verificationEmailSent = true;
|
verificationEmailSent = true;
|
||||||
} catch (emailError) {
|
} catch (emailError) {
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
@@ -200,7 +205,10 @@ router.post(
|
|||||||
const user = await User.findOne({ where: { email } });
|
const user = await User.findOne({ where: { email } });
|
||||||
|
|
||||||
if (!user) {
|
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
|
// Check if account is locked
|
||||||
@@ -217,7 +225,10 @@ router.post(
|
|||||||
if (!isPasswordValid) {
|
if (!isPasswordValid) {
|
||||||
// Increment login attempts
|
// Increment login attempts
|
||||||
await user.incLoginAttempts();
|
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
|
// Reset login attempts on successful login
|
||||||
@@ -322,7 +333,8 @@ router.post(
|
|||||||
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
return res.status(400).json({
|
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;
|
let lastName = familyName;
|
||||||
|
|
||||||
if (!firstName || !lastName) {
|
if (!firstName || !lastName) {
|
||||||
const emailUsername = email.split('@')[0];
|
const emailUsername = email.split("@")[0];
|
||||||
// Try to split email username by common separators
|
// Try to split email username by common separators
|
||||||
const nameParts = emailUsername.split(/[._-]/);
|
const nameParts = emailUsername.split(/[._-]/);
|
||||||
|
|
||||||
if (!firstName) {
|
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) {
|
if (!lastName) {
|
||||||
lastName = nameParts.length > 1
|
lastName =
|
||||||
? nameParts[nameParts.length - 1].charAt(0).toUpperCase() + nameParts[nameParts.length - 1].slice(1)
|
nameParts.length > 1
|
||||||
: 'User';
|
? 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
|
// 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({
|
const alphaInvitation = await AlphaInvitation.findOne({
|
||||||
where: { email: email.toLowerCase().trim() },
|
where: { email: email.toLowerCase().trim() },
|
||||||
});
|
});
|
||||||
@@ -467,26 +483,37 @@ router.post(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Email verification endpoint
|
// Email verification endpoint
|
||||||
router.post("/verify-email", sanitizeInput, async (req, res) => {
|
router.post(
|
||||||
|
"/verify-email",
|
||||||
|
emailVerificationLimiter,
|
||||||
|
authenticateToken,
|
||||||
|
sanitizeInput,
|
||||||
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { token } = req.body;
|
const { code } = req.body;
|
||||||
|
|
||||||
if (!token) {
|
if (!code) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: "Verification token required",
|
error: "Verification code required",
|
||||||
code: "TOKEN_REQUIRED",
|
code: "CODE_REQUIRED",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find user with this verification token
|
// Validate 6-digit format
|
||||||
const user = await User.findOne({
|
if (!/^\d{6}$/.test(code)) {
|
||||||
where: { verificationToken: token },
|
return res.status(400).json({
|
||||||
|
error: "Verification code must be 6 digits",
|
||||||
|
code: "INVALID_CODE_FORMAT",
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the authenticated user
|
||||||
|
const user = await User.findByPk(req.user.id);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return res.status(400).json({
|
return res.status(404).json({
|
||||||
error: "Invalid verification token",
|
error: "User not found",
|
||||||
code: "VERIFICATION_TOKEN_INVALID",
|
code: "USER_NOT_FOUND",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -498,11 +525,47 @@ router.post("/verify-email", sanitizeInput, async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if token is valid (not expired)
|
// Check if too many failed attempts
|
||||||
if (!user.isVerificationTokenValid(token)) {
|
if (user.isVerificationLocked()) {
|
||||||
|
return res.status(429).json({
|
||||||
|
error: "Too many verification attempts. Please request a new code.",
|
||||||
|
code: "TOO_MANY_ATTEMPTS",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has a verification token
|
||||||
|
if (!user.verificationToken) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: "Verification token has expired. Please request a new one.",
|
error: "No verification code found. Please request a new one.",
|
||||||
code: "VERIFICATION_TOKEN_EXPIRED",
|
code: "NO_CODE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -533,7 +596,8 @@ router.post("/verify-email", sanitizeInput, async (req, res) => {
|
|||||||
error: "Email verification failed. Please try again.",
|
error: "Email verification failed. Please try again.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Resend verification email endpoint
|
// Resend verification email endpoint
|
||||||
router.post(
|
router.post(
|
||||||
@@ -575,7 +639,10 @@ router.post(
|
|||||||
|
|
||||||
// Send verification email
|
// Send verification email
|
||||||
try {
|
try {
|
||||||
await emailServices.auth.sendVerificationEmail(user, user.verificationToken);
|
await emailServices.auth.sendVerificationEmail(
|
||||||
|
user,
|
||||||
|
user.verificationToken
|
||||||
|
);
|
||||||
} catch (emailError) {
|
} catch (emailError) {
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
reqLogger.error("Failed to resend verification email", {
|
reqLogger.error("Failed to resend verification email", {
|
||||||
|
|||||||
@@ -241,6 +241,14 @@ router.get('/posts/:id', optionalAuth, async (req, res, next) => {
|
|||||||
// POST /api/forum/posts - Create new post
|
// POST /api/forum/posts - Create new post
|
||||||
router.post('/posts', authenticateToken, async (req, res, next) => {
|
router.post('/posts', authenticateToken, async (req, res, next) => {
|
||||||
try {
|
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;
|
let { title, content, category, tags, zipCode, latitude: providedLat, longitude: providedLng, imageFilenames: rawImageFilenames } = req.body;
|
||||||
|
|
||||||
// Ensure imageFilenames is an array and validate S3 keys
|
// 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
|
// PUT /api/forum/posts/:id - Update post
|
||||||
router.put('/posts/:id', authenticateToken, async (req, res, next) => {
|
router.put('/posts/:id', authenticateToken, async (req, res, next) => {
|
||||||
try {
|
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);
|
const post = await ForumPost.findByPk(req.params.id);
|
||||||
|
|
||||||
if (!post) {
|
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
|
// POST /api/forum/posts/:id/comments - Add comment/reply
|
||||||
router.post('/posts/:id/comments', authenticateToken, async (req, res, next) => {
|
router.post('/posts/:id/comments', authenticateToken, async (req, res, next) => {
|
||||||
try {
|
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
|
// Support both parentId (new) and parentCommentId (legacy) for backwards compatibility
|
||||||
const { content, parentId, parentCommentId, imageFilenames: rawImageFilenames } = req.body;
|
const { content, parentId, parentCommentId, imageFilenames: rawImageFilenames } = req.body;
|
||||||
const parentIdResolved = parentId || parentCommentId;
|
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
|
// PUT /api/forum/comments/:id - Edit comment
|
||||||
router.put('/comments/:id', authenticateToken, async (req, res, next) => {
|
router.put('/comments/:id', authenticateToken, async (req, res, next) => {
|
||||||
try {
|
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 { content, imageFilenames: rawImageFilenames } = req.body;
|
||||||
const comment = await ForumComment.findByPk(req.params.id);
|
const comment = await ForumComment.findByPk(req.params.id);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
const { Op } = require("sequelize");
|
const { Op } = require("sequelize");
|
||||||
const { Rental, Item, User } = require("../models"); // Import from models/index.js to get models with associations
|
const { Rental, Item, User } = require("../models"); // Import from models/index.js to get models with associations
|
||||||
const { authenticateToken, requireVerifiedEmail } = require("../middleware/auth");
|
const {
|
||||||
|
authenticateToken,
|
||||||
|
requireVerifiedEmail,
|
||||||
|
} = require("../middleware/auth");
|
||||||
const FeeCalculator = require("../utils/feeCalculator");
|
const FeeCalculator = require("../utils/feeCalculator");
|
||||||
const RentalDurationCalculator = require("../utils/rentalDurationCalculator");
|
const RentalDurationCalculator = require("../utils/rentalDurationCalculator");
|
||||||
const RefundService = require("../services/refundService");
|
const RefundService = require("../services/refundService");
|
||||||
@@ -304,7 +307,11 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => {
|
|||||||
|
|
||||||
// Send rental request notification to owner
|
// Send rental request notification to owner
|
||||||
try {
|
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);
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
reqLogger.info("Rental request notification sent to owner", {
|
reqLogger.info("Rental request notification sent to owner", {
|
||||||
rentalId: rental.id,
|
rentalId: rental.id,
|
||||||
@@ -322,7 +329,10 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => {
|
|||||||
|
|
||||||
// Send rental request confirmation to renter
|
// Send rental request confirmation to renter
|
||||||
try {
|
try {
|
||||||
await emailServices.rentalFlow.sendRentalRequestConfirmationEmail(rentalWithDetails.renter, rentalWithDetails);
|
await emailServices.rentalFlow.sendRentalRequestConfirmationEmail(
|
||||||
|
rentalWithDetails.renter,
|
||||||
|
rentalWithDetails
|
||||||
|
);
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
reqLogger.info("Rental request confirmation sent to renter", {
|
reqLogger.info("Rental request confirmation sent to renter", {
|
||||||
rentalId: rental.id,
|
rentalId: rental.id,
|
||||||
@@ -358,12 +368,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
|||||||
{
|
{
|
||||||
model: User,
|
model: User,
|
||||||
as: "renter",
|
as: "renter",
|
||||||
attributes: [
|
attributes: ["id", "firstName", "lastName", "stripeCustomerId"],
|
||||||
"id",
|
|
||||||
"firstName",
|
|
||||||
"lastName",
|
|
||||||
"stripeCustomerId",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -445,7 +450,11 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
|||||||
// Send confirmation emails
|
// Send confirmation emails
|
||||||
// Send approval confirmation to owner with Stripe reminder
|
// Send approval confirmation to owner with Stripe reminder
|
||||||
try {
|
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);
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
reqLogger.info("Rental approval confirmation sent to owner", {
|
reqLogger.info("Rental approval confirmation sent to owner", {
|
||||||
rentalId: updatedRental.id,
|
rentalId: updatedRental.id,
|
||||||
@@ -453,11 +462,14 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
|||||||
});
|
});
|
||||||
} catch (emailError) {
|
} catch (emailError) {
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
reqLogger.error("Failed to send rental approval confirmation email to owner", {
|
reqLogger.error(
|
||||||
|
"Failed to send rental approval confirmation email to owner",
|
||||||
|
{
|
||||||
error: emailError.message,
|
error: emailError.message,
|
||||||
rentalId: updatedRental.id,
|
rentalId: updatedRental.id,
|
||||||
ownerId: updatedRental.ownerId,
|
ownerId: updatedRental.ownerId,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send rental confirmation to renter with payment receipt
|
// Send rental confirmation to renter with payment receipt
|
||||||
@@ -489,11 +501,14 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
} catch (emailError) {
|
} catch (emailError) {
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
reqLogger.error("Failed to send rental confirmation email to renter", {
|
reqLogger.error(
|
||||||
|
"Failed to send rental confirmation email to renter",
|
||||||
|
{
|
||||||
error: emailError.message,
|
error: emailError.message,
|
||||||
rentalId: updatedRental.id,
|
rentalId: updatedRental.id,
|
||||||
renterId: updatedRental.renterId,
|
renterId: updatedRental.renterId,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(updatedRental);
|
res.json(updatedRental);
|
||||||
@@ -537,7 +552,11 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
|||||||
// Send confirmation emails
|
// Send confirmation emails
|
||||||
// Send approval confirmation to owner (for free rentals, no Stripe reminder shown)
|
// Send approval confirmation to owner (for free rentals, no Stripe reminder shown)
|
||||||
try {
|
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);
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
reqLogger.info("Rental approval confirmation sent to owner", {
|
reqLogger.info("Rental approval confirmation sent to owner", {
|
||||||
rentalId: updatedRental.id,
|
rentalId: updatedRental.id,
|
||||||
@@ -545,11 +564,14 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
|||||||
});
|
});
|
||||||
} catch (emailError) {
|
} catch (emailError) {
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
reqLogger.error("Failed to send rental approval confirmation email to owner", {
|
reqLogger.error(
|
||||||
|
"Failed to send rental approval confirmation email to owner",
|
||||||
|
{
|
||||||
error: emailError.message,
|
error: emailError.message,
|
||||||
rentalId: updatedRental.id,
|
rentalId: updatedRental.id,
|
||||||
ownerId: updatedRental.ownerId,
|
ownerId: updatedRental.ownerId,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send rental confirmation to renter
|
// Send rental confirmation to renter
|
||||||
@@ -581,11 +603,14 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
} catch (emailError) {
|
} catch (emailError) {
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
reqLogger.error("Failed to send rental confirmation email to renter", {
|
reqLogger.error(
|
||||||
|
"Failed to send rental confirmation email to renter",
|
||||||
|
{
|
||||||
error: emailError.message,
|
error: emailError.message,
|
||||||
rentalId: updatedRental.id,
|
rentalId: updatedRental.id,
|
||||||
renterId: updatedRental.renterId,
|
renterId: updatedRental.renterId,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(updatedRental);
|
res.json(updatedRental);
|
||||||
@@ -687,7 +712,11 @@ router.put("/:id/decline", authenticateToken, async (req, res) => {
|
|||||||
|
|
||||||
// Send decline notification email to renter
|
// Send decline notification email to renter
|
||||||
try {
|
try {
|
||||||
await emailServices.rentalFlow.sendRentalDeclinedEmail(updatedRental.renter, updatedRental, reason);
|
await emailServices.rentalFlow.sendRentalDeclinedEmail(
|
||||||
|
updatedRental.renter,
|
||||||
|
updatedRental,
|
||||||
|
reason
|
||||||
|
);
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
reqLogger.info("Rental decline notification sent to renter", {
|
reqLogger.info("Rental decline notification sent to renter", {
|
||||||
rentalId: rental.id,
|
rentalId: rental.id,
|
||||||
@@ -904,7 +933,7 @@ router.post("/cost-preview", authenticateToken, async (req, res) => {
|
|||||||
// Validate date range
|
// Validate date range
|
||||||
if (rentalEndDateTime <= rentalStartDateTime) {
|
if (rentalEndDateTime <= rentalStartDateTime) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: "End date/time must be after start date/time",
|
error: "End must be after start",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -987,12 +1016,17 @@ router.get("/:id/refund-preview", authenticateToken, async (req, res, next) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get late fee preview
|
// Get late fee preview
|
||||||
router.get("/:id/late-fee-preview", authenticateToken, async (req, res, next) => {
|
router.get(
|
||||||
|
"/:id/late-fee-preview",
|
||||||
|
authenticateToken,
|
||||||
|
async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { actualReturnDateTime } = req.query;
|
const { actualReturnDateTime } = req.query;
|
||||||
|
|
||||||
if (!actualReturnDateTime) {
|
if (!actualReturnDateTime) {
|
||||||
return res.status(400).json({ error: "actualReturnDateTime is required" });
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "actualReturnDateTime is required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const rental = await Rental.findByPk(req.params.id, {
|
const rental = await Rental.findByPk(req.params.id, {
|
||||||
@@ -1024,7 +1058,8 @@ router.get("/:id/late-fee-preview", authenticateToken, async (req, res, next) =>
|
|||||||
});
|
});
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Cancel rental with refund processing
|
// Cancel rental with refund processing
|
||||||
router.post("/:id/cancel", authenticateToken, async (req, res, next) => {
|
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
|
// Send completion emails to both renter and owner
|
||||||
try {
|
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);
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
reqLogger.info("Rental completion emails sent", {
|
reqLogger.info("Rental completion emails sent", {
|
||||||
rentalId,
|
rentalId,
|
||||||
@@ -1224,7 +1263,11 @@ router.post("/:id/mark-return", authenticateToken, async (req, res, next) => {
|
|||||||
// Send notification to customer service
|
// Send notification to customer service
|
||||||
const owner = await User.findByPk(rental.ownerId);
|
const owner = await User.findByPk(rental.ownerId);
|
||||||
const renter = await User.findByPk(rental.renterId);
|
const renter = await User.findByPk(rental.renterId);
|
||||||
await emailServices.customerService.sendLostItemToCustomerService(updatedRental, owner, renter);
|
await emailServices.customerService.sendLostItemToCustomerService(
|
||||||
|
updatedRental,
|
||||||
|
owner,
|
||||||
|
renter
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -1261,14 +1304,14 @@ router.post("/:id/mark-return", authenticateToken, async (req, res, next) => {
|
|||||||
|
|
||||||
// Allowed fields for damage report (prevents mass assignment)
|
// Allowed fields for damage report (prevents mass assignment)
|
||||||
const ALLOWED_DAMAGE_REPORT_FIELDS = [
|
const ALLOWED_DAMAGE_REPORT_FIELDS = [
|
||||||
'description',
|
"description",
|
||||||
'canBeFixed',
|
"canBeFixed",
|
||||||
'repairCost',
|
"repairCost",
|
||||||
'needsReplacement',
|
"needsReplacement",
|
||||||
'replacementCost',
|
"replacementCost",
|
||||||
'proofOfOwnership',
|
"proofOfOwnership",
|
||||||
'actualReturnDateTime',
|
"actualReturnDateTime",
|
||||||
'imageFilenames',
|
"imageFilenames",
|
||||||
];
|
];
|
||||||
|
|
||||||
function extractAllowedDamageFields(body) {
|
function extractAllowedDamageFields(body) {
|
||||||
@@ -1295,9 +1338,13 @@ router.post("/:id/report-damage", authenticateToken, async (req, res, next) => {
|
|||||||
? damageInfo.imageFilenames
|
? damageInfo.imageFilenames
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const keyValidation = validateS3Keys(imageFilenamesArray, 'damage-reports', {
|
const keyValidation = validateS3Keys(
|
||||||
|
imageFilenamesArray,
|
||||||
|
"damage-reports",
|
||||||
|
{
|
||||||
maxKeys: IMAGE_LIMITS.damageReports,
|
maxKeys: IMAGE_LIMITS.damageReports,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
if (!keyValidation.valid) {
|
if (!keyValidation.valid) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: keyValidation.error,
|
error: keyValidation.error,
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ class AuthEmailService {
|
|||||||
const variables = {
|
const variables = {
|
||||||
recipientName: user.firstName || "there",
|
recipientName: user.firstName || "there",
|
||||||
verificationUrl: verificationUrl,
|
verificationUrl: verificationUrl,
|
||||||
|
verificationCode: verificationToken, // 6-digit code for display in email
|
||||||
};
|
};
|
||||||
|
|
||||||
const htmlContent = await this.templateManager.renderTemplate(
|
const htmlContent = await this.templateManager.renderTemplate(
|
||||||
|
|||||||
@@ -196,9 +196,29 @@
|
|||||||
|
|
||||||
<h1>Verify Your Email Address</h1>
|
<h1>Verify Your Email Address</h1>
|
||||||
|
|
||||||
<p>Thank you for registering with RentAll! To complete your account setup and start renting items, please verify your email address by clicking the button below.</p>
|
<p>Thank you for registering with RentAll! Use the verification code below to complete your account setup.</p>
|
||||||
|
|
||||||
<div style="text-align: center;">
|
<!-- Verification Code Display -->
|
||||||
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
|
<p style="margin-bottom: 10px; color: #6c757d; font-size: 14px;">Your verification code is:</p>
|
||||||
|
<div style="background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px 40px;
|
||||||
|
display: inline-block;
|
||||||
|
border: 2px dashed #28a745;">
|
||||||
|
<span style="font-size: 36px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 8px;
|
||||||
|
color: #28a745;
|
||||||
|
font-family: 'Courier New', monospace;">{{verificationCode}}</span>
|
||||||
|
</div>
|
||||||
|
<p style="margin-top: 10px; font-size: 14px; color: #6c757d;">
|
||||||
|
Enter this code in the app to verify your email
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin: 20px 0;">
|
||||||
|
<p style="color: #6c757d; margin-bottom: 10px; font-size: 14px;">Or click the button below:</p>
|
||||||
<a href="{{verificationUrl}}" class="button">Verify Email Address</a>
|
<a href="{{verificationUrl}}" class="button">Verify Email Address</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -210,7 +230,7 @@
|
|||||||
<p style="word-break: break-all; color: #667eea;">{{verificationUrl}}</p>
|
<p style="word-break: break-all; color: #667eea;">{{verificationUrl}}</p>
|
||||||
|
|
||||||
<div class="warning-box">
|
<div class="warning-box">
|
||||||
<p><strong>This link will expire in 24 hours.</strong> If you need a new verification link, you can request one from your account settings.</p>
|
<p><strong>This code will expire in 24 hours.</strong> If you need a new verification code, you can request one from your account settings.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p><strong>Didn't create an account?</strong> If you didn't register for a RentAll account, you can safely ignore this email.</p>
|
<p><strong>Didn't create an account?</strong> If you didn't register for a RentAll account, you can safely ignore this email.</p>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useAuth } from "../contexts/AuthContext";
|
|||||||
import PasswordStrengthMeter from "./PasswordStrengthMeter";
|
import PasswordStrengthMeter from "./PasswordStrengthMeter";
|
||||||
import PasswordInput from "./PasswordInput";
|
import PasswordInput from "./PasswordInput";
|
||||||
import ForgotPasswordModal from "./ForgotPasswordModal";
|
import ForgotPasswordModal from "./ForgotPasswordModal";
|
||||||
|
import VerificationCodeModal from "./VerificationCodeModal";
|
||||||
|
|
||||||
interface AuthModalProps {
|
interface AuthModalProps {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
@@ -23,6 +24,7 @@ const AuthModal: React.FC<AuthModalProps> = ({
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [showForgotPassword, setShowForgotPassword] = useState(false);
|
const [showForgotPassword, setShowForgotPassword] = useState(false);
|
||||||
|
const [showVerificationModal, setShowVerificationModal] = useState(false);
|
||||||
|
|
||||||
const { login, register } = useAuth();
|
const { login, register } = useAuth();
|
||||||
|
|
||||||
@@ -39,6 +41,7 @@ const AuthModal: React.FC<AuthModalProps> = ({
|
|||||||
setPassword("");
|
setPassword("");
|
||||||
setFirstName("");
|
setFirstName("");
|
||||||
setLastName("");
|
setLastName("");
|
||||||
|
setShowVerificationModal(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGoogleLogin = () => {
|
const handleGoogleLogin = () => {
|
||||||
@@ -68,28 +71,48 @@ const AuthModal: React.FC<AuthModalProps> = ({
|
|||||||
await login(email, password);
|
await login(email, password);
|
||||||
onHide();
|
onHide();
|
||||||
} else {
|
} else {
|
||||||
await register({
|
const response = await register({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
username: email.split("@")[0], // Generate username from email
|
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) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.message || "An error occurred");
|
setError(err.response?.data?.error || "An error occurred");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
if (!show && !showForgotPassword) return null;
|
if (!show && !showForgotPassword && !showVerificationModal) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!showForgotPassword && (
|
{/* Verification Code Modal - shown after signup */}
|
||||||
|
{showVerificationModal && (
|
||||||
|
<VerificationCodeModal
|
||||||
|
show={showVerificationModal}
|
||||||
|
onHide={() => {
|
||||||
|
setShowVerificationModal(false);
|
||||||
|
resetModal();
|
||||||
|
onHide();
|
||||||
|
}}
|
||||||
|
email={email}
|
||||||
|
onVerified={() => {
|
||||||
|
setShowVerificationModal(false);
|
||||||
|
resetModal();
|
||||||
|
onHide();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!showForgotPassword && !showVerificationModal && (
|
||||||
<div
|
<div
|
||||||
className="modal show d-block"
|
className="modal show d-block"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface CommentThreadProps {
|
|||||||
isAdmin?: boolean;
|
isAdmin?: boolean;
|
||||||
onAdminDelete?: (commentId: string) => Promise<void>;
|
onAdminDelete?: (commentId: string) => Promise<void>;
|
||||||
onAdminRestore?: (commentId: string) => Promise<void>;
|
onAdminRestore?: (commentId: string) => Promise<void>;
|
||||||
|
canReply?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CommentThread: React.FC<CommentThreadProps> = ({
|
const CommentThread: React.FC<CommentThreadProps> = ({
|
||||||
@@ -33,6 +34,7 @@ const CommentThread: React.FC<CommentThreadProps> = ({
|
|||||||
isAdmin = false,
|
isAdmin = false,
|
||||||
onAdminDelete,
|
onAdminDelete,
|
||||||
onAdminRestore,
|
onAdminRestore,
|
||||||
|
canReply = true,
|
||||||
}) => {
|
}) => {
|
||||||
const [showReplyForm, setShowReplyForm] = useState(false);
|
const [showReplyForm, setShowReplyForm] = useState(false);
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
@@ -299,7 +301,7 @@ const CommentThread: React.FC<CommentThreadProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="d-flex gap-2">
|
<div className="d-flex gap-2">
|
||||||
{!isEditing && canNest && (
|
{!isEditing && canNest && canReply && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-sm btn-link text-decoration-none p-0"
|
className="btn btn-sm btn-link text-decoration-none p-0"
|
||||||
onClick={() => setShowReplyForm(!showReplyForm)}
|
onClick={() => setShowReplyForm(!showReplyForm)}
|
||||||
@@ -394,6 +396,7 @@ const CommentThread: React.FC<CommentThreadProps> = ({
|
|||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
onAdminDelete={onAdminDelete}
|
onAdminDelete={onAdminDelete}
|
||||||
onAdminRestore={onAdminRestore}
|
onAdminRestore={onAdminRestore}
|
||||||
|
canReply={canReply}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
249
frontend/src/components/VerificationCodeModal.tsx
Normal file
249
frontend/src/components/VerificationCodeModal.tsx
Normal file
@@ -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<VerificationCodeModalProps> = ({
|
||||||
|
show,
|
||||||
|
onHide,
|
||||||
|
email,
|
||||||
|
onVerified,
|
||||||
|
}) => {
|
||||||
|
const [code, setCode] = useState<string[]>(["", "", "", "", "", ""]);
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className="modal show d-block"
|
||||||
|
tabIndex={-1}
|
||||||
|
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
||||||
|
>
|
||||||
|
<div className="modal-dialog modal-dialog-centered">
|
||||||
|
<div className="modal-content">
|
||||||
|
<div className="modal-header border-0 pb-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-close"
|
||||||
|
onClick={onHide}
|
||||||
|
aria-label="Close"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body px-4 pb-4 text-center">
|
||||||
|
<i
|
||||||
|
className="bi bi-envelope-check text-success"
|
||||||
|
style={{ fontSize: "3rem" }}
|
||||||
|
></i>
|
||||||
|
<h4 className="mt-3">Verify Your Email</h4>
|
||||||
|
<p className="text-muted">
|
||||||
|
We sent a 6-digit code to <strong>{email}</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-danger py-2" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{resendSuccess && (
|
||||||
|
<div className="alert alert-success py-2" role="alert">
|
||||||
|
New code sent! Check your email.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 6-digit code input */}
|
||||||
|
<div
|
||||||
|
className="d-flex justify-content-center gap-2 my-4"
|
||||||
|
onPaste={handlePaste}
|
||||||
|
>
|
||||||
|
{code.map((digit, index) => (
|
||||||
|
<input
|
||||||
|
key={index}
|
||||||
|
ref={(el) => { 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}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-success w-100 py-3"
|
||||||
|
onClick={() => handleVerify(code.join(""))}
|
||||||
|
disabled={loading || code.some((d) => d === "")}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<span className="spinner-border spinner-border-sm me-2" />
|
||||||
|
Verifying...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Verify Email"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<p className="text-muted small mb-2">Didn't receive the code?</p>
|
||||||
|
<button
|
||||||
|
className="btn btn-link text-decoration-none p-0"
|
||||||
|
onClick={handleResend}
|
||||||
|
disabled={resending || resendCooldown > 0}
|
||||||
|
>
|
||||||
|
{resendCooldown > 0
|
||||||
|
? `Resend in ${resendCooldown}s`
|
||||||
|
: resending
|
||||||
|
? "Sending..."
|
||||||
|
: "Resend Code"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted small mt-3">
|
||||||
|
Check your spam folder if you don't see the email.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VerificationCodeModal;
|
||||||
@@ -13,11 +13,15 @@ import {
|
|||||||
resetCSRFToken,
|
resetCSRFToken,
|
||||||
} from "../services/api";
|
} from "../services/api";
|
||||||
|
|
||||||
|
interface RegisterResponse {
|
||||||
|
verificationEmailSent?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
login: (email: string, password: string) => Promise<void>;
|
login: (email: string, password: string) => Promise<void>;
|
||||||
register: (data: any) => Promise<void>;
|
register: (data: any) => Promise<RegisterResponse>;
|
||||||
googleLogin: (code: string) => Promise<void>;
|
googleLogin: (code: string) => Promise<void>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
updateUser: (user: User) => void;
|
updateUser: (user: User) => void;
|
||||||
@@ -98,11 +102,14 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
|||||||
await fetchCSRFToken();
|
await fetchCSRFToken();
|
||||||
};
|
};
|
||||||
|
|
||||||
const register = async (data: any) => {
|
const register = async (data: any): Promise<RegisterResponse> => {
|
||||||
const response = await authAPI.register(data);
|
const response = await authAPI.register(data);
|
||||||
setUser(response.data.user);
|
setUser(response.data.user);
|
||||||
// Fetch new CSRF token after registration
|
// Fetch new CSRF token after registration
|
||||||
await fetchCSRFToken();
|
await fetchCSRFToken();
|
||||||
|
return {
|
||||||
|
verificationEmailSent: response.data.verificationEmailSent,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const googleLogin = async (code: string) => {
|
const googleLogin = async (code: string) => {
|
||||||
|
|||||||
@@ -5,18 +5,21 @@ import { forumAPI, addressAPI } from "../services/api";
|
|||||||
import { uploadFiles, getPublicImageUrl } from "../services/uploadService";
|
import { uploadFiles, getPublicImageUrl } from "../services/uploadService";
|
||||||
import TagInput from "../components/TagInput";
|
import TagInput from "../components/TagInput";
|
||||||
import ForumImageUpload from "../components/ForumImageUpload";
|
import ForumImageUpload from "../components/ForumImageUpload";
|
||||||
|
import VerificationCodeModal from "../components/VerificationCodeModal";
|
||||||
import { Address, ForumPost } from "../types";
|
import { Address, ForumPost } from "../types";
|
||||||
|
|
||||||
const CreateForumPost: React.FC = () => {
|
const CreateForumPost: React.FC = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const isEditMode = !!id;
|
const isEditMode = !!id;
|
||||||
const { user } = useAuth();
|
const { user, checkAuth } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [loading, setLoading] = useState(isEditMode);
|
const [loading, setLoading] = useState(isEditMode);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [userAddresses, setUserAddresses] = useState<Address[]>([]);
|
const [userAddresses, setUserAddresses] = useState<Address[]>([]);
|
||||||
const [existingImageKeys, setExistingImageKeys] = useState<string[]>([]);
|
const [existingImageKeys, setExistingImageKeys] = useState<string[]>([]);
|
||||||
|
const [showVerificationModal, setShowVerificationModal] = useState(false);
|
||||||
|
const [pendingSubmit, setPendingSubmit] = useState(false);
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
title: "",
|
title: "",
|
||||||
@@ -245,11 +248,32 @@ const CreateForumPost: React.FC = () => {
|
|||||||
navigate(`/forum/${response.data.id}`);
|
navigate(`/forum/${response.data.id}`);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} 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`);
|
setError(err.response?.data?.error || err.message || `Failed to ${isEditMode ? 'update' : 'create'} post`);
|
||||||
setIsSubmitting(false);
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="container mt-4">
|
<div className="container mt-4">
|
||||||
@@ -310,6 +334,23 @@ const CreateForumPost: React.FC = () => {
|
|||||||
</ol>
|
</ol>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{/* Email Verification Warning Banner */}
|
||||||
|
{user && !user.isVerified && (
|
||||||
|
<div className="alert alert-warning d-flex align-items-center mb-4">
|
||||||
|
<i className="bi bi-exclamation-triangle-fill me-2"></i>
|
||||||
|
<div className="flex-grow-1">
|
||||||
|
<strong>Email verification required.</strong> Verify your email to
|
||||||
|
create forum posts.
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn btn-warning btn-sm ms-3"
|
||||||
|
onClick={() => setShowVerificationModal(true)}
|
||||||
|
>
|
||||||
|
Verify Now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-lg-8 mx-auto">
|
<div className="col-lg-8 mx-auto">
|
||||||
{/* Guidelines Card - only show for new posts */}
|
{/* Guidelines Card - only show for new posts */}
|
||||||
@@ -542,6 +583,19 @@ const CreateForumPost: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Email Verification Modal */}
|
||||||
|
{user && (
|
||||||
|
<VerificationCodeModal
|
||||||
|
show={showVerificationModal}
|
||||||
|
onHide={() => {
|
||||||
|
setShowVerificationModal(false);
|
||||||
|
setPendingSubmit(false);
|
||||||
|
}}
|
||||||
|
email={user.email || ""}
|
||||||
|
onVerified={handleVerificationSuccess}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import LocationForm from "../components/LocationForm";
|
|||||||
import DeliveryOptions from "../components/DeliveryOptions";
|
import DeliveryOptions from "../components/DeliveryOptions";
|
||||||
import PricingForm from "../components/PricingForm";
|
import PricingForm from "../components/PricingForm";
|
||||||
import RulesForm from "../components/RulesForm";
|
import RulesForm from "../components/RulesForm";
|
||||||
|
import VerificationCodeModal from "../components/VerificationCodeModal";
|
||||||
import { Address } from "../types";
|
import { Address } from "../types";
|
||||||
import { IMAGE_LIMITS } from "../config/imageLimits";
|
import { IMAGE_LIMITS } from "../config/imageLimits";
|
||||||
|
|
||||||
@@ -48,9 +49,11 @@ interface ItemFormData {
|
|||||||
|
|
||||||
const CreateItem: React.FC = () => {
|
const CreateItem: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user } = useAuth();
|
const { user, checkAuth } = useAuth();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [showVerificationModal, setShowVerificationModal] = useState(false);
|
||||||
|
const [pendingSubmit, setPendingSubmit] = useState(false);
|
||||||
const [formData, setFormData] = useState<ItemFormData>({
|
const [formData, setFormData] = useState<ItemFormData>({
|
||||||
name: "",
|
name: "",
|
||||||
description: "",
|
description: "",
|
||||||
@@ -265,12 +268,38 @@ const CreateItem: React.FC = () => {
|
|||||||
|
|
||||||
navigate(`/items/${response.data.id}`);
|
navigate(`/items/${response.data.id}`);
|
||||||
} catch (err: any) {
|
} 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 {
|
} finally {
|
||||||
setLoading(false);
|
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<HTMLFormElement>;
|
||||||
|
handleSubmit(syntheticEvent);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleChange = (
|
const handleChange = (
|
||||||
e: React.ChangeEvent<
|
e: React.ChangeEvent<
|
||||||
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
|
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
|
||||||
@@ -401,6 +430,26 @@ const CreateItem: React.FC = () => {
|
|||||||
<div className="col-md-8">
|
<div className="col-md-8">
|
||||||
<h1>List an Item for Rent</h1>
|
<h1>List an Item for Rent</h1>
|
||||||
|
|
||||||
|
{/* Email verification warning banner */}
|
||||||
|
{user && !user.isVerified && (
|
||||||
|
<div
|
||||||
|
className="alert alert-warning d-flex align-items-center"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<i className="bi bi-exclamation-triangle-fill me-2"></i>
|
||||||
|
<div className="flex-grow-1">
|
||||||
|
Verify your email to create a listing.
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-warning btn-sm"
|
||||||
|
onClick={() => setShowVerificationModal(true)}
|
||||||
|
>
|
||||||
|
Verify Now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="alert alert-danger" role="alert">
|
<div className="alert alert-danger" role="alert">
|
||||||
{error}
|
{error}
|
||||||
@@ -484,10 +533,7 @@ const CreateItem: React.FC = () => {
|
|||||||
onTierToggle={handleTierToggle}
|
onTierToggle={handleTierToggle}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<RulesForm
|
<RulesForm rules={formData.rules || ""} onChange={handleChange} />
|
||||||
rules={formData.rules || ""}
|
|
||||||
onChange={handleChange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="d-grid gap-2 mb-5">
|
<div className="d-grid gap-2 mb-5">
|
||||||
<button
|
<button
|
||||||
@@ -508,6 +554,19 @@ const CreateItem: React.FC = () => {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Verification Code Modal */}
|
||||||
|
{showVerificationModal && user && (
|
||||||
|
<VerificationCodeModal
|
||||||
|
show={showVerificationModal}
|
||||||
|
onHide={() => {
|
||||||
|
setShowVerificationModal(false);
|
||||||
|
setPendingSubmit(false);
|
||||||
|
}}
|
||||||
|
email={user.email || ""}
|
||||||
|
onVerified={handleVerificationSuccess}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ import PostStatusBadge from '../components/PostStatusBadge';
|
|||||||
import CommentThread from '../components/CommentThread';
|
import CommentThread from '../components/CommentThread';
|
||||||
import CommentForm from '../components/CommentForm';
|
import CommentForm from '../components/CommentForm';
|
||||||
import AuthButton from '../components/AuthButton';
|
import AuthButton from '../components/AuthButton';
|
||||||
|
import VerificationCodeModal from '../components/VerificationCodeModal';
|
||||||
|
|
||||||
const ForumPostDetail: React.FC = () => {
|
const ForumPostDetail: React.FC = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const { user } = useAuth();
|
const { user, checkAuth } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const [post, setPost] = useState<ForumPost | null>(null);
|
const [post, setPost] = useState<ForumPost | null>(null);
|
||||||
@@ -20,6 +21,7 @@ const ForumPostDetail: React.FC = () => {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [actionLoading, setActionLoading] = useState(false);
|
const [actionLoading, setActionLoading] = useState(false);
|
||||||
const [showAdminModal, setShowAdminModal] = useState(false);
|
const [showAdminModal, setShowAdminModal] = useState(false);
|
||||||
|
const [showVerificationModal, setShowVerificationModal] = useState(false);
|
||||||
const [adminAction, setAdminAction] = useState<{
|
const [adminAction, setAdminAction] = useState<{
|
||||||
type: 'deletePost' | 'deleteComment' | 'restorePost' | 'restoreComment' | 'closePost' | 'reopenPost';
|
type: 'deletePost' | 'deleteComment' | 'restorePost' | 'restoreComment' | 'closePost' | 'reopenPost';
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -68,6 +70,14 @@ const ForumPostDetail: React.FC = () => {
|
|||||||
});
|
});
|
||||||
await fetchPost(); // Refresh to get new comment
|
await fetchPost(); // Refresh to get new comment
|
||||||
} catch (err: any) {
|
} 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');
|
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
|
await fetchPost(); // Refresh to get new reply
|
||||||
} catch (err: any) {
|
} 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');
|
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 (
|
const handleEditComment = async (
|
||||||
commentId: string,
|
commentId: string,
|
||||||
content: string,
|
content: string,
|
||||||
@@ -490,6 +513,7 @@ const ForumPostDetail: React.FC = () => {
|
|||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
onAdminDelete={handleAdminDeleteComment}
|
onAdminDelete={handleAdminDeleteComment}
|
||||||
onAdminRestore={handleAdminRestoreComment}
|
onAdminRestore={handleAdminRestoreComment}
|
||||||
|
canReply={!!user}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -529,6 +553,21 @@ const ForumPostDetail: React.FC = () => {
|
|||||||
|
|
||||||
{post.status !== 'closed' && user ? (
|
{post.status !== 'closed' && user ? (
|
||||||
<div>
|
<div>
|
||||||
|
{/* Email Verification Warning for unverified users */}
|
||||||
|
{!user.isVerified && (
|
||||||
|
<div className="alert alert-warning d-flex align-items-center mb-3">
|
||||||
|
<i className="bi bi-exclamation-triangle-fill me-2"></i>
|
||||||
|
<div className="flex-grow-1">
|
||||||
|
<strong>Email verification required.</strong> Verify your email to comment.
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn btn-warning btn-sm ms-3"
|
||||||
|
onClick={() => setShowVerificationModal(true)}
|
||||||
|
>
|
||||||
|
Verify Now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<h6>Add a comment</h6>
|
<h6>Add a comment</h6>
|
||||||
<CommentForm
|
<CommentForm
|
||||||
onSubmit={handleAddComment}
|
onSubmit={handleAddComment}
|
||||||
@@ -673,6 +712,16 @@ const ForumPostDetail: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Email Verification Modal */}
|
||||||
|
{user && (
|
||||||
|
<VerificationCodeModal
|
||||||
|
show={showVerificationModal}
|
||||||
|
onHide={() => setShowVerificationModal(false)}
|
||||||
|
email={user.email || ""}
|
||||||
|
onVerified={handleVerificationSuccess}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,15 +5,18 @@ import { useAuth } from "../contexts/AuthContext";
|
|||||||
import { itemAPI, rentalAPI } from "../services/api";
|
import { itemAPI, rentalAPI } from "../services/api";
|
||||||
import { getPublicImageUrl } from "../services/uploadService";
|
import { getPublicImageUrl } from "../services/uploadService";
|
||||||
import EmbeddedStripeCheckout from "../components/EmbeddedStripeCheckout";
|
import EmbeddedStripeCheckout from "../components/EmbeddedStripeCheckout";
|
||||||
|
import VerificationCodeModal from "../components/VerificationCodeModal";
|
||||||
|
|
||||||
const RentItem: React.FC = () => {
|
const RentItem: React.FC = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user } = useAuth();
|
const { user, checkAuth } = useAuth();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const [item, setItem] = useState<Item | null>(null);
|
const [item, setItem] = useState<Item | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showVerificationModal, setShowVerificationModal] = useState(false);
|
||||||
|
const [pendingSubmit, setPendingSubmit] = useState(false);
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
deliveryMethod: "pickup" as "pickup" | "delivery",
|
deliveryMethod: "pickup" as "pickup" | "delivery",
|
||||||
@@ -163,12 +166,31 @@ const RentItem: React.FC = () => {
|
|||||||
await rentalAPI.createRental(rentalData);
|
await rentalAPI.createRental(rentalData);
|
||||||
setCompleted(true);
|
setCompleted(true);
|
||||||
} catch (error: any) {
|
} 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(
|
setError(
|
||||||
error.response?.data?.error || "Failed to create rental request"
|
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 = (
|
const handleChange = (
|
||||||
e: React.ChangeEvent<
|
e: React.ChangeEvent<
|
||||||
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
|
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
|
||||||
@@ -213,6 +235,23 @@ const RentItem: React.FC = () => {
|
|||||||
<div className="col-md-8">
|
<div className="col-md-8">
|
||||||
<h1>Renting {item.name}</h1>
|
<h1>Renting {item.name}</h1>
|
||||||
|
|
||||||
|
{/* Email Verification Warning Banner */}
|
||||||
|
{user && !user.isVerified && (
|
||||||
|
<div className="alert alert-warning d-flex align-items-center mb-4">
|
||||||
|
<i className="bi bi-exclamation-triangle-fill me-2"></i>
|
||||||
|
<div className="flex-grow-1">
|
||||||
|
<strong>Email verification required.</strong> Verify your email
|
||||||
|
to book rentals.
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn btn-warning btn-sm ms-3"
|
||||||
|
onClick={() => setShowVerificationModal(true)}
|
||||||
|
>
|
||||||
|
Verify Now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="alert alert-danger" role="alert">
|
<div className="alert alert-danger" role="alert">
|
||||||
{error}
|
{error}
|
||||||
@@ -426,6 +465,19 @@ const RentItem: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Email Verification Modal */}
|
||||||
|
{user && (
|
||||||
|
<VerificationCodeModal
|
||||||
|
show={showVerificationModal}
|
||||||
|
onHide={() => {
|
||||||
|
setShowVerificationModal(false);
|
||||||
|
setPendingSubmit(false);
|
||||||
|
}}
|
||||||
|
email={user.email || ""}
|
||||||
|
onVerified={handleVerificationSuccess}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,93 +1,234 @@
|
|||||||
import React, { useEffect, useState, useRef } from 'react';
|
import React, { useEffect, useState, useRef } from "react";
|
||||||
import { useNavigate, useSearchParams, Link } from 'react-router-dom';
|
import { useNavigate, useSearchParams, Link } from "react-router-dom";
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import { authAPI } from '../services/api';
|
import { authAPI } from "../services/api";
|
||||||
|
|
||||||
const VerifyEmail: React.FC = () => {
|
const VerifyEmail: React.FC = () => {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { checkAuth, user } = useAuth();
|
const { checkAuth, user, loading: authLoading } = useAuth();
|
||||||
const [error, setError] = useState<string>('');
|
const [error, setError] = useState<string>("");
|
||||||
|
const [errorCode, setErrorCode] = useState<string>("");
|
||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
const [processing, setProcessing] = useState(true);
|
const [processing, setProcessing] = useState(true);
|
||||||
const [resending, setResending] = useState(false);
|
const [resending, setResending] = useState(false);
|
||||||
|
const [resendSuccess, setResendSuccess] = useState(false);
|
||||||
|
const [resendCooldown, setResendCooldown] = useState(0);
|
||||||
|
const [showManualEntry, setShowManualEntry] = useState(false);
|
||||||
|
const [code, setCode] = useState<string[]>(["", "", "", "", "", ""]);
|
||||||
|
const [verifying, setVerifying] = useState(false);
|
||||||
const hasProcessed = useRef(false);
|
const hasProcessed = useRef(false);
|
||||||
|
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
||||||
|
|
||||||
|
// Handle resend cooldown timer
|
||||||
useEffect(() => {
|
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 () => {
|
const handleVerification = async () => {
|
||||||
// Prevent double execution in React StrictMode
|
// Prevent double execution in React StrictMode
|
||||||
if (hasProcessed.current) {
|
if (hasProcessed.current) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
hasProcessed.current = true;
|
hasProcessed.current = true;
|
||||||
|
|
||||||
try {
|
const token = searchParams.get("token");
|
||||||
const token = searchParams.get('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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no token in URL, show manual entry form
|
||||||
if (!token) {
|
if (!token) {
|
||||||
setError('No verification token provided.');
|
setShowManualEntry(true);
|
||||||
setProcessing(false);
|
setProcessing(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the email with the token
|
// Auto-verify with token from URL
|
||||||
|
try {
|
||||||
await authAPI.verifyEmail(token);
|
await authAPI.verifyEmail(token);
|
||||||
|
|
||||||
setSuccess(true);
|
setSuccess(true);
|
||||||
setProcessing(false);
|
setProcessing(false);
|
||||||
|
|
||||||
// Refresh user data to update isVerified status
|
|
||||||
await checkAuth();
|
await checkAuth();
|
||||||
|
setTimeout(() => navigate("/", { replace: true }), 3000);
|
||||||
// Redirect to home after 3 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
navigate('/', { replace: true });
|
|
||||||
}, 3000);
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Email verification error:', err);
|
handleVerificationError(err);
|
||||||
const errorData = err.response?.data;
|
|
||||||
|
|
||||||
if (errorData?.code === 'VERIFICATION_TOKEN_EXPIRED') {
|
|
||||||
setError('Your verification link has expired. Please request a new one.');
|
|
||||||
} else if (errorData?.code === 'VERIFICATION_TOKEN_INVALID') {
|
|
||||||
setError('Invalid verification link. The link may have already been used or is incorrect.');
|
|
||||||
} else if (errorData?.code === 'ALREADY_VERIFIED') {
|
|
||||||
setError('Your email is already verified.');
|
|
||||||
} else {
|
|
||||||
setError(errorData?.error || 'Failed to verify email. Please try again.');
|
|
||||||
}
|
|
||||||
|
|
||||||
setProcessing(false);
|
setProcessing(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
handleVerification();
|
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 () => {
|
const handleResendVerification = async () => {
|
||||||
setResending(true);
|
setResending(true);
|
||||||
setError('');
|
setError("");
|
||||||
|
setErrorCode("");
|
||||||
|
setResendSuccess(false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await authAPI.resendVerification();
|
await authAPI.resendVerification();
|
||||||
setError('');
|
setResendSuccess(true);
|
||||||
alert('Verification email sent! Please check your inbox.');
|
setResendCooldown(60);
|
||||||
|
setCode(["", "", "", "", "", ""]);
|
||||||
|
inputRefs.current[0]?.focus();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Resend verification error:', err);
|
|
||||||
const errorData = err.response?.data;
|
const errorData = err.response?.data;
|
||||||
|
if (errorData?.code === "ALREADY_VERIFIED") {
|
||||||
if (errorData?.code === 'ALREADY_VERIFIED') {
|
setError("Your email is already verified.");
|
||||||
setError('Your email is already verified.');
|
} else if (err.response?.status === 429) {
|
||||||
} else if (errorData?.code === 'NO_TOKEN') {
|
setError("Please wait before requesting another code.");
|
||||||
setError('You must be logged in to resend the verification email.');
|
|
||||||
} else {
|
} else {
|
||||||
setError(errorData?.error || 'Failed to resend verification email. Please try again.');
|
setError(
|
||||||
|
errorData?.error ||
|
||||||
|
"Failed to resend verification email. Please try again."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setResending(false);
|
setResending(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Show loading while auth is initializing
|
||||||
|
if (authLoading) {
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<div className="row justify-content-center mt-5">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-body text-center py-5">
|
||||||
|
<div className="spinner-border text-primary mb-3" role="status">
|
||||||
|
<span className="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<h5>Loading...</h5>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<div className="row justify-content-center mt-5">
|
<div className="row justify-content-center mt-5">
|
||||||
@@ -96,38 +237,136 @@ const VerifyEmail: React.FC = () => {
|
|||||||
<div className="card-body text-center py-5">
|
<div className="card-body text-center py-5">
|
||||||
{processing ? (
|
{processing ? (
|
||||||
<>
|
<>
|
||||||
<div className="spinner-border text-primary mb-3" role="status">
|
<div
|
||||||
|
className="spinner-border text-primary mb-3"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
<span className="visually-hidden">Loading...</span>
|
<span className="visually-hidden">Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
<h5>Verifying Your Email...</h5>
|
<h5>Verifying Your Email...</h5>
|
||||||
<p className="text-muted">Please wait while we verify your email address.</p>
|
<p className="text-muted">
|
||||||
|
Please wait while we verify your email address.
|
||||||
|
</p>
|
||||||
</>
|
</>
|
||||||
) : success ? (
|
) : success ? (
|
||||||
<>
|
<>
|
||||||
<i className="bi bi-check-circle text-success" style={{ fontSize: '3rem' }}></i>
|
<i
|
||||||
|
className="bi bi-check-circle text-success"
|
||||||
|
style={{ fontSize: "3rem" }}
|
||||||
|
></i>
|
||||||
<h5 className="mt-3">Email Verified Successfully!</h5>
|
<h5 className="mt-3">Email Verified Successfully!</h5>
|
||||||
<p className="text-muted">
|
<p className="text-muted">
|
||||||
Your email has been verified. You will be redirected to the home page shortly.
|
Your email has been verified. You will be redirected
|
||||||
|
shortly.
|
||||||
</p>
|
</p>
|
||||||
<Link to="/" className="btn btn-primary mt-3">
|
<Link to="/" className="btn btn-success mt-3">
|
||||||
Go to Home
|
Go to Home
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
|
) : showManualEntry ? (
|
||||||
|
<>
|
||||||
|
<i
|
||||||
|
className="bi bi-envelope-check text-success"
|
||||||
|
style={{ fontSize: "3rem" }}
|
||||||
|
></i>
|
||||||
|
<h5 className="mt-3">Enter Verification Code</h5>
|
||||||
|
<p className="text-muted">
|
||||||
|
Enter the 6-digit code sent to your email
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-danger py-2" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{resendSuccess && (
|
||||||
|
<div className="alert alert-success py-2" role="alert">
|
||||||
|
New code sent! Check your email.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 6-digit code input */}
|
||||||
|
<div
|
||||||
|
className="d-flex justify-content-center gap-2 my-4"
|
||||||
|
onPaste={handlePaste}
|
||||||
|
>
|
||||||
|
{code.map((digit, index) => (
|
||||||
|
<input
|
||||||
|
key={index}
|
||||||
|
ref={(el) => { 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}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-success w-100 py-3"
|
||||||
|
onClick={() => handleManualVerify(code.join(""))}
|
||||||
|
disabled={verifying || code.some((d) => d === "")}
|
||||||
|
>
|
||||||
|
{verifying ? (
|
||||||
|
<>
|
||||||
|
<span className="spinner-border spinner-border-sm me-2" />
|
||||||
|
Verifying...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Verify Email"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<p className="text-muted small mb-2">
|
||||||
|
Didn't receive the code?
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="btn btn-link text-decoration-none p-0"
|
||||||
|
onClick={handleResendVerification}
|
||||||
|
disabled={resending || resendCooldown > 0}
|
||||||
|
>
|
||||||
|
{resendCooldown > 0
|
||||||
|
? `Resend in ${resendCooldown}s`
|
||||||
|
: resending
|
||||||
|
? "Sending..."
|
||||||
|
: "Resend Code"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted small mt-3">
|
||||||
|
Check your spam folder if you don't see the email.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="btn btn-outline-secondary mt-3"
|
||||||
|
>
|
||||||
|
Return to Home
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<>
|
<>
|
||||||
<i className="bi bi-exclamation-circle text-danger" style={{ fontSize: '3rem' }}></i>
|
<i
|
||||||
|
className="bi bi-exclamation-circle text-danger"
|
||||||
|
style={{ fontSize: "3rem" }}
|
||||||
|
></i>
|
||||||
<h5 className="mt-3">Verification Failed</h5>
|
<h5 className="mt-3">Verification Failed</h5>
|
||||||
<p className="text-danger">{error}</p>
|
<p className="text-danger">{error}</p>
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
{user && !error.includes('already verified') && (
|
|
||||||
<button
|
|
||||||
className="btn btn-primary me-2"
|
|
||||||
onClick={handleResendVerification}
|
|
||||||
disabled={resending}
|
|
||||||
>
|
|
||||||
{resending ? 'Sending...' : 'Resend Verification Email'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<Link to="/" className="btn btn-outline-secondary">
|
<Link to="/" className="btn btn-outline-secondary">
|
||||||
Return to Home
|
Return to Home
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ export const authAPI = {
|
|||||||
refresh: () => api.post("/auth/refresh"),
|
refresh: () => api.post("/auth/refresh"),
|
||||||
getCSRFToken: () => api.get("/auth/csrf-token"),
|
getCSRFToken: () => api.get("/auth/csrf-token"),
|
||||||
getStatus: () => api.get("/auth/status"),
|
getStatus: () => api.get("/auth/status"),
|
||||||
verifyEmail: (token: string) => api.post("/auth/verify-email", { token }),
|
verifyEmail: (code: string) => api.post("/auth/verify-email", { code }),
|
||||||
resendVerification: () => api.post("/auth/resend-verification"),
|
resendVerification: () => api.post("/auth/resend-verification"),
|
||||||
forgotPassword: (email: string) =>
|
forgotPassword: (email: string) =>
|
||||||
api.post("/auth/forgot-password", { email }),
|
api.post("/auth/forgot-password", { email }),
|
||||||
|
|||||||
Reference in New Issue
Block a user