diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js
index fa61c85..80ab961 100644
--- a/backend/middleware/auth.js
+++ b/backend/middleware/auth.js
@@ -33,6 +33,14 @@ const authenticateToken = async (req, res, next) => {
});
}
+ // Validate JWT version to invalidate old tokens after password change
+ if (decoded.jwtVersion !== user.jwtVersion) {
+ return res.status(401).json({
+ error: "Session expired due to password change. Please log in again.",
+ code: "JWT_VERSION_MISMATCH",
+ });
+ }
+
req.user = user;
next();
} catch (error) {
@@ -85,6 +93,12 @@ const optionalAuth = async (req, res, next) => {
return next();
}
+ // Validate JWT version to invalidate old tokens after password change
+ if (decoded.jwtVersion !== user.jwtVersion) {
+ req.user = null;
+ return next();
+ }
+
req.user = user;
next();
} catch (error) {
diff --git a/backend/middleware/validation.js b/backend/middleware/validation.js
index 05d6855..5d499fb 100644
--- a/backend/middleware/validation.js
+++ b/backend/middleware/validation.js
@@ -260,6 +260,56 @@ const validatePasswordChange = [
handleValidationErrors,
];
+// Forgot password validation
+const validateForgotPassword = [
+ body("email")
+ .isEmail()
+ .normalizeEmail()
+ .withMessage("Please provide a valid email address")
+ .isLength({ max: 255 })
+ .withMessage("Email must be less than 255 characters"),
+
+ handleValidationErrors,
+];
+
+// Reset password validation
+const validateResetPassword = [
+ body("token")
+ .notEmpty()
+ .withMessage("Reset token is required")
+ .isLength({ min: 64, max: 64 })
+ .withMessage("Invalid reset token format"),
+
+ body("newPassword")
+ .isLength({ min: 8, max: 128 })
+ .withMessage("Password must be between 8 and 128 characters")
+ .matches(passwordStrengthRegex)
+ .withMessage(
+ "Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character"
+ )
+ .custom((value) => {
+ if (commonPasswords.includes(value.toLowerCase())) {
+ throw new Error(
+ "Password is too common. Please choose a stronger password"
+ );
+ }
+ return true;
+ }),
+
+ handleValidationErrors,
+];
+
+// Verify reset token validation
+const validateVerifyResetToken = [
+ body("token")
+ .notEmpty()
+ .withMessage("Reset token is required")
+ .isLength({ min: 64, max: 64 })
+ .withMessage("Invalid reset token format"),
+
+ handleValidationErrors,
+];
+
module.exports = {
sanitizeInput,
handleValidationErrors,
@@ -268,4 +318,7 @@ module.exports = {
validateGoogleAuth,
validateProfileUpdate,
validatePasswordChange,
+ validateForgotPassword,
+ validateResetPassword,
+ validateVerifyResetToken,
};
diff --git a/backend/models/User.js b/backend/models/User.js
index a6eafcc..b0b53a5 100644
--- a/backend/models/User.js
+++ b/backend/models/User.js
@@ -84,6 +84,14 @@ const User = sequelize.define(
type: DataTypes.DATE,
allowNull: true,
},
+ passwordResetToken: {
+ type: DataTypes.STRING,
+ allowNull: true,
+ },
+ passwordResetTokenExpiry: {
+ type: DataTypes.DATE,
+ allowNull: true,
+ },
defaultAvailableAfter: {
type: DataTypes.STRING,
defaultValue: "09:00",
@@ -124,6 +132,11 @@ const User = sequelize.define(
type: DataTypes.DATE,
allowNull: true,
},
+ jwtVersion: {
+ type: DataTypes.INTEGER,
+ defaultValue: 0,
+ allowNull: false,
+ },
},
{
hooks: {
@@ -222,4 +235,59 @@ User.prototype.verifyEmail = async function () {
});
};
+// Password reset methods
+User.prototype.generatePasswordResetToken = async function () {
+ const crypto = require("crypto");
+ // Generate random token for email URL
+ const token = crypto.randomBytes(32).toString("hex");
+ // Hash token before storing in database (SHA-256)
+ const hashedToken = crypto.createHash("sha256").update(token).digest("hex");
+ const expiry = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
+
+ await this.update({
+ passwordResetToken: hashedToken,
+ passwordResetTokenExpiry: expiry,
+ });
+
+ // Return plain token for email URL (not stored in DB)
+ return token;
+};
+
+User.prototype.isPasswordResetTokenValid = function (token) {
+ if (!this.passwordResetToken || !this.passwordResetTokenExpiry) {
+ return false;
+ }
+
+ // Check if token is expired first
+ if (new Date() > new Date(this.passwordResetTokenExpiry)) {
+ return false;
+ }
+
+ const crypto = require("crypto");
+
+ // Hash the incoming token to compare with stored hash
+ const hashedToken = crypto.createHash("sha256").update(token).digest("hex");
+
+ // Use timing-safe comparison to prevent timing attacks
+ const storedTokenBuffer = Buffer.from(this.passwordResetToken, "hex");
+ const hashedTokenBuffer = Buffer.from(hashedToken, "hex");
+
+ // Ensure buffers are same length for timingSafeEqual
+ if (storedTokenBuffer.length !== hashedTokenBuffer.length) {
+ return false;
+ }
+
+ return crypto.timingSafeEqual(storedTokenBuffer, hashedTokenBuffer);
+};
+
+User.prototype.resetPassword = async function (newPassword) {
+ return this.update({
+ password: newPassword,
+ passwordResetToken: null,
+ passwordResetTokenExpiry: null,
+ // Increment JWT version to invalidate all existing sessions
+ jwtVersion: this.jwtVersion + 1,
+ });
+};
+
module.exports = User;
diff --git a/backend/routes/auth.js b/backend/routes/auth.js
index 8b7773c..2a14f1e 100644
--- a/backend/routes/auth.js
+++ b/backend/routes/auth.js
@@ -4,20 +4,30 @@ const { OAuth2Client } = require("google-auth-library");
const { User } = require("../models"); // Import from models/index.js to get models with associations
const logger = require("../utils/logger");
const emailService = require("../services/emailService");
+const crypto = require("crypto");
+
const {
sanitizeInput,
validateRegistration,
validateLogin,
validateGoogleAuth,
+ validateForgotPassword,
+ validateResetPassword,
+ validateVerifyResetToken,
} = require("../middleware/validation");
const { csrfProtection, getCSRFToken } = require("../middleware/csrf");
-const { loginLimiter, registerLimiter } = require("../middleware/rateLimiter");
+const {
+ loginLimiter,
+ registerLimiter,
+ passwordResetLimiter,
+} = require("../middleware/rateLimiter");
const router = express.Router();
const googleClient = new OAuth2Client(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
- process.env.GOOGLE_REDIRECT_URI || "http://localhost:3000/auth/google/callback"
+ process.env.GOOGLE_REDIRECT_URI ||
+ "http://localhost:3000/auth/google/callback"
);
// Get CSRF token endpoint
@@ -76,17 +86,19 @@ router.post(
reqLogger.error("Failed to send verification email", {
error: emailError.message,
userId: user.id,
- email: user.email
+ email: user.email,
});
// Continue with registration even if email fails
}
- const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET, {
- expiresIn: "15m", // Short-lived access token
- });
+ const token = jwt.sign(
+ { id: user.id, jwtVersion: user.jwtVersion },
+ process.env.JWT_SECRET,
+ { expiresIn: "15m" } // Short-lived access token
+ );
const refreshToken = jwt.sign(
- { id: user.id, type: "refresh" },
+ { id: user.id, jwtVersion: user.jwtVersion, type: "refresh" },
process.env.JWT_SECRET,
{ expiresIn: "7d" }
);
@@ -112,7 +124,7 @@ router.post(
reqLogger.info("User registration successful", {
userId: user.id,
username: user.username,
- email: user.email
+ email: user.email,
});
res.status(201).json({
@@ -133,7 +145,7 @@ router.post(
error: error.message,
stack: error.stack,
email: req.body.email,
- username: req.body.username
+ username: req.body.username,
});
res.status(500).json({ error: "Registration failed. Please try again." });
}
@@ -176,12 +188,14 @@ router.post(
// Reset login attempts on successful login
await user.resetLoginAttempts();
- const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET, {
- expiresIn: "15m", // Short-lived access token
- });
+ const token = jwt.sign(
+ { id: user.id, jwtVersion: user.jwtVersion },
+ process.env.JWT_SECRET,
+ { expiresIn: "15m" } // Short-lived access token
+ );
const refreshToken = jwt.sign(
- { id: user.id, type: "refresh" },
+ { id: user.id, jwtVersion: user.jwtVersion, type: "refresh" },
process.env.JWT_SECRET,
{ expiresIn: "7d" }
);
@@ -206,7 +220,7 @@ router.post(
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("User login successful", {
userId: user.id,
- email: user.email
+ email: user.email,
});
res.json({
@@ -225,7 +239,7 @@ router.post(
reqLogger.error("Login error", {
error: error.message,
stack: error.stack,
- email: req.body.email
+ email: req.body.email,
});
res.status(500).json({ error: "Login failed. Please try again." });
}
@@ -243,13 +257,17 @@ router.post(
const { code } = req.body;
if (!code) {
- return res.status(400).json({ error: "Authorization code is required" });
+ return res
+ .status(400)
+ .json({ error: "Authorization code is required" });
}
// Exchange authorization code for tokens
const { tokens } = await googleClient.getToken({
code,
- redirect_uri: process.env.GOOGLE_REDIRECT_URI || "http://localhost:3000/auth/google/callback",
+ redirect_uri:
+ process.env.GOOGLE_REDIRECT_URI ||
+ "http://localhost:3000/auth/google/callback",
});
// Verify the ID token from the token response
@@ -303,12 +321,14 @@ router.post(
}
// Generate JWT tokens
- const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET, {
- expiresIn: "15m",
- });
+ const token = jwt.sign(
+ { id: user.id, jwtVersion: user.jwtVersion },
+ process.env.JWT_SECRET,
+ { expiresIn: "15m" }
+ );
const refreshToken = jwt.sign(
- { id: user.id, type: "refresh" },
+ { id: user.id, jwtVersion: user.jwtVersion, type: "refresh" },
process.env.JWT_SECRET,
{ expiresIn: "7d" }
);
@@ -334,7 +354,9 @@ router.post(
reqLogger.info("Google authentication successful", {
userId: user.id,
email: user.email,
- isNewUser: !user.createdAt || (Date.now() - new Date(user.createdAt).getTime()) < 1000
+ isNewUser:
+ !user.createdAt ||
+ Date.now() - new Date(user.createdAt).getTime() < 1000,
});
res.json({
@@ -351,9 +373,9 @@ router.post(
});
} catch (error) {
if (error.message && error.message.includes("invalid_grant")) {
- return res
- .status(401)
- .json({ error: "Invalid or expired authorization code. Please try again." });
+ return res.status(401).json({
+ error: "Invalid or expired authorization code. Please try again.",
+ });
}
if (error.message && error.message.includes("redirect_uri_mismatch")) {
return res
@@ -364,7 +386,7 @@ router.post(
reqLogger.error("Google OAuth error", {
error: error.message,
stack: error.stack,
- codePresent: !!req.body.code
+ codePresent: !!req.body.code,
});
res
.status(500)
@@ -546,10 +568,20 @@ router.post("/refresh", async (req, res) => {
return res.status(401).json({ error: "User not found" });
}
+ // Validate JWT version to invalidate old tokens after password change
+ if (decoded.jwtVersion !== user.jwtVersion) {
+ return res.status(401).json({
+ error: "Session expired due to password change. Please log in again.",
+ code: "JWT_VERSION_MISMATCH",
+ });
+ }
+
// Generate new access token
- const newAccessToken = jwt.sign({ id: user.id }, process.env.JWT_SECRET, {
- expiresIn: "15m",
- });
+ const newAccessToken = jwt.sign(
+ { id: user.id, jwtVersion: user.jwtVersion },
+ process.env.JWT_SECRET,
+ { expiresIn: "15m" }
+ );
// Set new access token cookie
res.cookie("accessToken", newAccessToken, {
@@ -561,7 +593,7 @@ router.post("/refresh", async (req, res) => {
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Token refresh successful", {
- userId: user.id
+ userId: user.id,
});
res.json({
@@ -579,7 +611,7 @@ router.post("/refresh", async (req, res) => {
reqLogger.error("Token refresh error", {
error: error.message,
stack: error.stack,
- userId: req.user?.id
+ userId: req.user?.id,
});
res.status(401).json({ error: "Invalid or expired refresh token" });
}
@@ -589,7 +621,7 @@ router.post("/refresh", async (req, res) => {
router.post("/logout", (req, res) => {
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("User logout", {
- userId: req.user?.id || 'anonymous'
+ userId: req.user?.id || "anonymous",
});
// Clear cookies
@@ -604,13 +636,221 @@ router.get("/status", optionalAuth, async (req, res) => {
if (req.user) {
res.json({
authenticated: true,
- user: req.user
+ user: req.user,
});
} else {
res.json({
- authenticated: false
+ authenticated: false,
});
}
});
+// Forgot password endpoint
+router.post(
+ "/forgot-password",
+ passwordResetLimiter,
+ csrfProtection,
+ sanitizeInput,
+ validateForgotPassword,
+ async (req, res) => {
+ try {
+ const { email } = req.body;
+
+ // Find user with local auth provider only
+ const user = await User.findOne({
+ where: {
+ email,
+ authProvider: "local",
+ },
+ });
+
+ // Always return success to prevent email enumeration
+ // Don't reveal whether the email exists or not
+ if (user) {
+ // Generate password reset token (returns plain token for email)
+ const resetToken = await user.generatePasswordResetToken();
+
+ // Send password reset email
+ try {
+ await emailService.sendPasswordResetEmail(user, resetToken);
+
+ const reqLogger = logger.withRequestId(req.id);
+ reqLogger.info("Password reset email sent", {
+ userId: user.id,
+ email: user.email,
+ });
+ } catch (emailError) {
+ const reqLogger = logger.withRequestId(req.id);
+ reqLogger.error("Failed to send password reset email", {
+ error: emailError.message,
+ userId: user.id,
+ email: user.email,
+ });
+ // Continue - don't reveal email sending failure to user
+ }
+ } else {
+ const reqLogger = logger.withRequestId(req.id);
+ reqLogger.info(
+ "Password reset requested for non-existent or OAuth user",
+ {
+ email: email,
+ }
+ );
+ }
+
+ // Always return success message (security best practice)
+ res.json({
+ message:
+ "If an account exists with that email, you will receive password reset instructions.",
+ });
+ } catch (error) {
+ const reqLogger = logger.withRequestId(req.id);
+ reqLogger.error("Forgot password error", {
+ error: error.message,
+ stack: error.stack,
+ email: req.body.email,
+ });
+ res.status(500).json({
+ error: "Failed to process password reset request. Please try again.",
+ });
+ }
+ }
+);
+
+// Verify reset token endpoint (optional - for frontend UX)
+router.post(
+ "/verify-reset-token",
+ sanitizeInput,
+ validateVerifyResetToken,
+ async (req, res) => {
+ try {
+ const { token } = req.body;
+
+ // Hash the token to search for it in the database
+ const hashedToken = crypto
+ .createHash("sha256")
+ .update(token)
+ .digest("hex");
+
+ // Find user with this reset token (hashed)
+ const user = await User.findOne({
+ where: { passwordResetToken: hashedToken },
+ });
+
+ if (!user) {
+ return res.status(400).json({
+ valid: false,
+ error: "Invalid reset token",
+ code: "TOKEN_INVALID",
+ });
+ }
+
+ // Check if token is valid (not expired)
+ if (!user.isPasswordResetTokenValid(token)) {
+ return res.status(400).json({
+ valid: false,
+ error: "Reset token has expired. Please request a new one.",
+ code: "TOKEN_EXPIRED",
+ });
+ }
+
+ res.json({
+ valid: true,
+ });
+ } catch (error) {
+ const reqLogger = logger.withRequestId(req.id);
+ reqLogger.error("Verify reset token error", {
+ error: error.message,
+ stack: error.stack,
+ });
+ res.status(500).json({
+ valid: false,
+ error: "Failed to verify reset token. Please try again.",
+ });
+ }
+ }
+);
+
+// Reset password endpoint
+router.post(
+ "/reset-password",
+ passwordResetLimiter,
+ csrfProtection,
+ sanitizeInput,
+ validateResetPassword,
+ async (req, res) => {
+ try {
+ const { token, newPassword } = req.body;
+
+ // Hash the token to search for it in the database
+ const crypto = require("crypto");
+ const hashedToken = crypto
+ .createHash("sha256")
+ .update(token)
+ .digest("hex");
+
+ // Find user with this reset token (hashed)
+ const user = await User.findOne({
+ where: { passwordResetToken: hashedToken },
+ });
+
+ if (!user) {
+ return res.status(400).json({
+ error: "Invalid or expired reset token",
+ code: "TOKEN_INVALID",
+ });
+ }
+
+ // Check if token is valid (not expired)
+ if (!user.isPasswordResetTokenValid(token)) {
+ return res.status(400).json({
+ error: "Reset token has expired. Please request a new one.",
+ code: "TOKEN_EXPIRED",
+ });
+ }
+
+ // Reset password (this will clear the token and hash the new password)
+ await user.resetPassword(newPassword);
+
+ // Send password changed notification email
+ try {
+ await emailService.sendPasswordChangedEmail(user);
+ const reqLogger = logger.withRequestId(req.id);
+ reqLogger.info("Password changed notification sent", {
+ userId: user.id,
+ email: user.email,
+ });
+ } catch (emailError) {
+ const reqLogger = logger.withRequestId(req.id);
+ reqLogger.error("Failed to send password changed notification", {
+ error: emailError.message,
+ userId: user.id,
+ email: user.email,
+ });
+ // Continue - don't fail password reset if email fails
+ }
+
+ const reqLogger = logger.withRequestId(req.id);
+ reqLogger.info("Password reset successful", {
+ userId: user.id,
+ email: user.email,
+ });
+
+ res.json({
+ message:
+ "Password has been reset successfully. You can now log in with your new password.",
+ });
+ } catch (error) {
+ const reqLogger = logger.withRequestId(req.id);
+ reqLogger.error("Reset password error", {
+ error: error.message,
+ stack: error.stack,
+ });
+ res.status(500).json({
+ error: "Failed to reset password. Please try again.",
+ });
+ }
+ }
+);
+
module.exports = router;
diff --git a/backend/services/emailService.js b/backend/services/emailService.js
index 45d3fe1..1f69762 100644
--- a/backend/services/emailService.js
+++ b/backend/services/emailService.js
@@ -36,6 +36,8 @@ class EmailService {
"conditionCheckReminder.html",
"rentalConfirmation.html",
"emailVerification.html",
+ "passwordReset.html",
+ "passwordChanged.html",
"lateReturnCS.html",
"damageReportCS.html",
"lostItemCS.html",
@@ -244,6 +246,31 @@ class EmailService {
This link will expire in 24 hours.
`
),
+
+ passwordReset: baseTemplate.replace(
+ "{{content}}",
+ `
+ Hi {{recipientName}},
+ Reset Your Password
+ We received a request to reset the password for your RentAll account. Click the button below to choose a new password.
+ Reset Password
+ If the button doesn't work, copy and paste this link into your browser: {{resetUrl}}
+ This link will expire in 1 hour.
+ If you didn't request this, you can safely ignore this email.
+ `
+ ),
+
+ passwordChanged: baseTemplate.replace(
+ "{{content}}",
+ `
+ Hi {{recipientName}},
+ Your Password Has Been Changed
+ This is a confirmation that the password for your RentAll account ({{email}}) has been successfully changed.
+ Changed on: {{timestamp}}
+ For your security, all existing sessions have been logged out.
+ Didn't change your password? If you did not make this change, please contact our support team immediately.
+ `
+ ),
};
return (
@@ -326,6 +353,45 @@ class EmailService {
);
}
+ async sendPasswordResetEmail(user, resetToken) {
+ const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
+ const resetUrl = `${frontendUrl}/reset-password?token=${resetToken}`;
+
+ const variables = {
+ recipientName: user.firstName || "there",
+ resetUrl: resetUrl,
+ };
+
+ const htmlContent = this.renderTemplate("passwordReset", variables);
+
+ return await this.sendEmail(
+ user.email,
+ "Reset Your Password - RentAll",
+ htmlContent
+ );
+ }
+
+ async sendPasswordChangedEmail(user) {
+ const timestamp = new Date().toLocaleString("en-US", {
+ dateStyle: "long",
+ timeStyle: "short",
+ });
+
+ const variables = {
+ recipientName: user.firstName || "there",
+ email: user.email,
+ timestamp: timestamp,
+ };
+
+ const htmlContent = this.renderTemplate("passwordChanged", variables);
+
+ return await this.sendEmail(
+ user.email,
+ "Password Changed Successfully - RentAll",
+ htmlContent
+ );
+ }
+
async sendTemplateEmail(toEmail, subject, templateName, variables = {}) {
const htmlContent = this.renderTemplate(templateName, variables);
return await this.sendEmail(toEmail, subject, htmlContent);
diff --git a/backend/templates/emails/passwordChanged.html b/backend/templates/emails/passwordChanged.html
new file mode 100644
index 0000000..65177f5
--- /dev/null
+++ b/backend/templates/emails/passwordChanged.html
@@ -0,0 +1,246 @@
+
+
+
+
+
+
+ Password Changed Successfully - RentAll
+
+
+
+
+
+
+
+
Hi {{recipientName}},
+
+
Your Password Has Been Changed
+
+
+
Your password was successfully changed. You can now use your new password to log in to your RentAll account.
+
+
+
This is a confirmation that the password for your RentAll account has been changed. For your security, all existing sessions have been logged out.
+
+
+
+ Date & Time:
+ {{timestamp}}
+
+
+ Account Email:
+ {{email}}
+
+
+
+
+
Didn't change your password? If you did not make this change, your account may be compromised. Please contact our support team immediately at support@rentall.com to secure your account.
+
+
+
+
Security reminder: Keep your password secure and never share it with anyone. We recommend using a strong, unique password and enabling two-factor authentication when available.
+
+
+
Thanks for using RentAll!
+
+
+
+
+
+
diff --git a/backend/templates/emails/passwordReset.html b/backend/templates/emails/passwordReset.html
new file mode 100644
index 0000000..bd094ca
--- /dev/null
+++ b/backend/templates/emails/passwordReset.html
@@ -0,0 +1,246 @@
+
+
+
+
+
+
+ Reset Your Password - RentAll
+
+
+
+
+
+
+
+
Hi {{recipientName}},
+
+
Reset Your Password
+
+
We received a request to reset the password for your RentAll account. Click the button below to choose a new password.
+
+
+
+
If the button doesn't work, you can copy and paste this link into your browser:
+
{{resetUrl}}
+
+
+
This link will expire in 1 hour. For security reasons, password reset links are only valid for a short time. If your link has expired, you can request a new one.
+
+
+
+
Didn't request a password reset? If you didn't request this, you can safely ignore this email. Your password will not be changed. However, if you're concerned about your account security, please contact our support team immediately.
+
+
+
+
Security tip: Choose a strong password that includes a mix of uppercase and lowercase letters, numbers, and special characters. Never share your password with anyone.
+
+
+
Thanks for using RentAll!
+
+
+
+
+
+
diff --git a/backend/tests/unit/models/User.passwordReset.test.js b/backend/tests/unit/models/User.passwordReset.test.js
new file mode 100644
index 0000000..0067388
--- /dev/null
+++ b/backend/tests/unit/models/User.passwordReset.test.js
@@ -0,0 +1,293 @@
+const crypto = require('crypto');
+
+// Mock crypto module
+jest.mock('crypto');
+
+// Mock the entire models module
+jest.mock('../../../models', () => {
+ const mockUser = {
+ update: jest.fn(),
+ passwordResetToken: null,
+ passwordResetTokenExpiry: null,
+ };
+
+ return {
+ User: mockUser,
+ sequelize: {
+ models: {
+ User: mockUser
+ }
+ }
+ };
+});
+
+// Import User model methods - we'll test them directly
+const User = require('../../../models/User');
+
+describe('User Model - Password Reset', () => {
+ let mockUser;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ // Create a fresh mock user for each test
+ mockUser = {
+ id: 1,
+ email: 'test@example.com',
+ firstName: 'Test',
+ lastName: 'User',
+ password: 'hashedPassword123',
+ passwordResetToken: null,
+ passwordResetTokenExpiry: null,
+ update: jest.fn().mockImplementation(function(updates) {
+ Object.assign(this, updates);
+ return Promise.resolve(this);
+ })
+ };
+
+ // Add the prototype methods to mockUser
+ Object.setPrototypeOf(mockUser, User.prototype);
+ });
+
+ describe('generatePasswordResetToken', () => {
+ it('should generate a random token and set 1-hour expiry', async () => {
+ const mockRandomBytes = Buffer.from('a'.repeat(32));
+ const mockToken = mockRandomBytes.toString('hex');
+
+ crypto.randomBytes.mockReturnValue(mockRandomBytes);
+
+ await User.prototype.generatePasswordResetToken.call(mockUser);
+
+ expect(crypto.randomBytes).toHaveBeenCalledWith(32);
+ expect(mockUser.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ passwordResetToken: mockToken
+ })
+ );
+
+ // Check that expiry is approximately 1 hour from now
+ const updateCall = mockUser.update.mock.calls[0][0];
+ const expiryTime = updateCall.passwordResetTokenExpiry.getTime();
+ const expectedExpiry = Date.now() + 60 * 60 * 1000;
+
+ expect(expiryTime).toBeGreaterThan(expectedExpiry - 1000);
+ expect(expiryTime).toBeLessThan(expectedExpiry + 1000);
+ });
+
+ it('should update the user with token and expiry', async () => {
+ const mockRandomBytes = Buffer.from('b'.repeat(32));
+ const mockToken = mockRandomBytes.toString('hex');
+
+ crypto.randomBytes.mockReturnValue(mockRandomBytes);
+
+ const result = await User.prototype.generatePasswordResetToken.call(mockUser);
+
+ expect(mockUser.update).toHaveBeenCalledTimes(1);
+ expect(result.passwordResetToken).toBe(mockToken);
+ expect(result.passwordResetTokenExpiry).toBeInstanceOf(Date);
+ });
+
+ it('should generate unique tokens on multiple calls', async () => {
+ const mockRandomBytes1 = Buffer.from('a'.repeat(32));
+ const mockRandomBytes2 = Buffer.from('b'.repeat(32));
+
+ crypto.randomBytes
+ .mockReturnValueOnce(mockRandomBytes1)
+ .mockReturnValueOnce(mockRandomBytes2);
+
+ await User.prototype.generatePasswordResetToken.call(mockUser);
+ const firstToken = mockUser.update.mock.calls[0][0].passwordResetToken;
+
+ await User.prototype.generatePasswordResetToken.call(mockUser);
+ const secondToken = mockUser.update.mock.calls[1][0].passwordResetToken;
+
+ expect(firstToken).not.toBe(secondToken);
+ });
+ });
+
+ describe('isPasswordResetTokenValid', () => {
+ it('should return true for valid token and non-expired time', () => {
+ const validToken = 'valid-token-123';
+ const futureExpiry = new Date(Date.now() + 30 * 60 * 1000); // 30 minutes from now
+
+ mockUser.passwordResetToken = validToken;
+ mockUser.passwordResetTokenExpiry = futureExpiry;
+
+ const result = User.prototype.isPasswordResetTokenValid.call(mockUser, validToken);
+
+ expect(result).toBe(true);
+ });
+
+ it('should return false for missing token', () => {
+ mockUser.passwordResetToken = null;
+ mockUser.passwordResetTokenExpiry = new Date(Date.now() + 30 * 60 * 1000);
+
+ const result = User.prototype.isPasswordResetTokenValid.call(mockUser, 'any-token');
+
+ expect(result).toBe(false);
+ });
+
+ it('should return false for missing expiry', () => {
+ mockUser.passwordResetToken = 'valid-token';
+ mockUser.passwordResetTokenExpiry = null;
+
+ const result = User.prototype.isPasswordResetTokenValid.call(mockUser, 'valid-token');
+
+ expect(result).toBe(false);
+ });
+
+ it('should return false for mismatched token', () => {
+ mockUser.passwordResetToken = 'correct-token';
+ mockUser.passwordResetTokenExpiry = new Date(Date.now() + 30 * 60 * 1000);
+
+ const result = User.prototype.isPasswordResetTokenValid.call(mockUser, 'wrong-token');
+
+ expect(result).toBe(false);
+ });
+
+ it('should return false for expired token', () => {
+ const validToken = 'valid-token-123';
+ const pastExpiry = new Date(Date.now() - 10 * 60 * 1000); // 10 minutes ago
+
+ mockUser.passwordResetToken = validToken;
+ mockUser.passwordResetTokenExpiry = pastExpiry;
+
+ const result = User.prototype.isPasswordResetTokenValid.call(mockUser, validToken);
+
+ expect(result).toBe(false);
+ });
+
+ it('should return false for token expiring in the past by 1 second', () => {
+ const validToken = 'valid-token-123';
+ const pastExpiry = new Date(Date.now() - 1000); // 1 second ago
+
+ mockUser.passwordResetToken = validToken;
+ mockUser.passwordResetTokenExpiry = pastExpiry;
+
+ const result = User.prototype.isPasswordResetTokenValid.call(mockUser, validToken);
+
+ expect(result).toBe(false);
+ });
+
+ it('should handle edge case of token expiring exactly now', () => {
+ const validToken = 'valid-token-123';
+ // Set expiry 1ms in the future to handle timing precision
+ const nowExpiry = new Date(Date.now() + 1);
+
+ mockUser.passwordResetToken = validToken;
+ mockUser.passwordResetTokenExpiry = nowExpiry;
+
+ // This should be true because expiry is slightly in the future
+ const result = User.prototype.isPasswordResetTokenValid.call(mockUser, validToken);
+
+ expect(result).toBe(true);
+ });
+
+ it('should handle string dates correctly', () => {
+ const validToken = 'valid-token-123';
+ const futureExpiry = new Date(Date.now() + 30 * 60 * 1000).toISOString(); // String date
+
+ mockUser.passwordResetToken = validToken;
+ mockUser.passwordResetTokenExpiry = futureExpiry;
+
+ const result = User.prototype.isPasswordResetTokenValid.call(mockUser, validToken);
+
+ expect(result).toBe(true);
+ });
+ });
+
+ describe('resetPassword', () => {
+ it('should update password and clear token fields', async () => {
+ mockUser.passwordResetToken = 'some-token';
+ mockUser.passwordResetTokenExpiry = new Date();
+
+ await User.prototype.resetPassword.call(mockUser, 'newSecurePassword123!');
+
+ expect(mockUser.update).toHaveBeenCalledWith({
+ password: 'newSecurePassword123!',
+ passwordResetToken: null,
+ passwordResetTokenExpiry: null
+ });
+ });
+
+ it('should return updated user object', async () => {
+ const result = await User.prototype.resetPassword.call(mockUser, 'newPassword123!');
+
+ expect(result.password).toBe('newPassword123!');
+ expect(result.passwordResetToken).toBe(null);
+ expect(result.passwordResetTokenExpiry).toBe(null);
+ });
+
+ it('should call update only once', async () => {
+ await User.prototype.resetPassword.call(mockUser, 'newPassword123!');
+
+ expect(mockUser.update).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('Complete password reset flow', () => {
+ it('should complete full password reset flow successfully', async () => {
+ // Step 1: Generate password reset token
+ const mockRandomBytes = Buffer.from('c'.repeat(32));
+ const mockToken = mockRandomBytes.toString('hex');
+ crypto.randomBytes.mockReturnValue(mockRandomBytes);
+
+ await User.prototype.generatePasswordResetToken.call(mockUser);
+
+ expect(mockUser.passwordResetToken).toBe(mockToken);
+ expect(mockUser.passwordResetTokenExpiry).toBeInstanceOf(Date);
+
+ // Step 2: Validate token
+ const isValid = User.prototype.isPasswordResetTokenValid.call(mockUser, mockToken);
+ expect(isValid).toBe(true);
+
+ // Step 3: Reset password
+ await User.prototype.resetPassword.call(mockUser, 'newPassword123!');
+
+ expect(mockUser.password).toBe('newPassword123!');
+ expect(mockUser.passwordResetToken).toBe(null);
+ expect(mockUser.passwordResetTokenExpiry).toBe(null);
+ });
+
+ it('should fail password reset with wrong token', async () => {
+ // Generate token
+ const mockToken = 'd'.repeat(64);
+ const mockRandomBytes = Buffer.from('d'.repeat(32));
+ crypto.randomBytes.mockReturnValue(mockRandomBytes);
+
+ await User.prototype.generatePasswordResetToken.call(mockUser);
+
+ // Try to validate with wrong token
+ const isValid = User.prototype.isPasswordResetTokenValid.call(mockUser, 'wrong-token');
+
+ expect(isValid).toBe(false);
+ });
+
+ it('should fail password reset with expired token', async () => {
+ // Manually set an expired token
+ mockUser.passwordResetToken = 'expired-token';
+ mockUser.passwordResetTokenExpiry = new Date(Date.now() - 2 * 60 * 60 * 1000); // 2 hours ago
+
+ const isValid = User.prototype.isPasswordResetTokenValid.call(mockUser, 'expired-token');
+
+ expect(isValid).toBe(false);
+ });
+
+ it('should not allow password reset after token has been used', async () => {
+ // Generate token
+ const mockRandomBytes = Buffer.from('e'.repeat(32));
+ const mockToken = mockRandomBytes.toString('hex');
+ crypto.randomBytes.mockReturnValue(mockRandomBytes);
+
+ await User.prototype.generatePasswordResetToken.call(mockUser);
+
+ // Reset password (clears token)
+ await User.prototype.resetPassword.call(mockUser, 'newPassword123!');
+
+ // Try to validate same token again (should fail because it's cleared)
+ const isValid = User.prototype.isPasswordResetTokenValid.call(mockUser, mockToken);
+
+ expect(isValid).toBe(false);
+ });
+ });
+});
diff --git a/backend/tests/unit/models/User.verification.test.js b/backend/tests/unit/models/User.verification.test.js
new file mode 100644
index 0000000..cce0fa8
--- /dev/null
+++ b/backend/tests/unit/models/User.verification.test.js
@@ -0,0 +1,296 @@
+const crypto = require('crypto');
+
+// Mock crypto module
+jest.mock('crypto');
+
+// Mock the entire models module
+jest.mock('../../../models', () => {
+ const mockUser = {
+ update: jest.fn(),
+ verificationToken: null,
+ verificationTokenExpiry: null,
+ isVerified: false,
+ verifiedAt: null
+ };
+
+ return {
+ User: mockUser,
+ sequelize: {
+ models: {
+ User: mockUser
+ }
+ }
+ };
+});
+
+// Import User model methods - we'll test them directly
+const User = require('../../../models/User');
+
+describe('User Model - Email Verification', () => {
+ let mockUser;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ // Create a fresh mock user for each test
+ mockUser = {
+ id: 1,
+ email: 'test@example.com',
+ firstName: 'Test',
+ lastName: 'User',
+ verificationToken: null,
+ verificationTokenExpiry: null,
+ isVerified: false,
+ verifiedAt: null,
+ update: jest.fn().mockImplementation(function(updates) {
+ Object.assign(this, updates);
+ return Promise.resolve(this);
+ })
+ };
+
+ // Add the prototype methods to mockUser
+ Object.setPrototypeOf(mockUser, User.prototype);
+ });
+
+ describe('generateVerificationToken', () => {
+ it('should generate a random token and set 24-hour expiry', async () => {
+ const mockRandomBytes = Buffer.from('a'.repeat(32));
+ const mockToken = mockRandomBytes.toString('hex'); // This will be "61" repeated 32 times
+
+ crypto.randomBytes.mockReturnValue(mockRandomBytes);
+
+ await User.prototype.generateVerificationToken.call(mockUser);
+
+ expect(crypto.randomBytes).toHaveBeenCalledWith(32);
+ expect(mockUser.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ verificationToken: mockToken
+ })
+ );
+
+ // Check that expiry is approximately 24 hours from now
+ const updateCall = mockUser.update.mock.calls[0][0];
+ const expiryTime = updateCall.verificationTokenExpiry.getTime();
+ const expectedExpiry = Date.now() + 24 * 60 * 60 * 1000;
+
+ expect(expiryTime).toBeGreaterThan(expectedExpiry - 1000);
+ expect(expiryTime).toBeLessThan(expectedExpiry + 1000);
+ });
+
+ it('should update the user with token and expiry', async () => {
+ const mockRandomBytes = Buffer.from('b'.repeat(32));
+ const mockToken = mockRandomBytes.toString('hex');
+
+ crypto.randomBytes.mockReturnValue(mockRandomBytes);
+
+ const result = await User.prototype.generateVerificationToken.call(mockUser);
+
+ expect(mockUser.update).toHaveBeenCalledTimes(1);
+ expect(result.verificationToken).toBe(mockToken);
+ expect(result.verificationTokenExpiry).toBeInstanceOf(Date);
+ });
+
+ it('should generate unique tokens on multiple calls', async () => {
+ const mockRandomBytes1 = Buffer.from('a'.repeat(32));
+ const mockRandomBytes2 = Buffer.from('b'.repeat(32));
+
+ crypto.randomBytes
+ .mockReturnValueOnce(mockRandomBytes1)
+ .mockReturnValueOnce(mockRandomBytes2);
+
+ await User.prototype.generateVerificationToken.call(mockUser);
+ const firstToken = mockUser.update.mock.calls[0][0].verificationToken;
+
+ await User.prototype.generateVerificationToken.call(mockUser);
+ const secondToken = mockUser.update.mock.calls[1][0].verificationToken;
+
+ expect(firstToken).not.toBe(secondToken);
+ });
+ });
+
+ describe('isVerificationTokenValid', () => {
+ it('should return true for valid token and non-expired time', () => {
+ const validToken = 'valid-token-123';
+ const futureExpiry = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now
+
+ mockUser.verificationToken = validToken;
+ mockUser.verificationTokenExpiry = futureExpiry;
+
+ const result = User.prototype.isVerificationTokenValid.call(mockUser, validToken);
+
+ expect(result).toBe(true);
+ });
+
+ it('should return false for missing token', () => {
+ mockUser.verificationToken = null;
+ mockUser.verificationTokenExpiry = new Date(Date.now() + 60 * 60 * 1000);
+
+ const result = User.prototype.isVerificationTokenValid.call(mockUser, 'any-token');
+
+ expect(result).toBe(false);
+ });
+
+ it('should return false for missing expiry', () => {
+ mockUser.verificationToken = 'valid-token';
+ mockUser.verificationTokenExpiry = null;
+
+ const result = User.prototype.isVerificationTokenValid.call(mockUser, 'valid-token');
+
+ expect(result).toBe(false);
+ });
+
+ it('should return false for mismatched token', () => {
+ mockUser.verificationToken = 'correct-token';
+ mockUser.verificationTokenExpiry = new Date(Date.now() + 60 * 60 * 1000);
+
+ const result = User.prototype.isVerificationTokenValid.call(mockUser, 'wrong-token');
+
+ expect(result).toBe(false);
+ });
+
+ it('should return false for expired token', () => {
+ const validToken = 'valid-token-123';
+ const pastExpiry = new Date(Date.now() - 60 * 60 * 1000); // 1 hour ago
+
+ mockUser.verificationToken = validToken;
+ mockUser.verificationTokenExpiry = pastExpiry;
+
+ const result = User.prototype.isVerificationTokenValid.call(mockUser, validToken);
+
+ expect(result).toBe(false);
+ });
+
+ it('should return false for token expiring in the past by 1 second', () => {
+ const validToken = 'valid-token-123';
+ const pastExpiry = new Date(Date.now() - 1000); // 1 second ago
+
+ mockUser.verificationToken = validToken;
+ mockUser.verificationTokenExpiry = pastExpiry;
+
+ const result = User.prototype.isVerificationTokenValid.call(mockUser, validToken);
+
+ expect(result).toBe(false);
+ });
+
+ it('should handle edge case of token expiring exactly now', () => {
+ const validToken = 'valid-token-123';
+ // Set expiry 1ms in the future to handle timing precision
+ const nowExpiry = new Date(Date.now() + 1);
+
+ mockUser.verificationToken = validToken;
+ mockUser.verificationTokenExpiry = nowExpiry;
+
+ // This should be true because expiry is slightly in the future
+ const result = User.prototype.isVerificationTokenValid.call(mockUser, validToken);
+
+ expect(result).toBe(true);
+ });
+
+ it('should handle string dates correctly', () => {
+ const validToken = 'valid-token-123';
+ const futureExpiry = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // String date
+
+ mockUser.verificationToken = validToken;
+ mockUser.verificationTokenExpiry = futureExpiry;
+
+ const result = User.prototype.isVerificationTokenValid.call(mockUser, validToken);
+
+ expect(result).toBe(true);
+ });
+ });
+
+ describe('verifyEmail', () => {
+ it('should mark user as verified and clear token fields', async () => {
+ mockUser.verificationToken = 'some-token';
+ mockUser.verificationTokenExpiry = new Date();
+
+ await User.prototype.verifyEmail.call(mockUser);
+
+ expect(mockUser.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ isVerified: true,
+ verificationToken: null,
+ verificationTokenExpiry: null
+ })
+ );
+ });
+
+ it('should set verifiedAt timestamp', async () => {
+ const beforeTime = Date.now();
+
+ await User.prototype.verifyEmail.call(mockUser);
+
+ const updateCall = mockUser.update.mock.calls[0][0];
+ const verifiedAtTime = updateCall.verifiedAt.getTime();
+ const afterTime = Date.now();
+
+ expect(verifiedAtTime).toBeGreaterThanOrEqual(beforeTime);
+ expect(verifiedAtTime).toBeLessThanOrEqual(afterTime);
+ });
+
+ it('should return updated user object', async () => {
+ const result = await User.prototype.verifyEmail.call(mockUser);
+
+ expect(result.isVerified).toBe(true);
+ expect(result.verificationToken).toBe(null);
+ expect(result.verificationTokenExpiry).toBe(null);
+ expect(result.verifiedAt).toBeInstanceOf(Date);
+ });
+
+ it('should call update only once', async () => {
+ await User.prototype.verifyEmail.call(mockUser);
+
+ expect(mockUser.update).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('Complete verification flow', () => {
+ it('should complete full verification flow successfully', async () => {
+ // Step 1: Generate verification token
+ const mockRandomBytes = Buffer.from('c'.repeat(32));
+ const mockToken = mockRandomBytes.toString('hex');
+ crypto.randomBytes.mockReturnValue(mockRandomBytes);
+
+ await User.prototype.generateVerificationToken.call(mockUser);
+
+ expect(mockUser.verificationToken).toBe(mockToken);
+ expect(mockUser.verificationTokenExpiry).toBeInstanceOf(Date);
+
+ // Step 2: Validate token
+ const isValid = User.prototype.isVerificationTokenValid.call(mockUser, mockToken);
+ expect(isValid).toBe(true);
+
+ // Step 3: Verify email
+ await User.prototype.verifyEmail.call(mockUser);
+
+ expect(mockUser.isVerified).toBe(true);
+ expect(mockUser.verificationToken).toBe(null);
+ expect(mockUser.verificationTokenExpiry).toBe(null);
+ expect(mockUser.verifiedAt).toBeInstanceOf(Date);
+ });
+
+ it('should fail verification with wrong token', async () => {
+ // Generate token
+ const mockToken = 'd'.repeat(64);
+ const mockRandomBytes = Buffer.from('d'.repeat(32));
+ crypto.randomBytes.mockReturnValue(mockRandomBytes);
+
+ await User.prototype.generateVerificationToken.call(mockUser);
+
+ // Try to validate with wrong token
+ const isValid = User.prototype.isVerificationTokenValid.call(mockUser, 'wrong-token');
+
+ expect(isValid).toBe(false);
+ });
+
+ it('should fail verification with expired token', async () => {
+ // Manually set an expired token
+ mockUser.verificationToken = 'expired-token';
+ mockUser.verificationTokenExpiry = new Date(Date.now() - 25 * 60 * 60 * 1000); // 25 hours ago
+
+ const isValid = User.prototype.isVerificationTokenValid.call(mockUser, 'expired-token');
+
+ expect(isValid).toBe(false);
+ });
+ });
+});
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 40ebd34..0392e7a 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -7,6 +7,7 @@ import AuthModal from './components/AuthModal';
import Home from './pages/Home';
import GoogleCallback from './pages/GoogleCallback';
import VerifyEmail from './pages/VerifyEmail';
+import ResetPassword from './pages/ResetPassword';
import ItemList from './pages/ItemList';
import ItemDetail from './pages/ItemDetail';
import EditItem from './pages/EditItem';
@@ -39,6 +40,7 @@ const AppContent: React.FC = () => {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/frontend/src/components/AuthModal.tsx b/frontend/src/components/AuthModal.tsx
index 6445ed8..7277f61 100644
--- a/frontend/src/components/AuthModal.tsx
+++ b/frontend/src/components/AuthModal.tsx
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from "react";
import { useAuth } from "../contexts/AuthContext";
import PasswordStrengthMeter from "./PasswordStrengthMeter";
import PasswordInput from "./PasswordInput";
+import ForgotPasswordModal from "./ForgotPasswordModal";
interface AuthModalProps {
show: boolean;
@@ -21,6 +22,7 @@ const AuthModal: React.FC = ({
const [lastName, setLastName] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
+ const [showForgotPassword, setShowForgotPassword] = useState(false);
const { login, register } = useAuth();
@@ -83,17 +85,18 @@ const AuthModal: React.FC = ({
};
- if (!show) return null;
+ if (!show && !showForgotPassword) return null;
return (
<>
-
-
-
+ {!showForgotPassword && (
+
+
+
= ({
)}
+ {mode === "login" && (
+
+ )}
+
= ({
-
+
+ )}
+
+ {/* Forgot Password Modal */}
+
setShowForgotPassword(false)}
+ onBackToLogin={() => setShowForgotPassword(false)}
+ />
>
);
};
diff --git a/frontend/src/components/BetaPasswordProtection.tsx b/frontend/src/components/BetaPasswordProtection.tsx
deleted file mode 100644
index ef22828..0000000
--- a/frontend/src/components/BetaPasswordProtection.tsx
+++ /dev/null
@@ -1,135 +0,0 @@
-import React, { useState, useEffect } from "react";
-
-interface BetaPasswordProtectionProps {
- children: React.ReactNode;
-}
-
-const BetaPasswordProtection: React.FC = ({
- children,
-}) => {
- const [isAuthenticated, setIsAuthenticated] = useState(false);
- const [password, setPassword] = useState("");
- const [error, setError] = useState("");
- const [loading, setLoading] = useState(true);
-
- useEffect(() => {
- // Check if user already has valid beta access
- const betaToken = localStorage.getItem("betaAccess");
- if (betaToken) {
- // Verify the stored token is still valid
- verifyBetaAccess(betaToken);
- } else {
- setLoading(false);
- }
- }, []);
-
- const verifyBetaAccess = async (token: string) => {
- try {
- const response = await fetch(
- `${process.env.REACT_APP_API_URL}/beta/verify`,
- {
- headers: {
- "X-Beta-Password": token,
- },
- }
- );
-
- if (response.ok) {
- setIsAuthenticated(true);
- } else {
- localStorage.removeItem("betaAccess");
- }
- } catch (error) {
- localStorage.removeItem("betaAccess");
- } finally {
- setLoading(false);
- }
- };
-
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
- setError("");
-
- if (!password) {
- setError("Please enter a password");
- return;
- }
-
- try {
- const response = await fetch(
- `${process.env.REACT_APP_API_URL}/beta/verify`,
- {
- headers: {
- "X-Beta-Password": password,
- },
- }
- );
-
- if (response.ok) {
- localStorage.setItem("betaAccess", password);
- setIsAuthenticated(true);
- } else {
- setError("Invalid beta password");
- }
- } catch (error) {
- setError("Failed to verify beta password");
- }
- };
-
- if (loading) {
- return (
-
- );
- }
-
- if (!isAuthenticated) {
- return (
-
-
-
-
Beta Access Required
-
- This site is currently in beta testing. Please enter the beta
- password to continue.
-
-
-
-
-
- );
- }
-
- return <>{children}>;
-};
-
-export default BetaPasswordProtection;
diff --git a/frontend/src/components/ForgotPasswordModal.tsx b/frontend/src/components/ForgotPasswordModal.tsx
new file mode 100644
index 0000000..2936dfe
--- /dev/null
+++ b/frontend/src/components/ForgotPasswordModal.tsx
@@ -0,0 +1,175 @@
+import React, { useState } from "react";
+import { authAPI } from "../services/api";
+
+interface ForgotPasswordModalProps {
+ show: boolean;
+ onHide: () => void;
+ onBackToLogin?: () => void;
+}
+
+const ForgotPasswordModal: React.FC = ({
+ show,
+ onHide,
+ onBackToLogin,
+}) => {
+ const [email, setEmail] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+ const [success, setSuccess] = useState(false);
+
+ const resetModal = () => {
+ setEmail("");
+ setError("");
+ setSuccess(false);
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setLoading(true);
+ setError("");
+
+ try {
+ await authAPI.forgotPassword(email);
+ setSuccess(true);
+ } catch (err: any) {
+ console.error('Forgot password error:', err);
+ const errorData = err.response?.data;
+
+ // Check for rate limiting
+ if (err.response?.status === 429) {
+ const retryAfter = errorData?.retryAfter;
+ if (retryAfter) {
+ const minutes = Math.ceil(retryAfter / 60);
+ setError(`Too many password reset requests. Please try again in ${minutes} minute${minutes > 1 ? 's' : ''}.`);
+ } else {
+ setError('Too many password reset requests. Please try again later.');
+ }
+ } else if (errorData?.details) {
+ // Validation errors
+ const validationErrors = errorData.details.map((d: any) => d.message).join(', ');
+ setError(validationErrors);
+ } else {
+ setError(errorData?.error || "Failed to send reset email. Please try again.");
+ }
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ if (!show) return null;
+
+ return (
+ <>
+
+
+
+
+ {
+ resetModal();
+ onHide();
+ }}
+ >
+
+
+ {success ? (
+ <>
+
+
+
Check Your Email
+
+ If an account exists with that email address, you will
+ receive password reset instructions shortly.
+
+
+ Please check your spam folder if you don't see the email
+ within a few minutes.
+
+
{
+ resetModal();
+ onHide();
+ }}
+ >
+ Close
+
+
+ >
+ ) : (
+ <>
+
Forgot Password?
+
+ Enter your email address and we'll send you instructions to
+ reset your password.
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+ >
+ )}
+
+
+
+
+ >
+ );
+};
+
+export default ForgotPasswordModal;
diff --git a/frontend/src/pages/ResetPassword.tsx b/frontend/src/pages/ResetPassword.tsx
new file mode 100644
index 0000000..459c5aa
--- /dev/null
+++ b/frontend/src/pages/ResetPassword.tsx
@@ -0,0 +1,205 @@
+import React, { useEffect, useState, useRef } from 'react';
+import { useNavigate, useSearchParams, Link } from 'react-router-dom';
+import { useAuth } from '../contexts/AuthContext';
+import { authAPI } from '../services/api';
+import PasswordInput from '../components/PasswordInput';
+import PasswordStrengthMeter from '../components/PasswordStrengthMeter';
+
+const ResetPassword: React.FC = () => {
+ const [searchParams] = useSearchParams();
+ const navigate = useNavigate();
+ const { openAuthModal } = useAuth();
+ const [error, setError] = useState('');
+ const [success, setSuccess] = useState(false);
+ const [validating, setValidating] = useState(true);
+ const [tokenValid, setTokenValid] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+ const [newPassword, setNewPassword] = useState('');
+ const [confirmPassword, setConfirmPassword] = useState('');
+ const hasValidated = useRef(false);
+
+ useEffect(() => {
+ const validateToken = async () => {
+ // Prevent double execution in React StrictMode
+ if (hasValidated.current) {
+ return;
+ }
+ hasValidated.current = true;
+
+ try {
+ const token = searchParams.get('token');
+
+ if (!token) {
+ setError('No reset token provided.');
+ setValidating(false);
+ return;
+ }
+
+ // Verify the token is valid
+ await authAPI.verifyResetToken(token);
+
+ setTokenValid(true);
+ setValidating(false);
+ } catch (err: any) {
+ console.error('Token validation error:', err);
+ const errorData = err.response?.data;
+
+ if (errorData?.code === 'TOKEN_EXPIRED') {
+ setError('Your password reset link has expired. Please request a new one.');
+ } else if (errorData?.code === 'TOKEN_INVALID') {
+ setError('Invalid password reset link. The link may have already been used or is incorrect.');
+ } else {
+ setError(errorData?.error || 'Failed to validate reset link. Please try again.');
+ }
+
+ setValidating(false);
+ setTokenValid(false);
+ }
+ };
+
+ validateToken();
+ }, [searchParams]);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError('');
+
+ // Validate passwords match
+ if (newPassword !== confirmPassword) {
+ setError('Passwords do not match.');
+ return;
+ }
+
+ setSubmitting(true);
+
+ try {
+ const token = searchParams.get('token');
+ if (!token) {
+ setError('No reset token provided.');
+ setSubmitting(false);
+ return;
+ }
+
+ await authAPI.resetPassword(token, newPassword);
+
+ setSuccess(true);
+ } catch (err: any) {
+ console.error('Password reset error:', err);
+ const errorData = err.response?.data;
+
+ if (errorData?.code === 'TOKEN_EXPIRED') {
+ setError('Your reset link has expired. Please request a new one.');
+ } else if (errorData?.code === 'TOKEN_INVALID') {
+ setError('Invalid reset link. Please request a new one.');
+ } else if (errorData?.details) {
+ // Validation errors
+ const validationErrors = errorData.details.map((d: any) => d.message).join(', ');
+ setError(validationErrors);
+ } else {
+ setError(errorData?.error || 'Failed to reset password. Please try again.');
+ }
+
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+ {validating ? (
+
+
+ Loading...
+
+
Verifying Reset Link...
+
Please wait while we verify your password reset link.
+
+ ) : success ? (
+
+
+
Password Reset Successfully!
+
+ Your password has been reset. You can now log in with your new password.
+
+
{
+ navigate('/', { replace: true });
+ openAuthModal('login');
+ }}
+ >
+ Log In Now
+
+
+ ) : !tokenValid ? (
+
+
+
Invalid Reset Link
+
{error}
+
+
+ Return to Home
+
+
+
+ ) : (
+ <>
+
+
Reset Your Password
+
Enter your new password below.
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
+ Return to Home
+
+
+ >
+ )}
+
+
+
+
+
+ );
+};
+
+export default ResetPassword;
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index f40d0b1..0889e83 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -168,6 +168,9 @@ export const authAPI = {
getStatus: () => api.get("/auth/status"),
verifyEmail: (token: string) => api.post("/auth/verify-email", { token }),
resendVerification: () => api.post("/auth/resend-verification"),
+ forgotPassword: (email: string) => api.post("/auth/forgot-password", { email }),
+ verifyResetToken: (token: string) => api.post("/auth/verify-reset-token", { token }),
+ resetPassword: (token: string, newPassword: string) => api.post("/auth/reset-password", { token, newPassword }),
};
export const userAPI = {