password reset
This commit is contained in:
@@ -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;
|
req.user = user;
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -85,6 +93,12 @@ const optionalAuth = async (req, res, next) => {
|
|||||||
return 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;
|
req.user = user;
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -260,6 +260,56 @@ const validatePasswordChange = [
|
|||||||
handleValidationErrors,
|
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 = {
|
module.exports = {
|
||||||
sanitizeInput,
|
sanitizeInput,
|
||||||
handleValidationErrors,
|
handleValidationErrors,
|
||||||
@@ -268,4 +318,7 @@ module.exports = {
|
|||||||
validateGoogleAuth,
|
validateGoogleAuth,
|
||||||
validateProfileUpdate,
|
validateProfileUpdate,
|
||||||
validatePasswordChange,
|
validatePasswordChange,
|
||||||
|
validateForgotPassword,
|
||||||
|
validateResetPassword,
|
||||||
|
validateVerifyResetToken,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -84,6 +84,14 @@ const User = sequelize.define(
|
|||||||
type: DataTypes.DATE,
|
type: DataTypes.DATE,
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
},
|
},
|
||||||
|
passwordResetToken: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
passwordResetTokenExpiry: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
defaultAvailableAfter: {
|
defaultAvailableAfter: {
|
||||||
type: DataTypes.STRING,
|
type: DataTypes.STRING,
|
||||||
defaultValue: "09:00",
|
defaultValue: "09:00",
|
||||||
@@ -124,6 +132,11 @@ const User = sequelize.define(
|
|||||||
type: DataTypes.DATE,
|
type: DataTypes.DATE,
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
},
|
},
|
||||||
|
jwtVersion: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
defaultValue: 0,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
hooks: {
|
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;
|
module.exports = User;
|
||||||
|
|||||||
@@ -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 { User } = require("../models"); // Import from models/index.js to get models with associations
|
||||||
const logger = require("../utils/logger");
|
const logger = require("../utils/logger");
|
||||||
const emailService = require("../services/emailService");
|
const emailService = require("../services/emailService");
|
||||||
|
const crypto = require("crypto");
|
||||||
|
|
||||||
const {
|
const {
|
||||||
sanitizeInput,
|
sanitizeInput,
|
||||||
validateRegistration,
|
validateRegistration,
|
||||||
validateLogin,
|
validateLogin,
|
||||||
validateGoogleAuth,
|
validateGoogleAuth,
|
||||||
|
validateForgotPassword,
|
||||||
|
validateResetPassword,
|
||||||
|
validateVerifyResetToken,
|
||||||
} = require("../middleware/validation");
|
} = require("../middleware/validation");
|
||||||
const { csrfProtection, getCSRFToken } = require("../middleware/csrf");
|
const { csrfProtection, getCSRFToken } = require("../middleware/csrf");
|
||||||
const { loginLimiter, registerLimiter } = require("../middleware/rateLimiter");
|
const {
|
||||||
|
loginLimiter,
|
||||||
|
registerLimiter,
|
||||||
|
passwordResetLimiter,
|
||||||
|
} = require("../middleware/rateLimiter");
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const googleClient = new OAuth2Client(
|
const googleClient = new OAuth2Client(
|
||||||
process.env.GOOGLE_CLIENT_ID,
|
process.env.GOOGLE_CLIENT_ID,
|
||||||
process.env.GOOGLE_CLIENT_SECRET,
|
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
|
// Get CSRF token endpoint
|
||||||
@@ -76,17 +86,19 @@ router.post(
|
|||||||
reqLogger.error("Failed to send verification email", {
|
reqLogger.error("Failed to send verification email", {
|
||||||
error: emailError.message,
|
error: emailError.message,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
email: user.email
|
email: user.email,
|
||||||
});
|
});
|
||||||
// Continue with registration even if email fails
|
// Continue with registration even if email fails
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET, {
|
const token = jwt.sign(
|
||||||
expiresIn: "15m", // Short-lived access token
|
{ id: user.id, jwtVersion: user.jwtVersion },
|
||||||
});
|
process.env.JWT_SECRET,
|
||||||
|
{ expiresIn: "15m" } // Short-lived access token
|
||||||
|
);
|
||||||
|
|
||||||
const refreshToken = jwt.sign(
|
const refreshToken = jwt.sign(
|
||||||
{ id: user.id, type: "refresh" },
|
{ id: user.id, jwtVersion: user.jwtVersion, type: "refresh" },
|
||||||
process.env.JWT_SECRET,
|
process.env.JWT_SECRET,
|
||||||
{ expiresIn: "7d" }
|
{ expiresIn: "7d" }
|
||||||
);
|
);
|
||||||
@@ -112,7 +124,7 @@ router.post(
|
|||||||
reqLogger.info("User registration successful", {
|
reqLogger.info("User registration successful", {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
email: user.email
|
email: user.email,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
@@ -133,7 +145,7 @@ router.post(
|
|||||||
error: error.message,
|
error: error.message,
|
||||||
stack: error.stack,
|
stack: error.stack,
|
||||||
email: req.body.email,
|
email: req.body.email,
|
||||||
username: req.body.username
|
username: req.body.username,
|
||||||
});
|
});
|
||||||
res.status(500).json({ error: "Registration failed. Please try again." });
|
res.status(500).json({ error: "Registration failed. Please try again." });
|
||||||
}
|
}
|
||||||
@@ -176,12 +188,14 @@ router.post(
|
|||||||
// Reset login attempts on successful login
|
// Reset login attempts on successful login
|
||||||
await user.resetLoginAttempts();
|
await user.resetLoginAttempts();
|
||||||
|
|
||||||
const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET, {
|
const token = jwt.sign(
|
||||||
expiresIn: "15m", // Short-lived access token
|
{ id: user.id, jwtVersion: user.jwtVersion },
|
||||||
});
|
process.env.JWT_SECRET,
|
||||||
|
{ expiresIn: "15m" } // Short-lived access token
|
||||||
|
);
|
||||||
|
|
||||||
const refreshToken = jwt.sign(
|
const refreshToken = jwt.sign(
|
||||||
{ id: user.id, type: "refresh" },
|
{ id: user.id, jwtVersion: user.jwtVersion, type: "refresh" },
|
||||||
process.env.JWT_SECRET,
|
process.env.JWT_SECRET,
|
||||||
{ expiresIn: "7d" }
|
{ expiresIn: "7d" }
|
||||||
);
|
);
|
||||||
@@ -206,7 +220,7 @@ router.post(
|
|||||||
const reqLogger = logger.withRequestId(req.id);
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
reqLogger.info("User login successful", {
|
reqLogger.info("User login successful", {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
email: user.email
|
email: user.email,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -225,7 +239,7 @@ router.post(
|
|||||||
reqLogger.error("Login error", {
|
reqLogger.error("Login error", {
|
||||||
error: error.message,
|
error: error.message,
|
||||||
stack: error.stack,
|
stack: error.stack,
|
||||||
email: req.body.email
|
email: req.body.email,
|
||||||
});
|
});
|
||||||
res.status(500).json({ error: "Login failed. Please try again." });
|
res.status(500).json({ error: "Login failed. Please try again." });
|
||||||
}
|
}
|
||||||
@@ -243,13 +257,17 @@ router.post(
|
|||||||
const { code } = req.body;
|
const { code } = req.body;
|
||||||
|
|
||||||
if (!code) {
|
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
|
// Exchange authorization code for tokens
|
||||||
const { tokens } = await googleClient.getToken({
|
const { tokens } = await googleClient.getToken({
|
||||||
code,
|
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
|
// Verify the ID token from the token response
|
||||||
@@ -303,12 +321,14 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate JWT tokens
|
// Generate JWT tokens
|
||||||
const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET, {
|
const token = jwt.sign(
|
||||||
expiresIn: "15m",
|
{ id: user.id, jwtVersion: user.jwtVersion },
|
||||||
});
|
process.env.JWT_SECRET,
|
||||||
|
{ expiresIn: "15m" }
|
||||||
|
);
|
||||||
|
|
||||||
const refreshToken = jwt.sign(
|
const refreshToken = jwt.sign(
|
||||||
{ id: user.id, type: "refresh" },
|
{ id: user.id, jwtVersion: user.jwtVersion, type: "refresh" },
|
||||||
process.env.JWT_SECRET,
|
process.env.JWT_SECRET,
|
||||||
{ expiresIn: "7d" }
|
{ expiresIn: "7d" }
|
||||||
);
|
);
|
||||||
@@ -334,7 +354,9 @@ router.post(
|
|||||||
reqLogger.info("Google authentication successful", {
|
reqLogger.info("Google authentication successful", {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
email: user.email,
|
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({
|
res.json({
|
||||||
@@ -351,9 +373,9 @@ router.post(
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.message && error.message.includes("invalid_grant")) {
|
if (error.message && error.message.includes("invalid_grant")) {
|
||||||
return res
|
return res.status(401).json({
|
||||||
.status(401)
|
error: "Invalid or expired authorization code. Please try again.",
|
||||||
.json({ error: "Invalid or expired authorization code. Please try again." });
|
});
|
||||||
}
|
}
|
||||||
if (error.message && error.message.includes("redirect_uri_mismatch")) {
|
if (error.message && error.message.includes("redirect_uri_mismatch")) {
|
||||||
return res
|
return res
|
||||||
@@ -364,7 +386,7 @@ router.post(
|
|||||||
reqLogger.error("Google OAuth error", {
|
reqLogger.error("Google OAuth error", {
|
||||||
error: error.message,
|
error: error.message,
|
||||||
stack: error.stack,
|
stack: error.stack,
|
||||||
codePresent: !!req.body.code
|
codePresent: !!req.body.code,
|
||||||
});
|
});
|
||||||
res
|
res
|
||||||
.status(500)
|
.status(500)
|
||||||
@@ -546,10 +568,20 @@ router.post("/refresh", async (req, res) => {
|
|||||||
return res.status(401).json({ error: "User not found" });
|
return res.status(401).json({ error: "User not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate new access token
|
// Validate JWT version to invalidate old tokens after password change
|
||||||
const newAccessToken = jwt.sign({ id: user.id }, process.env.JWT_SECRET, {
|
if (decoded.jwtVersion !== user.jwtVersion) {
|
||||||
expiresIn: "15m",
|
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, jwtVersion: user.jwtVersion },
|
||||||
|
process.env.JWT_SECRET,
|
||||||
|
{ expiresIn: "15m" }
|
||||||
|
);
|
||||||
|
|
||||||
// Set new access token cookie
|
// Set new access token cookie
|
||||||
res.cookie("accessToken", newAccessToken, {
|
res.cookie("accessToken", newAccessToken, {
|
||||||
@@ -561,7 +593,7 @@ router.post("/refresh", async (req, res) => {
|
|||||||
|
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
reqLogger.info("Token refresh successful", {
|
reqLogger.info("Token refresh successful", {
|
||||||
userId: user.id
|
userId: user.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -579,7 +611,7 @@ router.post("/refresh", async (req, res) => {
|
|||||||
reqLogger.error("Token refresh error", {
|
reqLogger.error("Token refresh error", {
|
||||||
error: error.message,
|
error: error.message,
|
||||||
stack: error.stack,
|
stack: error.stack,
|
||||||
userId: req.user?.id
|
userId: req.user?.id,
|
||||||
});
|
});
|
||||||
res.status(401).json({ error: "Invalid or expired refresh token" });
|
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) => {
|
router.post("/logout", (req, res) => {
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
reqLogger.info("User logout", {
|
reqLogger.info("User logout", {
|
||||||
userId: req.user?.id || 'anonymous'
|
userId: req.user?.id || "anonymous",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear cookies
|
// Clear cookies
|
||||||
@@ -604,13 +636,221 @@ router.get("/status", optionalAuth, async (req, res) => {
|
|||||||
if (req.user) {
|
if (req.user) {
|
||||||
res.json({
|
res.json({
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
user: req.user
|
user: req.user,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
res.json({
|
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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ class EmailService {
|
|||||||
"conditionCheckReminder.html",
|
"conditionCheckReminder.html",
|
||||||
"rentalConfirmation.html",
|
"rentalConfirmation.html",
|
||||||
"emailVerification.html",
|
"emailVerification.html",
|
||||||
|
"passwordReset.html",
|
||||||
|
"passwordChanged.html",
|
||||||
"lateReturnCS.html",
|
"lateReturnCS.html",
|
||||||
"damageReportCS.html",
|
"damageReportCS.html",
|
||||||
"lostItemCS.html",
|
"lostItemCS.html",
|
||||||
@@ -244,6 +246,31 @@ class EmailService {
|
|||||||
<p><strong>This link will expire in 24 hours.</strong></p>
|
<p><strong>This link will expire in 24 hours.</strong></p>
|
||||||
`
|
`
|
||||||
),
|
),
|
||||||
|
|
||||||
|
passwordReset: baseTemplate.replace(
|
||||||
|
"{{content}}",
|
||||||
|
`
|
||||||
|
<p>Hi {{recipientName}},</p>
|
||||||
|
<h2>Reset Your Password</h2>
|
||||||
|
<p>We received a request to reset the password for your RentAll account. Click the button below to choose a new password.</p>
|
||||||
|
<p><a href="{{resetUrl}}" class="button">Reset Password</a></p>
|
||||||
|
<p>If the button doesn't work, copy and paste this link into your browser: {{resetUrl}}</p>
|
||||||
|
<p><strong>This link will expire in 1 hour.</strong></p>
|
||||||
|
<p>If you didn't request this, you can safely ignore this email.</p>
|
||||||
|
`
|
||||||
|
),
|
||||||
|
|
||||||
|
passwordChanged: baseTemplate.replace(
|
||||||
|
"{{content}}",
|
||||||
|
`
|
||||||
|
<p>Hi {{recipientName}},</p>
|
||||||
|
<h2>Your Password Has Been Changed</h2>
|
||||||
|
<p>This is a confirmation that the password for your RentAll account ({{email}}) has been successfully changed.</p>
|
||||||
|
<p><strong>Changed on:</strong> {{timestamp}}</p>
|
||||||
|
<p>For your security, all existing sessions have been logged out.</p>
|
||||||
|
<p><strong>Didn't change your password?</strong> If you did not make this change, please contact our support team immediately.</p>
|
||||||
|
`
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 = {}) {
|
async sendTemplateEmail(toEmail, subject, templateName, variables = {}) {
|
||||||
const htmlContent = this.renderTemplate(templateName, variables);
|
const htmlContent = this.renderTemplate(templateName, variables);
|
||||||
return await this.sendEmail(toEmail, subject, htmlContent);
|
return await this.sendEmail(toEmail, subject, htmlContent);
|
||||||
|
|||||||
246
backend/templates/emails/passwordChanged.html
Normal file
246
backend/templates/emails/passwordChanged.html
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<title>Password Changed Successfully - RentAll</title>
|
||||||
|
<style>
|
||||||
|
/* Reset styles */
|
||||||
|
body, table, td, p, a, li, blockquote {
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
table, td {
|
||||||
|
mso-table-lspace: 0pt;
|
||||||
|
mso-table-rspace: 0pt;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
-ms-interpolation-mode: bicubic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base styles */
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100% !important;
|
||||||
|
min-width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #212529;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Container */
|
||||||
|
.email-container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.header {
|
||||||
|
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
|
||||||
|
padding: 40px 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
text-decoration: none;
|
||||||
|
letter-spacing: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagline {
|
||||||
|
color: #d4edda;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content */
|
||||||
|
.content {
|
||||||
|
padding: 40px 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
color: #212529;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content p {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
color: #6c757d;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content strong {
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Success box */
|
||||||
|
.success-box {
|
||||||
|
background-color: #d4edda;
|
||||||
|
border-left: 4px solid #28a745;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-box p {
|
||||||
|
margin: 0;
|
||||||
|
color: #155724;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info box */
|
||||||
|
.info-box {
|
||||||
|
background-color: #e7f3ff;
|
||||||
|
border-left: 4px solid #0066cc;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box p {
|
||||||
|
margin: 0;
|
||||||
|
color: #004085;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Security box */
|
||||||
|
.security-box {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
border-left: 4px solid #dc3545;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 20px 0;
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-box p {
|
||||||
|
margin: 0;
|
||||||
|
color: #721c24;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Details table */
|
||||||
|
.details-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-table td {
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-table td:first-child {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #495057;
|
||||||
|
width: 40%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-table td:last-child {
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.footer {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
padding: 30px;
|
||||||
|
text-align: center;
|
||||||
|
border-top: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer p {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a {
|
||||||
|
color: #667eea;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media only screen and (max-width: 600px) {
|
||||||
|
.email-container {
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header, .content, .footer {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="email-container">
|
||||||
|
<div class="header">
|
||||||
|
<div class="logo">RentAll</div>
|
||||||
|
<div class="tagline">Password Changed Successfully</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<p>Hi {{recipientName}},</p>
|
||||||
|
|
||||||
|
<h1>Your Password Has Been Changed</h1>
|
||||||
|
|
||||||
|
<div class="success-box">
|
||||||
|
<p><strong>Your password was successfully changed.</strong> You can now use your new password to log in to your RentAll account.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>This is a confirmation that the password for your RentAll account has been changed. For your security, all existing sessions have been logged out.</p>
|
||||||
|
|
||||||
|
<table class="details-table">
|
||||||
|
<tr>
|
||||||
|
<td>Date & Time:</td>
|
||||||
|
<td>{{timestamp}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Account Email:</td>
|
||||||
|
<td>{{email}}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="security-box">
|
||||||
|
<p><strong>Didn't change your password?</strong> 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.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
<p><strong>Security reminder:</strong> Keep your password secure and never share it with anyone. We recommend using a strong, unique password and enabling two-factor authentication when available.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Thanks for using RentAll!</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p><strong>RentAll</strong></p>
|
||||||
|
<p>This is a security notification sent to confirm your password change. If you have any concerns about your account security, please contact our support team immediately.</p>
|
||||||
|
<p>© 2024 RentAll. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
246
backend/templates/emails/passwordReset.html
Normal file
246
backend/templates/emails/passwordReset.html
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<title>Reset Your Password - RentAll</title>
|
||||||
|
<style>
|
||||||
|
/* Reset styles */
|
||||||
|
body, table, td, p, a, li, blockquote {
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
table, td {
|
||||||
|
mso-table-lspace: 0pt;
|
||||||
|
mso-table-rspace: 0pt;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
-ms-interpolation-mode: bicubic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base styles */
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100% !important;
|
||||||
|
min-width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #212529;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Container */
|
||||||
|
.email-container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.header {
|
||||||
|
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
|
||||||
|
padding: 40px 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
text-decoration: none;
|
||||||
|
letter-spacing: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagline {
|
||||||
|
color: #d4edda;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content */
|
||||||
|
.content {
|
||||||
|
padding: 40px 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
color: #212529;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content p {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
color: #6c757d;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content strong {
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button */
|
||||||
|
.button {
|
||||||
|
display: inline-block;
|
||||||
|
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
|
||||||
|
color: #ffffff !important;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 16px 32px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 20px 0;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(40, 167, 69, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info box */
|
||||||
|
.info-box {
|
||||||
|
background-color: #e7f3ff;
|
||||||
|
border-left: 4px solid #0066cc;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box p {
|
||||||
|
margin: 0;
|
||||||
|
color: #004085;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Warning box */
|
||||||
|
.warning-box {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
border-left: 4px solid #ffc107;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 20px 0;
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-box p {
|
||||||
|
margin: 0;
|
||||||
|
color: #856404;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Security box */
|
||||||
|
.security-box {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
border-left: 4px solid #dc3545;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 20px 0;
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-box p {
|
||||||
|
margin: 0;
|
||||||
|
color: #721c24;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.footer {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
padding: 30px;
|
||||||
|
text-align: center;
|
||||||
|
border-top: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer p {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a {
|
||||||
|
color: #667eea;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media only screen and (max-width: 600px) {
|
||||||
|
.email-container {
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header, .content, .footer {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="email-container">
|
||||||
|
<div class="header">
|
||||||
|
<div class="logo">RentAll</div>
|
||||||
|
<div class="tagline">Password Reset Request</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<p>Hi {{recipientName}},</p>
|
||||||
|
|
||||||
|
<h1>Reset Your Password</h1>
|
||||||
|
|
||||||
|
<p>We received a request to reset the password for your RentAll account. Click the button below to choose a new password.</p>
|
||||||
|
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<a href="{{resetUrl}}" class="button">Reset Password</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>If the button doesn't work, you can copy and paste this link into your browser:</p>
|
||||||
|
<p style="word-break: break-all; color: #667eea;">{{resetUrl}}</p>
|
||||||
|
|
||||||
|
<div class="warning-box">
|
||||||
|
<p><strong>This link will expire in 1 hour.</strong> For security reasons, password reset links are only valid for a short time. If your link has expired, you can request a new one.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="security-box">
|
||||||
|
<p><strong>Didn't request a password reset?</strong> 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.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
<p><strong>Security tip:</strong> Choose a strong password that includes a mix of uppercase and lowercase letters, numbers, and special characters. Never share your password with anyone.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Thanks for using RentAll!</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p><strong>RentAll</strong></p>
|
||||||
|
<p>This is a transactional email sent in response to a password reset request. You received this message because someone requested a password reset for this email address.</p>
|
||||||
|
<p>If you have any questions or concerns, please contact our support team.</p>
|
||||||
|
<p>© 2024 RentAll. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
293
backend/tests/unit/models/User.passwordReset.test.js
Normal file
293
backend/tests/unit/models/User.passwordReset.test.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
296
backend/tests/unit/models/User.verification.test.js
Normal file
296
backend/tests/unit/models/User.verification.test.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ import AuthModal from './components/AuthModal';
|
|||||||
import Home from './pages/Home';
|
import Home from './pages/Home';
|
||||||
import GoogleCallback from './pages/GoogleCallback';
|
import GoogleCallback from './pages/GoogleCallback';
|
||||||
import VerifyEmail from './pages/VerifyEmail';
|
import VerifyEmail from './pages/VerifyEmail';
|
||||||
|
import ResetPassword from './pages/ResetPassword';
|
||||||
import ItemList from './pages/ItemList';
|
import ItemList from './pages/ItemList';
|
||||||
import ItemDetail from './pages/ItemDetail';
|
import ItemDetail from './pages/ItemDetail';
|
||||||
import EditItem from './pages/EditItem';
|
import EditItem from './pages/EditItem';
|
||||||
@@ -39,6 +40,7 @@ const AppContent: React.FC = () => {
|
|||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
<Route path="/auth/google/callback" element={<GoogleCallback />} />
|
<Route path="/auth/google/callback" element={<GoogleCallback />} />
|
||||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||||
|
<Route path="/reset-password" element={<ResetPassword />} />
|
||||||
<Route path="/items" element={<ItemList />} />
|
<Route path="/items" element={<ItemList />} />
|
||||||
<Route path="/items/:id" element={<ItemDetail />} />
|
<Route path="/items/:id" element={<ItemDetail />} />
|
||||||
<Route path="/users/:id" element={<PublicProfile />} />
|
<Route path="/users/:id" element={<PublicProfile />} />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from "react";
|
|||||||
import { useAuth } from "../contexts/AuthContext";
|
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";
|
||||||
|
|
||||||
interface AuthModalProps {
|
interface AuthModalProps {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
@@ -21,6 +22,7 @@ const AuthModal: React.FC<AuthModalProps> = ({
|
|||||||
const [lastName, setLastName] = useState("");
|
const [lastName, setLastName] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [showForgotPassword, setShowForgotPassword] = useState(false);
|
||||||
|
|
||||||
const { login, register } = useAuth();
|
const { login, register } = useAuth();
|
||||||
|
|
||||||
@@ -83,10 +85,11 @@ const AuthModal: React.FC<AuthModalProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
if (!show) return null;
|
if (!show && !showForgotPassword) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{!showForgotPassword && (
|
||||||
<div
|
<div
|
||||||
className="modal show d-block"
|
className="modal show d-block"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
@@ -168,6 +171,21 @@ const AuthModal: React.FC<AuthModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{mode === "login" && (
|
||||||
|
<div className="text-end mb-3" style={{ marginTop: '-0.5rem' }}>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="text-primary text-decoration-none small"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowForgotPassword(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Forgot password?
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn btn-primary w-100 py-3 mb-1"
|
className="btn btn-primary w-100 py-3 mb-1"
|
||||||
@@ -231,6 +249,14 @@ const AuthModal: React.FC<AuthModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Forgot Password Modal */}
|
||||||
|
<ForgotPasswordModal
|
||||||
|
show={showForgotPassword}
|
||||||
|
onHide={() => setShowForgotPassword(false)}
|
||||||
|
onBackToLogin={() => setShowForgotPassword(false)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
|
||||||
|
|
||||||
interface BetaPasswordProtectionProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const BetaPasswordProtection: React.FC<BetaPasswordProtectionProps> = ({
|
|
||||||
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 (
|
|
||||||
<div className="min-vh-100 d-flex align-items-center justify-content-center">
|
|
||||||
<div className="spinner-border text-primary" role="status">
|
|
||||||
<span className="visually-hidden">Loading...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
return (
|
|
||||||
<div className="min-vh-100 d-flex align-items-center justify-content-center bg-light">
|
|
||||||
<div
|
|
||||||
className="card shadow"
|
|
||||||
style={{ maxWidth: "400px", width: "100%" }}
|
|
||||||
>
|
|
||||||
<div className="card-body p-5">
|
|
||||||
<h2 className="text-center mb-4">Beta Access Required</h2>
|
|
||||||
<p className="text-muted text-center mb-4">
|
|
||||||
This site is currently in beta testing. Please enter the beta
|
|
||||||
password to continue.
|
|
||||||
</p>
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label htmlFor="betaPassword" className="form-label">
|
|
||||||
Beta Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
className="form-control"
|
|
||||||
id="betaPassword"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
placeholder="Enter beta password"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{error && (
|
|
||||||
<div className="alert alert-danger" role="alert">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button type="submit" className="btn btn-primary w-100">
|
|
||||||
Access Beta
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default BetaPasswordProtection;
|
|
||||||
175
frontend/src/components/ForgotPasswordModal.tsx
Normal file
175
frontend/src/components/ForgotPasswordModal.tsx
Normal file
@@ -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<ForgotPasswordModalProps> = ({
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<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={() => {
|
||||||
|
resetModal();
|
||||||
|
onHide();
|
||||||
|
}}
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body px-4 pb-4">
|
||||||
|
{success ? (
|
||||||
|
<>
|
||||||
|
<div className="text-center">
|
||||||
|
<i
|
||||||
|
className="bi bi-envelope-check text-success"
|
||||||
|
style={{ fontSize: "3rem" }}
|
||||||
|
></i>
|
||||||
|
<h4 className="mt-3">Check Your Email</h4>
|
||||||
|
<p className="text-muted">
|
||||||
|
If an account exists with that email address, you will
|
||||||
|
receive password reset instructions shortly.
|
||||||
|
</p>
|
||||||
|
<p className="text-muted small">
|
||||||
|
Please check your spam folder if you don't see the email
|
||||||
|
within a few minutes.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary mt-3"
|
||||||
|
onClick={() => {
|
||||||
|
resetModal();
|
||||||
|
onHide();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h4 className="text-center mb-2">Forgot Password?</h4>
|
||||||
|
<p className="text-center text-muted mb-4">
|
||||||
|
Enter your email address and we'll send you instructions to
|
||||||
|
reset your password.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-danger" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Email Address</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className="form-control"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="your@email.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary w-100 py-3"
|
||||||
|
disabled={loading || !email}
|
||||||
|
>
|
||||||
|
{loading ? "Sending..." : "Send Reset Instructions"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="text-center mt-3">
|
||||||
|
<small className="text-muted">
|
||||||
|
Remember your password?{" "}
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="text-primary text-decoration-none"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
resetModal();
|
||||||
|
if (onBackToLogin) {
|
||||||
|
onBackToLogin();
|
||||||
|
} else {
|
||||||
|
onHide();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back to Login
|
||||||
|
</a>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ForgotPasswordModal;
|
||||||
205
frontend/src/pages/ResetPassword.tsx
Normal file
205
frontend/src/pages/ResetPassword.tsx
Normal file
@@ -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<string>('');
|
||||||
|
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 (
|
||||||
|
<div className="container">
|
||||||
|
<div className="row justify-content-center mt-5">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-body py-5">
|
||||||
|
{validating ? (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="spinner-border text-primary mb-3" role="status">
|
||||||
|
<span className="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<h5>Verifying Reset Link...</h5>
|
||||||
|
<p className="text-muted">Please wait while we verify your password reset link.</p>
|
||||||
|
</div>
|
||||||
|
) : success ? (
|
||||||
|
<div className="text-center">
|
||||||
|
<i className="bi bi-check-circle text-success" style={{ fontSize: '3rem' }}></i>
|
||||||
|
<h5 className="mt-3">Password Reset Successfully!</h5>
|
||||||
|
<p className="text-muted">
|
||||||
|
Your password has been reset. You can now log in with your new password.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary mt-3"
|
||||||
|
onClick={() => {
|
||||||
|
navigate('/', { replace: true });
|
||||||
|
openAuthModal('login');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Log In Now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : !tokenValid ? (
|
||||||
|
<div className="text-center">
|
||||||
|
<i className="bi bi-exclamation-circle text-danger" style={{ fontSize: '3rem' }}></i>
|
||||||
|
<h5 className="mt-3">Invalid Reset Link</h5>
|
||||||
|
<p className="text-danger">{error}</p>
|
||||||
|
<div className="mt-3">
|
||||||
|
<Link to="/" className="btn btn-outline-secondary">
|
||||||
|
Return to Home
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-center mb-4">
|
||||||
|
<h4>Reset Your Password</h4>
|
||||||
|
<p className="text-muted">Enter your new password below.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-danger" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<PasswordInput
|
||||||
|
id="newPassword"
|
||||||
|
label="New Password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: '-0.75rem', marginBottom: '1rem' }}>
|
||||||
|
<PasswordStrengthMeter password={newPassword} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PasswordInput
|
||||||
|
id="confirmPassword"
|
||||||
|
label="Confirm New Password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary w-100 py-3 mt-3"
|
||||||
|
disabled={submitting || !newPassword || !confirmPassword}
|
||||||
|
>
|
||||||
|
{submitting ? 'Resetting Password...' : 'Reset Password'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="text-center mt-3">
|
||||||
|
<Link to="/" className="text-decoration-none">
|
||||||
|
Return to Home
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ResetPassword;
|
||||||
@@ -168,6 +168,9 @@ export const authAPI = {
|
|||||||
getStatus: () => api.get("/auth/status"),
|
getStatus: () => api.get("/auth/status"),
|
||||||
verifyEmail: (token: string) => api.post("/auth/verify-email", { token }),
|
verifyEmail: (token: string) => api.post("/auth/verify-email", { token }),
|
||||||
resendVerification: () => api.post("/auth/resend-verification"),
|
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 = {
|
export const userAPI = {
|
||||||
|
|||||||
Reference in New Issue
Block a user