From 8b4bffe5d449b5f4795fa2dd6cd11f933b0251dd Mon Sep 17 00:00:00 2001
From: Muhammad Saffi Ullah <42832684+saffiullah200@users.noreply.github.com>
Date: Sun, 25 Jan 2026 01:25:23 +0100
Subject: [PATCH 1/6] feat: implement full inactivity tracking and account
reactivation system
- Add inactivity email templates (warning, archived, weekly report)
- Create reactivate-user endpoint for archived accounts
- Update sign-in endpoints to handle archived users with proper response
- Reset inactivity tracking flags on successful login
- Create inactivity-checker helper with functions:
* sendInactivityWarnings: email users inactive for 1+ year
* archiveInactiveUsers: archive users 7 days after warning
* runWeeklyReport: send admin summary email
- Add cron endpoints for scheduled tasks:
* POST /cron/inactivity-check (daily)
* POST /cron/weekly-report (weekly)
- Protected cron endpoints with CRON_SECRET header
- Add validation for reactivate-user endpoint
Inactivity workflow:
1. User inactive for 1 year -> warning email sent
2. No login within 7 days -> account archived
3. Archived user can reactivate via /users/reactivate endpoint
---
.gitignore | 8 +-
src/helpers/inactivity-checker.js | 244 +++++++++++++++++++++++++++
src/helpers/mail-template.js | 229 +++++++++++++++++++++++++
src/routes/auth/facebook-sign-in.js | 11 +-
src/routes/auth/google-sign-in.js | 11 +-
src/routes/auth/sign-in.js | 21 ++-
src/routes/others/inactivity-cron.js | 48 ++++++
src/routes/others/index.js | 5 +
src/routes/users/index.js | 4 +
src/routes/users/reactivate-user.js | 91 ++++++++++
src/routes/users/validations.js | 43 +++++
11 files changed, 705 insertions(+), 10 deletions(-)
create mode 100644 src/helpers/inactivity-checker.js
create mode 100644 src/routes/others/inactivity-cron.js
create mode 100644 src/routes/users/reactivate-user.js
diff --git a/.gitignore b/.gitignore
index 059e090..9a9c987 100644
--- a/.gitignore
+++ b/.gitignore
@@ -61,4 +61,10 @@ typings/
.vscode/
# MacOS files
-.DS_Store
\ No newline at end of file
+.DS_Store
+
+# PEM files (SSH keys, certificates)
+*.pem
+
+# Documentation (local only)
+docs/*.md
\ No newline at end of file
diff --git a/src/helpers/inactivity-checker.js b/src/helpers/inactivity-checker.js
new file mode 100644
index 0000000..5e10fe3
--- /dev/null
+++ b/src/helpers/inactivity-checker.js
@@ -0,0 +1,244 @@
+const moment = require("moment");
+const { User } = require("../models/user");
+const { RefreshToken } = require("../models/refresh-token");
+const { sendEmail } = require("../helpers");
+const {
+ inactivityWarningEmailTemplate,
+ accountArchivedEmailTemplate,
+ weeklyInactivityReportEmailTemplate,
+} = require("../helpers/mail-template");
+
+const INACTIVITY_THRESHOLD_DAYS = 365; // 1 year
+const ARCHIVE_GRACE_PERIOD_DAYS = 7; // 7 days after warning email
+const ADMIN_EMAIL = process.env.ADMIN_EMAIL || "admin@axsmap.com";
+const APP_URL = process.env.APP_URL || "https://www.axsmap.com";
+
+/**
+ * Find users who haven't logged in for over a year and haven't received an inactivity email yet
+ * Send them a warning email
+ */
+async function sendInactivityWarnings() {
+ const oneYearAgo = moment.utc().subtract(INACTIVITY_THRESHOLD_DAYS, "days").toDate();
+
+ try {
+ const inactiveUsers = await User.find({
+ isArchived: false,
+ isBlocked: false,
+ inactivityEmailSent: { $ne: true },
+ $or: [
+ { lastLogin: { $lt: oneYearAgo } },
+ { lastLogin: null, createdAt: { $lt: oneYearAgo } }
+ ]
+ }).select("_id email firstName lastName lastLogin createdAt");
+
+ console.log(`[Inactivity Check] Found ${inactiveUsers.length} users to send warnings to`);
+
+ const warningsSent = [];
+
+ for (const user of inactiveUsers) {
+ try {
+ const loginUrl = `${APP_URL}/sign-in`;
+ const emailContent = inactivityWarningEmailTemplate(
+ user.firstName || "User",
+ loginUrl
+ );
+
+ await sendEmail({
+ receiversEmails: [user.email],
+ subject: "We miss you! Your AXS Map account needs attention",
+ htmlContent: emailContent,
+ textContent: `Hi ${user.firstName}, we noticed you haven't logged into AXS Map in over a year. Please log in within 7 days to keep your account active.`,
+ });
+
+ // Mark that we've sent the inactivity email
+ await User.findByIdAndUpdate(user._id, {
+ inactivityEmailSent: true,
+ inactivityEmailSentAt: new Date(),
+ });
+
+ warningsSent.push({
+ email: user.email,
+ firstName: user.firstName,
+ lastName: user.lastName,
+ });
+
+ console.log(`[Inactivity Check] Warning email sent to ${user.email}`);
+ } catch (emailErr) {
+ console.error(`[Inactivity Check] Failed to send warning email to ${user.email}:`, emailErr.message);
+ }
+ }
+
+ return warningsSent;
+ } catch (err) {
+ console.error("[Inactivity Check] Error finding inactive users:", err.message);
+ return [];
+ }
+}
+
+/**
+ * Find users who received an inactivity email more than 7 days ago and still haven't logged in
+ * Archive their accounts
+ */
+async function archiveInactiveUsers() {
+ const sevenDaysAgo = moment.utc().subtract(ARCHIVE_GRACE_PERIOD_DAYS, "days").toDate();
+
+ try {
+ const usersToArchive = await User.find({
+ isArchived: false,
+ inactivityEmailSent: true,
+ inactivityEmailSentAt: { $lt: sevenDaysAgo },
+ }).select("_id email firstName lastName");
+
+ console.log(`[Inactivity Check] Found ${usersToArchive.length} users to archive`);
+
+ const archivedUsers = [];
+
+ for (const user of usersToArchive) {
+ try {
+ // Archive the user
+ await User.findByIdAndUpdate(user._id, {
+ isArchived: true,
+ updatedAt: new Date(),
+ });
+
+ // Delete their refresh token
+ await RefreshToken.deleteOne({ userId: user._id.toString() });
+
+ // Send archived notification email
+ const reactivateUrl = `${APP_URL}/reactivate-account`;
+ const emailContent = accountArchivedEmailTemplate(
+ user.firstName || "User",
+ reactivateUrl
+ );
+
+ await sendEmail({
+ receiversEmails: [user.email],
+ subject: "Your AXS Map account has been archived",
+ htmlContent: emailContent,
+ textContent: `Hi ${user.firstName}, your AXS Map account has been archived due to inactivity. You can reactivate it anytime by visiting ${reactivateUrl}`,
+ });
+
+ archivedUsers.push({
+ email: user.email,
+ firstName: user.firstName,
+ lastName: user.lastName,
+ });
+
+ console.log(`[Inactivity Check] Archived user ${user.email}`);
+ } catch (archiveErr) {
+ console.error(`[Inactivity Check] Failed to archive user ${user.email}:`, archiveErr.message);
+ }
+ }
+
+ return archivedUsers;
+ } catch (err) {
+ console.error("[Inactivity Check] Error archiving inactive users:", err.message);
+ return [];
+ }
+}
+
+/**
+ * Get count of users reactivated in the past week
+ */
+async function getReactivatedUsersCount() {
+ const oneWeekAgo = moment.utc().subtract(7, "days").toDate();
+
+ try {
+ const count = await User.countDocuments({
+ isArchived: false,
+ inactivityEmailSent: false,
+ lastLogin: { $gte: oneWeekAgo },
+ updatedAt: { $gte: oneWeekAgo },
+ });
+ return count;
+ } catch (err) {
+ console.error("[Inactivity Check] Error counting reactivated users:", err.message);
+ return 0;
+ }
+}
+
+/**
+ * Send weekly report to admin team
+ */
+async function sendWeeklyReport(warningsSent, archivedUsers) {
+ const weekEndDate = moment.utc().format("MMMM D, YYYY");
+ const weekStartDate = moment.utc().subtract(7, "days").format("MMMM D, YYYY");
+
+ const totalReactivated = await getReactivatedUsersCount();
+
+ const reportData = {
+ weekStartDate,
+ weekEndDate,
+ totalWarningsSent: warningsSent.length,
+ totalArchived: archivedUsers.length,
+ totalReactivated,
+ archivedUsers,
+ warningsSentUsers: warningsSent,
+ };
+
+ const emailContent = weeklyInactivityReportEmailTemplate(reportData);
+
+ try {
+ await sendEmail({
+ receiversEmails: [ADMIN_EMAIL],
+ subject: `AXS Map Weekly Inactivity Report - ${weekEndDate}`,
+ htmlContent: emailContent,
+ textContent: `Weekly Inactivity Report: ${warningsSent.length} warnings sent, ${archivedUsers.length} users archived, ${totalReactivated} users reactivated.`,
+ });
+
+ console.log(`[Inactivity Check] Weekly report sent to ${ADMIN_EMAIL}`);
+ } catch (err) {
+ console.error("[Inactivity Check] Failed to send weekly report:", err.message);
+ }
+}
+
+/**
+ * Main function to run the inactivity check process
+ * This should be called by a cron job daily
+ */
+async function runInactivityCheck() {
+ console.log("[Inactivity Check] Starting inactivity check process...");
+
+ // Step 1: Send warnings to users who haven't logged in for over a year
+ const warningsSent = await sendInactivityWarnings();
+
+ // Step 2: Archive users who didn't respond to warnings within 7 days
+ const archivedUsers = await archiveInactiveUsers();
+
+ console.log(`[Inactivity Check] Completed. Warnings sent: ${warningsSent.length}, Users archived: ${archivedUsers.length}`);
+
+ return {
+ warningsSent,
+ archivedUsers,
+ };
+}
+
+/**
+ * Run the weekly report (should be called once per week)
+ */
+async function runWeeklyReport() {
+ console.log("[Inactivity Check] Generating weekly report...");
+
+ // Get data from the past week
+ const oneWeekAgo = moment.utc().subtract(7, "days").toDate();
+
+ // Users who received warnings this week
+ const warningsSentUsers = await User.find({
+ inactivityEmailSentAt: { $gte: oneWeekAgo },
+ }).select("email firstName lastName");
+
+ // Users archived this week
+ const archivedUsers = await User.find({
+ isArchived: true,
+ updatedAt: { $gte: oneWeekAgo },
+ }).select("email firstName lastName");
+
+ await sendWeeklyReport(warningsSentUsers, archivedUsers);
+}
+
+module.exports = {
+ runInactivityCheck,
+ runWeeklyReport,
+ sendInactivityWarnings,
+ archiveInactiveUsers,
+};
diff --git a/src/helpers/mail-template.js b/src/helpers/mail-template.js
index bbb4960..b33b2c0 100644
--- a/src/helpers/mail-template.js
+++ b/src/helpers/mail-template.js
@@ -226,9 +226,238 @@ const donationMailTemplate = (name) => {
`;
};
+const inactivityWarningEmailTemplate = (name, loginUrl) => {
+ return `
+
+
+
+
+
+
+
+
+
+
+
+ We Miss You! 👋
+ Your AXS Map account needs attention
+ |
+
+
+
+ |
+
+ Hi ${name},
+
+
+
+ We noticed you haven't logged into AXS Map in over a year. We'd hate to see you go!
+
+
+
+ Important: To keep your account active, please log in within the next 7 days. If we don't hear from you, your account will be archived for security purposes.
+
+
+
+
+
+ Don't worry — even if your account is archived, you can always reactivate it by logging in and resetting your password.
+
+
+
+ We'd love to have you back helping make the world more accessible!
+
+
+
+ Best, The AXS Map Team
+
+ |
+
+
+
+
+ |
+
+ Questions? Contact Support
+
+ © 2025 AXS MAP. All rights reserved.
+ |
+
+
+
+ |
+
+
+
+
+`;
+};
+
+const accountArchivedEmailTemplate = (name, reactivateUrl) => {
+ return `
+
+
+
+
+
+
+
+
+
+
+
+ Your Account Has Been Archived 📁
+ But you can come back anytime!
+ |
+
+
+
+ |
+
+ Hi ${name},
+
+
+
+ Due to inactivity, your AXS Map account has been archived. This is a security measure to protect your data.
+
+
+
+ Good news: You can reactivate your account at any time! Simply click the button below to start the reactivation process.
+
+
+
+
+
+ During reactivation, you'll need to set a new password and confirm your profile information.
+
+
+
+ We hope to see you back soon!
+
+
+
+ Best, The AXS Map Team
+
+ |
+
+
+
+
+ |
+
+ Questions? Contact Support
+
+ © 2025 AXS MAP. All rights reserved.
+ |
+
+
+
+ |
+
+
+
+
+`;
+};
+
+const weeklyInactivityReportEmailTemplate = (reportData) => {
+ const {
+ weekStartDate,
+ weekEndDate,
+ totalWarningsSent,
+ totalArchived,
+ totalReactivated,
+ archivedUsers,
+ warningsSentUsers
+ } = reportData;
+
+ const archivedUsersList = archivedUsers && archivedUsers.length > 0
+ ? archivedUsers.map(u => `${u.email} (${u.firstName} ${u.lastName})`).join('')
+ : 'No users archived this week';
+
+ const warningsSentList = warningsSentUsers && warningsSentUsers.length > 0
+ ? warningsSentUsers.map(u => `${u.email} (${u.firstName} ${u.lastName})`).join('')
+ : 'No warnings sent this week';
+
+ return `
+
+
+
+
+
+
+
+
+
+
+
+ Weekly Inactivity Report 📊
+ ${weekStartDate} - ${weekEndDate}
+ |
+
+
+
+
+ Summary
+
+
+
+ | Inactivity Warnings Sent |
+ ${totalWarningsSent} |
+
+
+ | Accounts Archived |
+ ${totalArchived} |
+
+
+ | Accounts Reactivated |
+ ${totalReactivated} |
+
+
+
+ Archived Users
+
+
+ Warnings Sent To
+
+ |
+
+
+
+
+ |
+ This is an automated report from AXS Map
+ © 2025 AXS MAP. Admin Report
+ |
+
+
+
+ |
+
+
+
+
+`;
+};
+
module.exports = {
activationEmailTemplate,
submitServeyUserMailTemplate,
adminServeyMailTemplate,
donationMailTemplate,
+ inactivityWarningEmailTemplate,
+ accountArchivedEmailTemplate,
+ weeklyInactivityReportEmailTemplate,
};
diff --git a/src/routes/auth/facebook-sign-in.js b/src/routes/auth/facebook-sign-in.js
index 8bcb50f..dd75376 100644
--- a/src/routes/auth/facebook-sign-in.js
+++ b/src/routes/auth/facebook-sign-in.js
@@ -66,14 +66,19 @@ module.exports = async (req, res, next) => {
// Check if user is archived
if (user.isArchived) {
return res.status(403).json({
- error: "Account archived",
+ general: "Account is archived due to inactivity",
isArchived: true,
+ requiresReactivation: true,
userId: user._id.toString()
});
}
- // Update lastLogin for existing users
- await User.findByIdAndUpdate(user._id, { lastLogin: new Date() });
+ // Update lastLogin for existing users and reset inactivity tracking
+ await User.findByIdAndUpdate(user._id, {
+ lastLogin: new Date(),
+ inactivityEmailSent: false,
+ inactivityEmailSentAt: null
+ });
}
// Set token expiration based on rememberMe
diff --git a/src/routes/auth/google-sign-in.js b/src/routes/auth/google-sign-in.js
index d424e4b..ce2c323 100644
--- a/src/routes/auth/google-sign-in.js
+++ b/src/routes/auth/google-sign-in.js
@@ -67,14 +67,19 @@ module.exports = async (req, res) => {
// Check if user is archived
if (user.isArchived) {
return res.status(403).json({
- error: "Account archived",
+ general: "Account is archived due to inactivity",
isArchived: true,
+ requiresReactivation: true,
userId: user._id.toString()
});
}
- // Update lastLogin for existing users
- await User.findByIdAndUpdate(user._id, { lastLogin: new Date() });
+ // Update lastLogin for existing users and reset inactivity tracking
+ await User.findByIdAndUpdate(user._id, {
+ lastLogin: new Date(),
+ inactivityEmailSent: false,
+ inactivityEmailSentAt: null
+ });
}
// Set token expiration based on rememberMe
diff --git a/src/routes/auth/sign-in.js b/src/routes/auth/sign-in.js
index 01a8529..1f69e7c 100644
--- a/src/routes/auth/sign-in.js
+++ b/src/routes/auth/sign-in.js
@@ -20,7 +20,8 @@ module.exports = async (req, res, next) => {
let user;
try {
- user = await User.findOne({ email, isArchived: false });
+ // First check if user exists (including archived users)
+ user = await User.findOne({ email });
console.log("User", user);
} catch (err) {
console.log(`User with email ${email} failed to be found at sign-in.`);
@@ -31,6 +32,16 @@ module.exports = async (req, res, next) => {
return res.status(400).json({ general: "Email or password incorrect" });
}
+ // Check if user is archived - redirect to reactivation
+ if (user.isArchived) {
+ return res.status(403).json({
+ general: "Account is archived due to inactivity",
+ isArchived: true,
+ requiresReactivation: true,
+ userId: user._id.toString()
+ });
+ }
+
if (user.isBlocked) {
return res.status(423).json({ general: "You are blocked" });
}
@@ -47,9 +58,13 @@ module.exports = async (req, res, next) => {
const userId = user.id;
- // Update lastLogin timestamp
+ // Update lastLogin timestamp and reset inactivity tracking
try {
- await User.findByIdAndUpdate(userId, { lastLogin: new Date() });
+ await User.findByIdAndUpdate(userId, {
+ lastLogin: new Date(),
+ inactivityEmailSent: false,
+ inactivityEmailSentAt: null
+ });
} catch (updateErr) {
console.log(`Failed to update lastLogin for userId ${userId}: ${updateErr.message}`);
// Continue with login even if lastLogin update fails
diff --git a/src/routes/others/inactivity-cron.js b/src/routes/others/inactivity-cron.js
new file mode 100644
index 0000000..f61b2d3
--- /dev/null
+++ b/src/routes/others/inactivity-cron.js
@@ -0,0 +1,48 @@
+const { runInactivityCheck, runWeeklyReport } = require("../../helpers/inactivity-checker");
+
+/**
+ * Endpoint to trigger inactivity check
+ * Should be called by a cron job daily
+ * Protected by a secret key in the header
+ */
+module.exports = {
+ runDailyCheck: async (req, res) => {
+ // Verify the cron secret to prevent unauthorized access
+ const cronSecret = req.headers["x-cron-secret"];
+ if (cronSecret !== process.env.CRON_SECRET) {
+ return res.status(401).json({ error: "Unauthorized" });
+ }
+
+ try {
+ const result = await runInactivityCheck();
+ return res.status(200).json({
+ success: true,
+ message: "Inactivity check completed",
+ warningsSent: result.warningsSent.length,
+ usersArchived: result.archivedUsers.length,
+ });
+ } catch (err) {
+ console.error("[Cron] Failed to run inactivity check:", err.message);
+ return res.status(500).json({ error: "Failed to run inactivity check" });
+ }
+ },
+
+ runWeeklyReportEndpoint: async (req, res) => {
+ // Verify the cron secret to prevent unauthorized access
+ const cronSecret = req.headers["x-cron-secret"];
+ if (cronSecret !== process.env.CRON_SECRET) {
+ return res.status(401).json({ error: "Unauthorized" });
+ }
+
+ try {
+ await runWeeklyReport();
+ return res.status(200).json({
+ success: true,
+ message: "Weekly report sent",
+ });
+ } catch (err) {
+ console.error("[Cron] Failed to send weekly report:", err.message);
+ return res.status(500).json({ error: "Failed to send weekly report" });
+ }
+ },
+};
diff --git a/src/routes/others/index.js b/src/routes/others/index.js
index 0ba2a70..e4d77fe 100644
--- a/src/routes/others/index.js
+++ b/src/routes/others/index.js
@@ -3,6 +3,7 @@ const express = require("express");
const contact = require("./contact");
const migrateScores = require("./migrate-scores");
const survey = require("./survey");
+const { runDailyCheck, runWeeklyReportEndpoint } = require("./inactivity-cron");
const { isAuthenticated } = require("../../helpers");
const router = new express.Router();
@@ -11,4 +12,8 @@ router.post("/contact", contact);
router.post("/survey", isAuthenticated({ isOptional: false }), survey);
router.get("/migrate-scores", migrateScores);
+// Cron job endpoints for inactivity tracking
+router.post("/cron/inactivity-check", runDailyCheck);
+router.post("/cron/weekly-report", runWeeklyReportEndpoint);
+
module.exports = router;
diff --git a/src/routes/users/index.js b/src/routes/users/index.js
index eb38e7a..3467cfa 100644
--- a/src/routes/users/index.js
+++ b/src/routes/users/index.js
@@ -13,9 +13,13 @@ const getProfile = require('./get-profile');
const listUsers = require('./list-users');
const unblockUser = require('./unblock-user');
const deactivateUser = require('./deactivate-user');
+const reactivateUser = require('./reactivate-user');
const router = new express.Router();
+// Public endpoint for reactivating archived accounts (no auth required)
+router.post('/reactivate', reactivateUser);
+
router.get('/profile', isAuthenticated({ isOptional: false }), getProfile);
router.put('/password', isAuthenticated({ isOptional: false }), changePassword);
router.get('', isAuthenticated({ isOptional: false }), listUsers);
diff --git a/src/routes/users/reactivate-user.js b/src/routes/users/reactivate-user.js
new file mode 100644
index 0000000..102b178
--- /dev/null
+++ b/src/routes/users/reactivate-user.js
@@ -0,0 +1,91 @@
+const crypto = require("crypto");
+const jwt = require("jsonwebtoken");
+const moment = require("moment");
+
+const { RefreshToken } = require("../../models/refresh-token");
+const { User } = require("../../models/user");
+
+const { validateReactivateUser } = require("./validations");
+
+/**
+ * Reactivate an archived user account
+ * User must provide email, new password, and required profile fields
+ */
+module.exports = async (req, res, next) => {
+ const { errors, isValid } = validateReactivateUser(req.body);
+ if (!isValid) {
+ return res.status(400).json(errors);
+ }
+
+ const { email, password, firstName, lastName } = req.body;
+
+ let user;
+ try {
+ // Find the archived user by email
+ user = await User.findOne({ email, isArchived: true });
+ } catch (err) {
+ console.log(`User with email ${email} failed to be found at reactivate-user.`);
+ return next(err);
+ }
+
+ if (!user) {
+ return res.status(404).json({ general: "Archived account not found with this email" });
+ }
+
+ // Update user fields for reactivation
+ user.isArchived = false;
+ user.password = password; // Will be hashed by the virtual setter
+ user.firstName = firstName;
+ user.lastName = lastName;
+ user.lastLogin = new Date();
+ user.inactivityEmailSent = false;
+ user.inactivityEmailSentAt = null;
+ user.updatedAt = moment.utc().toDate();
+
+ // Update optional fields if provided
+ if (req.body.phone) user.phone = req.body.phone;
+ if (req.body.zip) user.zip = req.body.zip;
+ if (req.body.gender) user.gender = req.body.gender;
+ if (req.body.disabilities) user.disabilities = req.body.disabilities;
+
+ try {
+ await user.save();
+ } catch (err) {
+ console.log(`User with email ${email} failed to be reactivated.`);
+ return next(err);
+ }
+
+ // Generate tokens for the reactivated user
+ const userId = user.id;
+ const today = moment.utc();
+ const expiresAt = today.add(7, "days").toDate();
+ const key = `${userId}${crypto.randomBytes(28).toString("hex")}`;
+
+ let refreshToken;
+ try {
+ refreshToken = await RefreshToken.findOneAndUpdate(
+ { userId },
+ { expiresAt, key, userId, rememberMe: false },
+ { new: true, setDefaultsOnInsert: true, upsert: true }
+ );
+ } catch (err) {
+ console.log(`Refresh Token for userId ${userId} failed to be created at reactivate-user.`);
+ return next(err);
+ }
+
+ const token = jwt.sign({ userId }, process.env.JWT_SECRET, {
+ expiresIn: '7d',
+ });
+
+ return res.status(200).json({
+ general: "Account reactivated successfully",
+ token,
+ refreshToken: refreshToken.key,
+ user: {
+ id: user.id,
+ email: user.email,
+ firstName: user.firstName,
+ lastName: user.lastName,
+ }
+ });
+};
diff --git a/src/routes/users/validations.js b/src/routes/users/validations.js
index d59b5f9..cccf82c 100644
--- a/src/routes/users/validations.js
+++ b/src/routes/users/validations.js
@@ -294,6 +294,49 @@ module.exports = {
errors.sortBy = 'Should be a valid sort';
}
+ return { errors, isValid: isEmpty(errors) };
+ },
+ validateReactivateUser(data) {
+ const errors = {};
+
+ if (!data.email) {
+ errors.email = 'Is required';
+ } else if (typeof data.email !== 'string') {
+ errors.email = 'Should be a string';
+ } else if (!isEmail(data.email)) {
+ errors.email = 'Should be a valid email';
+ }
+
+ if (!data.password) {
+ errors.password = 'Is required';
+ } else if (typeof data.password !== 'string') {
+ errors.password = 'Should be a string';
+ } else if (data.password.length < 8) {
+ errors.password = 'Should have more than 7 characters';
+ } else if (data.password.length > 30) {
+ errors.password = 'Should have less than 31 characters';
+ }
+
+ if (!data.firstName) {
+ errors.firstName = 'Is required';
+ } else if (typeof data.firstName !== 'string') {
+ errors.firstName = 'Should be a string';
+ } else if (/[~`!#$%^&*+=\-[\]\\';,./{}|\\":<>?\d]/g.test(data.firstName)) {
+ errors.firstName = 'Should only have letters';
+ } else if (cleanSpaces(data.firstName).length > 24) {
+ errors.firstName = 'Should have less than 25 characters';
+ }
+
+ if (!data.lastName) {
+ errors.lastName = 'Is required';
+ } else if (typeof data.lastName !== 'string') {
+ errors.lastName = 'Should be a string';
+ } else if (/[~`!#$%^&*+=\-[\]\\';,./{}|\\":<>?\d]/g.test(data.lastName)) {
+ errors.lastName = 'Should only have letters';
+ } else if (cleanSpaces(data.lastName).length > 36) {
+ errors.lastName = 'Should have less than 37 characters';
+ }
+
return { errors, isValid: isEmpty(errors) };
}
};
From a416982d9c3b1b6c7ddc1bee7ce61114a58bcd9a Mon Sep 17 00:00:00 2001
From: Muhammad Saffi Ullah <42832684+saffiullah200@users.noreply.github.com>
Date: Sun, 25 Jan 2026 01:40:42 +0100
Subject: [PATCH 2/6] refactor: only use lastLogin for inactivity tracking, no
createdAt fallback
- Remove $or condition that used createdAt as fallback
- Only send warning emails to users with recorded lastLogin
- This prevents mass emails to 13k+ existing users
- Inactivity tracking will only apply to users who log in after this feature is deployed
---
src/helpers/inactivity-checker.js | 9 ++++-----
1 file changed, 4 insertions(+), 5 deletions(-)
diff --git a/src/helpers/inactivity-checker.js b/src/helpers/inactivity-checker.js
index 5e10fe3..9fbad4d 100644
--- a/src/helpers/inactivity-checker.js
+++ b/src/helpers/inactivity-checker.js
@@ -16,20 +16,19 @@ const APP_URL = process.env.APP_URL || "https://www.axsmap.com";
/**
* Find users who haven't logged in for over a year and haven't received an inactivity email yet
* Send them a warning email
+ * Note: Only users with a recorded lastLogin will be checked (no fallback to createdAt)
*/
async function sendInactivityWarnings() {
const oneYearAgo = moment.utc().subtract(INACTIVITY_THRESHOLD_DAYS, "days").toDate();
try {
+ // Only check users who have lastLogin recorded (not null)
const inactiveUsers = await User.find({
isArchived: false,
isBlocked: false,
inactivityEmailSent: { $ne: true },
- $or: [
- { lastLogin: { $lt: oneYearAgo } },
- { lastLogin: null, createdAt: { $lt: oneYearAgo } }
- ]
- }).select("_id email firstName lastName lastLogin createdAt");
+ lastLogin: { $ne: null, $lt: oneYearAgo }
+ }).select("_id email firstName lastName lastLogin");
console.log(`[Inactivity Check] Found ${inactiveUsers.length} users to send warnings to`);
From 76780923b1b2a575f5190a01fba449aec5b8a017 Mon Sep 17 00:00:00 2001
From: Muhammad Saffi Ullah <42832684+saffiullah200@users.noreply.github.com>
Date: Sun, 25 Jan 2026 01:46:38 +0100
Subject: [PATCH 3/6] fix: address code review feedback for inactivity tracking
- Fix textContent email fallback (use displayName instead of user.firstName directly)
- Add reactivatedAt field to User model for accurate reactivation tracking
- Update getReactivatedUsersCount to use reactivatedAt timestamp
- Set reactivatedAt when user reactivates their account
- Fix CRON_SECRET check to fail closed (reject if not configured)
- Remove userId from archived user responses (security/enumeration risk)
- Remove debug console.log from sign-in
---
src/helpers/inactivity-checker.js | 15 ++++++++-------
src/models/user.js | 4 ++++
src/routes/auth/facebook-sign-in.js | 3 +--
src/routes/auth/google-sign-in.js | 3 +--
src/routes/auth/sign-in.js | 4 +---
src/routes/others/inactivity-cron.js | 10 ++++++++--
src/routes/users/reactivate-user.js | 1 +
7 files changed, 24 insertions(+), 16 deletions(-)
diff --git a/src/helpers/inactivity-checker.js b/src/helpers/inactivity-checker.js
index 9fbad4d..4ba96e5 100644
--- a/src/helpers/inactivity-checker.js
+++ b/src/helpers/inactivity-checker.js
@@ -37,8 +37,9 @@ async function sendInactivityWarnings() {
for (const user of inactiveUsers) {
try {
const loginUrl = `${APP_URL}/sign-in`;
+ const displayName = user.firstName || "User";
const emailContent = inactivityWarningEmailTemplate(
- user.firstName || "User",
+ displayName,
loginUrl
);
@@ -46,7 +47,7 @@ async function sendInactivityWarnings() {
receiversEmails: [user.email],
subject: "We miss you! Your AXS Map account needs attention",
htmlContent: emailContent,
- textContent: `Hi ${user.firstName}, we noticed you haven't logged into AXS Map in over a year. Please log in within 7 days to keep your account active.`,
+ textContent: `Hi ${displayName}, we noticed you haven't logged into AXS Map in over a year. Please log in within 7 days to keep your account active.`,
});
// Mark that we've sent the inactivity email
@@ -105,8 +106,9 @@ async function archiveInactiveUsers() {
// Send archived notification email
const reactivateUrl = `${APP_URL}/reactivate-account`;
+ const displayName = user.firstName || "User";
const emailContent = accountArchivedEmailTemplate(
- user.firstName || "User",
+ displayName,
reactivateUrl
);
@@ -114,7 +116,7 @@ async function archiveInactiveUsers() {
receiversEmails: [user.email],
subject: "Your AXS Map account has been archived",
htmlContent: emailContent,
- textContent: `Hi ${user.firstName}, your AXS Map account has been archived due to inactivity. You can reactivate it anytime by visiting ${reactivateUrl}`,
+ textContent: `Hi ${displayName}, your AXS Map account has been archived due to inactivity. You can reactivate it anytime by visiting ${reactivateUrl}`,
});
archivedUsers.push({
@@ -138,6 +140,7 @@ async function archiveInactiveUsers() {
/**
* Get count of users reactivated in the past week
+ * Uses reactivatedAt timestamp set during account reactivation
*/
async function getReactivatedUsersCount() {
const oneWeekAgo = moment.utc().subtract(7, "days").toDate();
@@ -145,9 +148,7 @@ async function getReactivatedUsersCount() {
try {
const count = await User.countDocuments({
isArchived: false,
- inactivityEmailSent: false,
- lastLogin: { $gte: oneWeekAgo },
- updatedAt: { $gte: oneWeekAgo },
+ reactivatedAt: { $gte: oneWeekAgo },
});
return count;
} catch (err) {
diff --git a/src/models/user.js b/src/models/user.js
index 6dd097a..c569f7f 100644
--- a/src/models/user.js
+++ b/src/models/user.js
@@ -99,6 +99,10 @@ const userSchema = new mongoose.Schema(
type: Date,
default: null,
},
+ reactivatedAt: {
+ type: Date,
+ default: null,
+ },
isBlocked: {
type: Boolean,
default: false,
diff --git a/src/routes/auth/facebook-sign-in.js b/src/routes/auth/facebook-sign-in.js
index dd75376..d34af1d 100644
--- a/src/routes/auth/facebook-sign-in.js
+++ b/src/routes/auth/facebook-sign-in.js
@@ -68,8 +68,7 @@ module.exports = async (req, res, next) => {
return res.status(403).json({
general: "Account is archived due to inactivity",
isArchived: true,
- requiresReactivation: true,
- userId: user._id.toString()
+ requiresReactivation: true
});
}
diff --git a/src/routes/auth/google-sign-in.js b/src/routes/auth/google-sign-in.js
index ce2c323..5123fef 100644
--- a/src/routes/auth/google-sign-in.js
+++ b/src/routes/auth/google-sign-in.js
@@ -69,8 +69,7 @@ module.exports = async (req, res) => {
return res.status(403).json({
general: "Account is archived due to inactivity",
isArchived: true,
- requiresReactivation: true,
- userId: user._id.toString()
+ requiresReactivation: true
});
}
diff --git a/src/routes/auth/sign-in.js b/src/routes/auth/sign-in.js
index 1f69e7c..4a3defe 100644
--- a/src/routes/auth/sign-in.js
+++ b/src/routes/auth/sign-in.js
@@ -22,7 +22,6 @@ module.exports = async (req, res, next) => {
try {
// First check if user exists (including archived users)
user = await User.findOne({ email });
- console.log("User", user);
} catch (err) {
console.log(`User with email ${email} failed to be found at sign-in.`);
return next(err);
@@ -37,8 +36,7 @@ module.exports = async (req, res, next) => {
return res.status(403).json({
general: "Account is archived due to inactivity",
isArchived: true,
- requiresReactivation: true,
- userId: user._id.toString()
+ requiresReactivation: true
});
}
diff --git a/src/routes/others/inactivity-cron.js b/src/routes/others/inactivity-cron.js
index f61b2d3..eab6570 100644
--- a/src/routes/others/inactivity-cron.js
+++ b/src/routes/others/inactivity-cron.js
@@ -8,8 +8,11 @@ const { runInactivityCheck, runWeeklyReport } = require("../../helpers/inactivit
module.exports = {
runDailyCheck: async (req, res) => {
// Verify the cron secret to prevent unauthorized access
+ // Fail closed: reject if CRON_SECRET is not configured or header is missing
const cronSecret = req.headers["x-cron-secret"];
- if (cronSecret !== process.env.CRON_SECRET) {
+ const expectedSecret = process.env.CRON_SECRET;
+
+ if (!expectedSecret || !cronSecret || cronSecret !== expectedSecret) {
return res.status(401).json({ error: "Unauthorized" });
}
@@ -29,8 +32,11 @@ module.exports = {
runWeeklyReportEndpoint: async (req, res) => {
// Verify the cron secret to prevent unauthorized access
+ // Fail closed: reject if CRON_SECRET is not configured or header is missing
const cronSecret = req.headers["x-cron-secret"];
- if (cronSecret !== process.env.CRON_SECRET) {
+ const expectedSecret = process.env.CRON_SECRET;
+
+ if (!expectedSecret || !cronSecret || cronSecret !== expectedSecret) {
return res.status(401).json({ error: "Unauthorized" });
}
diff --git a/src/routes/users/reactivate-user.js b/src/routes/users/reactivate-user.js
index 102b178..6d57f68 100644
--- a/src/routes/users/reactivate-user.js
+++ b/src/routes/users/reactivate-user.js
@@ -40,6 +40,7 @@ module.exports = async (req, res, next) => {
user.lastLogin = new Date();
user.inactivityEmailSent = false;
user.inactivityEmailSentAt = null;
+ user.reactivatedAt = new Date(); // Track when user reactivated for reporting
user.updatedAt = moment.utc().toDate();
// Update optional fields if provided
From ec08f9ab4920aa0a270f6a827b867655795c6a4f Mon Sep 17 00:00:00 2001
From: Muhammad Saffi Ullah <42832684+saffiullah200@users.noreply.github.com>
Date: Sun, 25 Jan 2026 12:22:00 +0100
Subject: [PATCH 4/6] fix(security): secure reactivation with userId + password
verification
BREAKING CHANGE: Reactivation endpoint now requires userId instead of email
Security improvements:
- Reactivation now requires userId (from 403 login response) + currentPassword
- This prevents account takeover - attacker must know original password
- Sign-in endpoints return userId in 403 response for archived users
- Generic "Invalid credentials" errors prevent enumeration
Removed weekly reports:
- Removed sendWeeklyReport() and runWeeklyReportJob() functions
- Removed /cron/weekly-report endpoint
- Only daily inactivity check remains
Updated validation:
- validateReactivateUser now validates userId (24-char ObjectId)
- Requires currentPassword + newPassword fields
Frontend guide updated with new API contract.
---
FRONTEND_INACTIVITY_GUIDE.md | 392 +++++++++++++++++++++++++++
src/helpers/inactivity-checker.js | 80 ------
src/helpers/mail-template.js | 85 ------
src/routes/auth/facebook-sign-in.js | 6 +-
src/routes/auth/google-sign-in.js | 6 +-
src/routes/auth/sign-in.js | 8 +-
src/routes/others/inactivity-cron.js | 24 +-
src/routes/others/index.js | 5 +-
src/routes/users/reactivate-user.js | 48 ++--
src/routes/users/validations.js | 34 ++-
10 files changed, 461 insertions(+), 227 deletions(-)
create mode 100644 FRONTEND_INACTIVITY_GUIDE.md
diff --git a/FRONTEND_INACTIVITY_GUIDE.md b/FRONTEND_INACTIVITY_GUIDE.md
new file mode 100644
index 0000000..3813196
--- /dev/null
+++ b/FRONTEND_INACTIVITY_GUIDE.md
@@ -0,0 +1,392 @@
+# Frontend Integration Guide: Inactivity Tracking System
+
+## Overview
+This system archives inactive user accounts after 1 year and allows users to reactivate them. The flow is designed to be secure and prevent account takeover.
+
+## Security Model
+The reactivation flow requires **two-factor verification**:
+1. **Something they know (email)**: User must attempt login with their email first
+2. **Something they prove (password)**: User must provide their current password to complete reactivation
+
+This prevents attackers from reactivating accounts without knowing the password.
+
+---
+
+## API Changes
+
+### 1. Sign-In Responses for Archived Users
+
+When a user attempts to sign in and their account is archived, the API returns:
+
+**Regular Sign-In (POST /auth/sign-in)**
+```json
+{
+ "status": 403,
+ "body": {
+ "general": "Account is archived due to inactivity",
+ "isArchived": true,
+ "requiresReactivation": true,
+ "userId": "507f1f77bcf86cd799439011"
+ }
+}
+```
+
+**Google Sign-In (POST /auth/google-sign-in)**
+```json
+{
+ "status": 403,
+ "body": {
+ "general": "Account is archived due to inactivity",
+ "isArchived": true,
+ "requiresReactivation": true,
+ "userId": "507f1f77bcf86cd799439011"
+ }
+}
+```
+
+**Facebook Sign-In (POST /auth/facebook-sign-in)**
+```json
+{
+ "status": 403,
+ "body": {
+ "general": "Account is archived due to inactivity",
+ "isArchived": true,
+ "requiresReactivation": true,
+ "userId": "507f1f77bcf86cd799439011"
+ }
+}
+```
+
+> **Note**: Social login users (Google/Facebook) cannot use the password reactivation endpoint since they don't have a password. They'll need to contact support.
+
+---
+
+### 2. Reactivate Account Endpoint
+
+**POST /users/reactivate**
+
+Allows archived users to reactivate their account by verifying their identity with their current password.
+
+**Request Body:**
+```json
+{
+ "userId": "507f1f77bcf86cd799439011",
+ "currentPassword": "their-old-password",
+ "newPassword": "new-secure-password-123",
+ "firstName": "John",
+ "lastName": "Doe"
+}
+```
+
+**Success Response (200):**
+```json
+{
+ "general": "Account reactivated successfully",
+ "token": "eyJhbGciOiJIUzI1NiIs...",
+ "refreshToken": "507f1f77bcf86cd799439011abc123..."
+}
+```
+
+**Error Responses:**
+
+| Status | Scenario | Response |
+|--------|----------|----------|
+| 400 | Validation error | `{ "userId": "Is required", "currentPassword": "Is required", ... }` |
+| 400 | Invalid userId or password | `{ "general": "Invalid credentials" }` |
+| 400 | Social login user | `{ "general": "This account was created with social login..." }` |
+
+---
+
+## Frontend Implementation
+
+### Step 1: Update Sign-In Handler
+
+```javascript
+const handleSignIn = async (email, password, rememberMe) => {
+ try {
+ const response = await api.post('/auth/sign-in', { email, password, rememberMe });
+ // Handle successful login
+ storeTokens(response.data);
+ redirectToDashboard();
+ } catch (error) {
+ if (error.response?.status === 403 && error.response?.data?.isArchived) {
+ // Store userId for reactivation flow
+ const { userId } = error.response.data;
+ redirectToReactivation({ userId, email });
+ } else {
+ showError(error.response?.data?.general || 'Login failed');
+ }
+ }
+};
+```
+
+### Step 2: Create Reactivation Page
+
+```jsx
+// ReactivateAccountPage.jsx
+import { useState } from 'react';
+
+const ReactivateAccountPage = () => {
+ const { userId, email } = useLocation().state || {};
+ const [formData, setFormData] = useState({
+ currentPassword: '',
+ newPassword: '',
+ confirmPassword: '',
+ firstName: '',
+ lastName: ''
+ });
+ const [errors, setErrors] = useState({});
+ const [loading, setLoading] = useState(false);
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+
+ if (formData.newPassword !== formData.confirmPassword) {
+ setErrors({ confirmPassword: 'Passwords do not match' });
+ return;
+ }
+
+ setLoading(true);
+ try {
+ const response = await api.post('/users/reactivate', {
+ userId,
+ currentPassword: formData.currentPassword,
+ newPassword: formData.newPassword,
+ firstName: formData.firstName,
+ lastName: formData.lastName
+ });
+
+ // Store tokens and redirect
+ storeTokens(response.data);
+ showSuccess('Account reactivated successfully!');
+ redirectToDashboard();
+ } catch (error) {
+ setErrors(error.response?.data || { general: 'Reactivation failed' });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ if (!userId) {
+ return ;
+ }
+
+ return (
+
+
Reactivate Your Account
+
Your account was archived due to inactivity. Enter your current password to reactivate it.
+
+ {errors.general &&
{errors.general}}
+
+
+
+
+ If you originally signed up with Google or Facebook and cannot reactivate,
+ please contact support.
+
+
+ );
+};
+```
+
+### Step 3: Handle Social Login Archived Users
+
+For Google/Facebook users, show a different message since they can't use password reactivation:
+
+```jsx
+const handleGoogleSignIn = async (credential, rememberMe) => {
+ try {
+ const response = await api.post('/auth/google-sign-in', { credential, rememberMe });
+ storeTokens(response.data);
+ redirectToDashboard();
+ } catch (error) {
+ if (error.response?.status === 403 && error.response?.data?.isArchived) {
+ // Social login users can't use password reactivation
+ showModal({
+ title: 'Account Archived',
+ message: 'Your account has been archived due to inactivity. Since you signed up with Google, please contact support to reactivate your account.',
+ actions: [
+ { label: 'Contact Support', onClick: () => window.location.href = '/contact' }
+ ]
+ });
+ } else {
+ showError(error.response?.data?.general || 'Login failed');
+ }
+ }
+};
+```
+
+---
+
+## User Flow Diagram
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ User Login Flow │
+└─────────────────────────────────────────────────────────────────┘
+
+User attempts login (email/password or social)
+ │
+ ▼
+ ┌──────────────────┐
+ │ Account Active? │
+ └────────┬─────────┘
+ │
+ ┌──────────┴──────────┐
+ │ │
+ ▼ ▼
+ [YES] [NO - Archived]
+ │ │
+ ▼ ▼
+ Login Success Return 403 with:
+ │ - isArchived: true
+ ▼ - userId: "..."
+ Return tokens │
+ │
+ ┌──────────────┴──────────────┐
+ │ │
+ ▼ ▼
+ [Password User] [Social User]
+ │ │
+ ▼ ▼
+ Show Reactivation Show "Contact Support"
+ Form Message
+ │
+ ▼
+ User enters:
+ - userId (from 403)
+ - currentPassword
+ - newPassword
+ - firstName
+ - lastName
+ │
+ ▼
+ POST /users/reactivate
+ │
+ ┌──────┴──────┐
+ │ │
+ ▼ ▼
+ [Valid] [Invalid]
+ │ │
+ ▼ ▼
+ Account Show error
+ Reactivated "Invalid credentials"
+ │
+ ▼
+ Return tokens
+ (auto logged in)
+```
+
+---
+
+## Email Templates
+
+Users will receive these emails from the inactivity system:
+
+### 1. Inactivity Warning Email (sent at 11 months of inactivity)
+Subject: "Your AXS Map account will be archived soon"
+
+Content explains:
+- Account will be archived in 30 days if they don't log in
+- Link to sign in
+
+### 2. Account Archived Email (sent when archived at 12 months)
+Subject: "Your AXS Map account has been archived"
+
+Content explains:
+- Account is now archived
+- They can reactivate by signing in and using their current password
+- No data has been deleted
+
+---
+
+## Validation Rules
+
+### Reactivation Request Validation
+
+| Field | Rules |
+|-------|-------|
+| `userId` | Required, string, exactly 24 characters (MongoDB ObjectId) |
+| `currentPassword` | Required, string |
+| `newPassword` | Required, string, 8-30 characters |
+| `firstName` | Required, string, letters only, max 24 characters |
+| `lastName` | Required, string, letters only, max 36 characters |
+
+---
+
+## Testing
+
+### Test Scenarios
+
+1. **Archived user login (password)**
+ - User attempts sign-in with archived account
+ - Should receive 403 with `isArchived: true` and `userId`
+
+2. **Successful reactivation**
+ - User provides correct userId + currentPassword
+ - Account reactivated, tokens returned
+
+3. **Wrong current password**
+ - User provides wrong currentPassword
+ - Should receive 400 "Invalid credentials"
+
+4. **Social login archived user**
+ - User attempts Google/Facebook sign-in
+ - Should receive 403 with contact support message
+ - Cannot use password reactivation endpoint
+
+5. **Invalid userId**
+ - User provides non-existent userId
+ - Should receive 400 "Invalid credentials"
+
+---
+
+## Questions?
+
+Contact the backend team for any integration questions.
diff --git a/src/helpers/inactivity-checker.js b/src/helpers/inactivity-checker.js
index 4ba96e5..c4ed008 100644
--- a/src/helpers/inactivity-checker.js
+++ b/src/helpers/inactivity-checker.js
@@ -5,12 +5,10 @@ const { sendEmail } = require("../helpers");
const {
inactivityWarningEmailTemplate,
accountArchivedEmailTemplate,
- weeklyInactivityReportEmailTemplate,
} = require("../helpers/mail-template");
const INACTIVITY_THRESHOLD_DAYS = 365; // 1 year
const ARCHIVE_GRACE_PERIOD_DAYS = 7; // 7 days after warning email
-const ADMIN_EMAIL = process.env.ADMIN_EMAIL || "admin@axsmap.com";
const APP_URL = process.env.APP_URL || "https://www.axsmap.com";
/**
@@ -138,60 +136,6 @@ async function archiveInactiveUsers() {
}
}
-/**
- * Get count of users reactivated in the past week
- * Uses reactivatedAt timestamp set during account reactivation
- */
-async function getReactivatedUsersCount() {
- const oneWeekAgo = moment.utc().subtract(7, "days").toDate();
-
- try {
- const count = await User.countDocuments({
- isArchived: false,
- reactivatedAt: { $gte: oneWeekAgo },
- });
- return count;
- } catch (err) {
- console.error("[Inactivity Check] Error counting reactivated users:", err.message);
- return 0;
- }
-}
-
-/**
- * Send weekly report to admin team
- */
-async function sendWeeklyReport(warningsSent, archivedUsers) {
- const weekEndDate = moment.utc().format("MMMM D, YYYY");
- const weekStartDate = moment.utc().subtract(7, "days").format("MMMM D, YYYY");
-
- const totalReactivated = await getReactivatedUsersCount();
-
- const reportData = {
- weekStartDate,
- weekEndDate,
- totalWarningsSent: warningsSent.length,
- totalArchived: archivedUsers.length,
- totalReactivated,
- archivedUsers,
- warningsSentUsers: warningsSent,
- };
-
- const emailContent = weeklyInactivityReportEmailTemplate(reportData);
-
- try {
- await sendEmail({
- receiversEmails: [ADMIN_EMAIL],
- subject: `AXS Map Weekly Inactivity Report - ${weekEndDate}`,
- htmlContent: emailContent,
- textContent: `Weekly Inactivity Report: ${warningsSent.length} warnings sent, ${archivedUsers.length} users archived, ${totalReactivated} users reactivated.`,
- });
-
- console.log(`[Inactivity Check] Weekly report sent to ${ADMIN_EMAIL}`);
- } catch (err) {
- console.error("[Inactivity Check] Failed to send weekly report:", err.message);
- }
-}
-
/**
* Main function to run the inactivity check process
* This should be called by a cron job daily
@@ -213,32 +157,8 @@ async function runInactivityCheck() {
};
}
-/**
- * Run the weekly report (should be called once per week)
- */
-async function runWeeklyReport() {
- console.log("[Inactivity Check] Generating weekly report...");
-
- // Get data from the past week
- const oneWeekAgo = moment.utc().subtract(7, "days").toDate();
-
- // Users who received warnings this week
- const warningsSentUsers = await User.find({
- inactivityEmailSentAt: { $gte: oneWeekAgo },
- }).select("email firstName lastName");
-
- // Users archived this week
- const archivedUsers = await User.find({
- isArchived: true,
- updatedAt: { $gte: oneWeekAgo },
- }).select("email firstName lastName");
-
- await sendWeeklyReport(warningsSentUsers, archivedUsers);
-}
-
module.exports = {
runInactivityCheck,
- runWeeklyReport,
sendInactivityWarnings,
archiveInactiveUsers,
};
diff --git a/src/helpers/mail-template.js b/src/helpers/mail-template.js
index b33b2c0..01a6860 100644
--- a/src/helpers/mail-template.js
+++ b/src/helpers/mail-template.js
@@ -368,90 +368,6 @@ const accountArchivedEmailTemplate = (name, reactivateUrl) => {
`;
};
-const weeklyInactivityReportEmailTemplate = (reportData) => {
- const {
- weekStartDate,
- weekEndDate,
- totalWarningsSent,
- totalArchived,
- totalReactivated,
- archivedUsers,
- warningsSentUsers
- } = reportData;
-
- const archivedUsersList = archivedUsers && archivedUsers.length > 0
- ? archivedUsers.map(u => `${u.email} (${u.firstName} ${u.lastName})`).join('')
- : 'No users archived this week';
-
- const warningsSentList = warningsSentUsers && warningsSentUsers.length > 0
- ? warningsSentUsers.map(u => `${u.email} (${u.firstName} ${u.lastName})`).join('')
- : 'No warnings sent this week';
-
- return `
-
-
-
-
-
-
-
-
-
-
-
- Weekly Inactivity Report 📊
- ${weekStartDate} - ${weekEndDate}
- |
-
-
-
-
- Summary
-
-
-
- | Inactivity Warnings Sent |
- ${totalWarningsSent} |
-
-
- | Accounts Archived |
- ${totalArchived} |
-
-
- | Accounts Reactivated |
- ${totalReactivated} |
-
-
-
- Archived Users
-
-
- Warnings Sent To
-
- |
-
-
-
-
- |
- This is an automated report from AXS Map
- © 2025 AXS MAP. Admin Report
- |
-
-
-
- |
-
-
-
-
-`;
-};
-
module.exports = {
activationEmailTemplate,
submitServeyUserMailTemplate,
@@ -459,5 +375,4 @@ module.exports = {
donationMailTemplate,
inactivityWarningEmailTemplate,
accountArchivedEmailTemplate,
- weeklyInactivityReportEmailTemplate,
};
diff --git a/src/routes/auth/facebook-sign-in.js b/src/routes/auth/facebook-sign-in.js
index d34af1d..1ce95b3 100644
--- a/src/routes/auth/facebook-sign-in.js
+++ b/src/routes/auth/facebook-sign-in.js
@@ -63,12 +63,14 @@ module.exports = async (req, res, next) => {
await user.save();
} else {
- // Check if user is archived
+ // Check if user is archived - return userId for reactivation flow
+ // For social login users, they'll need to contact support since they don't have a password
if (user.isArchived) {
return res.status(403).json({
general: "Account is archived due to inactivity",
isArchived: true,
- requiresReactivation: true
+ requiresReactivation: true,
+ userId: user.id
});
}
diff --git a/src/routes/auth/google-sign-in.js b/src/routes/auth/google-sign-in.js
index 5123fef..e5a6b44 100644
--- a/src/routes/auth/google-sign-in.js
+++ b/src/routes/auth/google-sign-in.js
@@ -64,12 +64,14 @@ module.exports = async (req, res) => {
});
await user.save();
} else {
- // Check if user is archived
+ // Check if user is archived - return userId for reactivation flow
+ // For social login users, they'll need to contact support since they don't have a password
if (user.isArchived) {
return res.status(403).json({
general: "Account is archived due to inactivity",
isArchived: true,
- requiresReactivation: true
+ requiresReactivation: true,
+ userId: user.id
});
}
diff --git a/src/routes/auth/sign-in.js b/src/routes/auth/sign-in.js
index 4a3defe..aef992d 100644
--- a/src/routes/auth/sign-in.js
+++ b/src/routes/auth/sign-in.js
@@ -31,12 +31,16 @@ module.exports = async (req, res, next) => {
return res.status(400).json({ general: "Email or password incorrect" });
}
- // Check if user is archived - redirect to reactivation
+ // Check if user is archived - return userId for reactivation flow
+ // userId is safe to return here because:
+ // 1. User has already proven they know the email (a valid account email)
+ // 2. Reactivation still requires currentPassword verification
if (user.isArchived) {
return res.status(403).json({
general: "Account is archived due to inactivity",
isArchived: true,
- requiresReactivation: true
+ requiresReactivation: true,
+ userId: user.id
});
}
diff --git a/src/routes/others/inactivity-cron.js b/src/routes/others/inactivity-cron.js
index eab6570..76f94a3 100644
--- a/src/routes/others/inactivity-cron.js
+++ b/src/routes/others/inactivity-cron.js
@@ -1,4 +1,4 @@
-const { runInactivityCheck, runWeeklyReport } = require("../../helpers/inactivity-checker");
+const { runInactivityCheck } = require("../../helpers/inactivity-checker");
/**
* Endpoint to trigger inactivity check
@@ -29,26 +29,4 @@ module.exports = {
return res.status(500).json({ error: "Failed to run inactivity check" });
}
},
-
- runWeeklyReportEndpoint: async (req, res) => {
- // Verify the cron secret to prevent unauthorized access
- // Fail closed: reject if CRON_SECRET is not configured or header is missing
- const cronSecret = req.headers["x-cron-secret"];
- const expectedSecret = process.env.CRON_SECRET;
-
- if (!expectedSecret || !cronSecret || cronSecret !== expectedSecret) {
- return res.status(401).json({ error: "Unauthorized" });
- }
-
- try {
- await runWeeklyReport();
- return res.status(200).json({
- success: true,
- message: "Weekly report sent",
- });
- } catch (err) {
- console.error("[Cron] Failed to send weekly report:", err.message);
- return res.status(500).json({ error: "Failed to send weekly report" });
- }
- },
};
diff --git a/src/routes/others/index.js b/src/routes/others/index.js
index e4d77fe..95baa53 100644
--- a/src/routes/others/index.js
+++ b/src/routes/others/index.js
@@ -3,7 +3,7 @@ const express = require("express");
const contact = require("./contact");
const migrateScores = require("./migrate-scores");
const survey = require("./survey");
-const { runDailyCheck, runWeeklyReportEndpoint } = require("./inactivity-cron");
+const { runDailyCheck } = require("./inactivity-cron");
const { isAuthenticated } = require("../../helpers");
const router = new express.Router();
@@ -12,8 +12,7 @@ router.post("/contact", contact);
router.post("/survey", isAuthenticated({ isOptional: false }), survey);
router.get("/migrate-scores", migrateScores);
-// Cron job endpoints for inactivity tracking
+// Cron job endpoint for inactivity tracking
router.post("/cron/inactivity-check", runDailyCheck);
-router.post("/cron/weekly-report", runWeeklyReportEndpoint);
module.exports = router;
diff --git a/src/routes/users/reactivate-user.js b/src/routes/users/reactivate-user.js
index 6d57f68..5d60caf 100644
--- a/src/routes/users/reactivate-user.js
+++ b/src/routes/users/reactivate-user.js
@@ -9,7 +9,10 @@ const { validateReactivateUser } = require("./validations");
/**
* Reactivate an archived user account
- * User must provide email, new password, and required profile fields
+ * User must provide userId (from archived login response), current password, and new password
+ * This prevents account takeover - user must:
+ * 1. Attempt login first (to get userId from 403 response)
+ * 2. Know their original password
*/
module.exports = async (req, res, next) => {
const { errors, isValid } = validateReactivateUser(req.body);
@@ -17,30 +20,44 @@ module.exports = async (req, res, next) => {
return res.status(400).json(errors);
}
- const { email, password, firstName, lastName } = req.body;
+ const { userId, currentPassword, newPassword, firstName, lastName } = req.body;
let user;
try {
- // Find the archived user by email
- user = await User.findOne({ email, isArchived: true });
+ // Find the archived user by userId
+ user = await User.findOne({ _id: userId, isArchived: true });
} catch (err) {
- console.log(`User with email ${email} failed to be found at reactivate-user.`);
+ console.log(`Reactivation failed for userId ${userId}`);
return next(err);
}
+ // Return generic error to prevent enumeration
if (!user) {
- return res.status(404).json({ general: "Archived account not found with this email" });
+ return res.status(400).json({ general: "Invalid credentials" });
+ }
+
+ // Verify current password to prove account ownership
+ if (!user.hashedPassword) {
+ // User signed up via social login, can't use password reactivation
+ return res.status(400).json({
+ general: "This account was created with social login. Please use Google or Facebook to sign in, then contact support if your account is archived."
+ });
+ }
+
+ const passwordMatches = user.comparePassword(currentPassword);
+ if (!passwordMatches) {
+ return res.status(400).json({ general: "Invalid credentials" });
}
// Update user fields for reactivation
user.isArchived = false;
- user.password = password; // Will be hashed by the virtual setter
+ user.password = newPassword; // Will be hashed by the virtual setter
user.firstName = firstName;
user.lastName = lastName;
user.lastLogin = new Date();
user.inactivityEmailSent = false;
user.inactivityEmailSentAt = null;
- user.reactivatedAt = new Date(); // Track when user reactivated for reporting
+ user.reactivatedAt = new Date();
user.updatedAt = moment.utc().toDate();
// Update optional fields if provided
@@ -52,29 +69,28 @@ module.exports = async (req, res, next) => {
try {
await user.save();
} catch (err) {
- console.log(`User with email ${email} failed to be reactivated.`);
+ console.log(`User with userId ${userId} failed to be reactivated.`);
return next(err);
}
- // Generate tokens for the reactivated user
- const userId = user.id;
+ // Generate tokens for the reactivated user - use user.id instead of redeclaring
const today = moment.utc();
const expiresAt = today.add(7, "days").toDate();
- const key = `${userId}${crypto.randomBytes(28).toString("hex")}`;
+ const key = `${user.id}${crypto.randomBytes(28).toString("hex")}`;
let refreshToken;
try {
refreshToken = await RefreshToken.findOneAndUpdate(
- { userId },
- { expiresAt, key, userId, rememberMe: false },
+ { userId: user.id },
+ { expiresAt, key, userId: user.id, rememberMe: false },
{ new: true, setDefaultsOnInsert: true, upsert: true }
);
} catch (err) {
- console.log(`Refresh Token for userId ${userId} failed to be created at reactivate-user.`);
+ console.log(`Refresh Token for userId ${user.id} failed to be created at reactivate-user.`);
return next(err);
}
- const token = jwt.sign({ userId }, process.env.JWT_SECRET, {
+ const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET, {
expiresIn: '7d',
});
diff --git a/src/routes/users/validations.js b/src/routes/users/validations.js
index cccf82c..0138f27 100644
--- a/src/routes/users/validations.js
+++ b/src/routes/users/validations.js
@@ -299,22 +299,28 @@ module.exports = {
validateReactivateUser(data) {
const errors = {};
- if (!data.email) {
- errors.email = 'Is required';
- } else if (typeof data.email !== 'string') {
- errors.email = 'Should be a string';
- } else if (!isEmail(data.email)) {
- errors.email = 'Should be a valid email';
+ if (!data.userId) {
+ errors.userId = 'Is required';
+ } else if (typeof data.userId !== 'string') {
+ errors.userId = 'Should be a string';
+ } else if (data.userId.length !== 24) {
+ errors.userId = 'Should be a valid user ID';
}
- if (!data.password) {
- errors.password = 'Is required';
- } else if (typeof data.password !== 'string') {
- errors.password = 'Should be a string';
- } else if (data.password.length < 8) {
- errors.password = 'Should have more than 7 characters';
- } else if (data.password.length > 30) {
- errors.password = 'Should have less than 31 characters';
+ if (!data.currentPassword) {
+ errors.currentPassword = 'Is required';
+ } else if (typeof data.currentPassword !== 'string') {
+ errors.currentPassword = 'Should be a string';
+ }
+
+ if (!data.newPassword) {
+ errors.newPassword = 'Is required';
+ } else if (typeof data.newPassword !== 'string') {
+ errors.newPassword = 'Should be a string';
+ } else if (data.newPassword.length < 8) {
+ errors.newPassword = 'Should have more than 7 characters';
+ } else if (data.newPassword.length > 30) {
+ errors.newPassword = 'Should have less than 31 characters';
}
if (!data.firstName) {
From 3a606064a99200c0f00431fad944487a6a278187 Mon Sep 17 00:00:00 2001
From: Muhammad Saffi Ullah <42832684+saffiullah200@users.noreply.github.com>
Date: Sun, 25 Jan 2026 12:25:18 +0100
Subject: [PATCH 5/6] feat(auth): allow forgot password flow to reactivate
archived accounts
- forgotten-password.js: Include archived users in lookup
- reset-password.js: Reset inactivity flags when password is reset
- Sets lastLogin to reset inactivity timer
- Sets isArchived to false to reactivate account
- Clears inactivityEmailSent and inactivityEmailSentAt
- Sets reactivatedAt if user was archived
This provides an alternative recovery path for:
- Users who forgot their password
- Social login users who need to reactivate archived accounts
Updated frontend guide with new recovery flow documentation.
---
FRONTEND_INACTIVITY_GUIDE.md | 37 +++++++++++++++++++++------
src/routes/auth/forgotten-password.js | 3 ++-
src/routes/auth/reset-password.js | 16 +++++++++++-
src/routes/users/reactivate-user.js | 3 ++-
4 files changed, 48 insertions(+), 11 deletions(-)
diff --git a/FRONTEND_INACTIVITY_GUIDE.md b/FRONTEND_INACTIVITY_GUIDE.md
index 3813196..11f3984 100644
--- a/FRONTEND_INACTIVITY_GUIDE.md
+++ b/FRONTEND_INACTIVITY_GUIDE.md
@@ -10,6 +10,12 @@ The reactivation flow requires **two-factor verification**:
This prevents attackers from reactivating accounts without knowing the password.
+### Alternative Recovery: Forgot Password
+Users can also reactivate archived accounts using the **Forgot Password** flow:
+- Archived users can request a password reset email
+- Completing the reset will automatically reactivate their account
+- This is secure because it requires access to the user's email
+
---
## API Changes
@@ -57,7 +63,7 @@ When a user attempts to sign in and their account is archived, the API returns:
}
```
-> **Note**: Social login users (Google/Facebook) cannot use the password reactivation endpoint since they don't have a password. They'll need to contact support.
+> **Note**: Social login users (Google/Facebook) should use the **Forgot Password** flow to reactivate, since they may not have a password set.
---
@@ -237,7 +243,7 @@ const ReactivateAccountPage = () => {
### Step 3: Handle Social Login Archived Users
-For Google/Facebook users, show a different message since they can't use password reactivation:
+For Google/Facebook users, they can use the **Forgot Password** flow to reactivate:
```jsx
const handleGoogleSignIn = async (credential, rememberMe) => {
@@ -247,12 +253,13 @@ const handleGoogleSignIn = async (credential, rememberMe) => {
redirectToDashboard();
} catch (error) {
if (error.response?.status === 403 && error.response?.data?.isArchived) {
- // Social login users can't use password reactivation
+ // Social login users should use forgot password to reactivate
showModal({
title: 'Account Archived',
- message: 'Your account has been archived due to inactivity. Since you signed up with Google, please contact support to reactivate your account.',
+ message: 'Your account has been archived due to inactivity. You can reactivate it by using the "Forgot Password" feature to set a new password.',
actions: [
- { label: 'Contact Support', onClick: () => window.location.href = '/contact' }
+ { label: 'Reset Password', onClick: () => window.location.href = '/forgotten-password' },
+ { label: 'Cancel', onClick: () => {} }
]
});
} else {
@@ -368,7 +375,7 @@ Content explains:
- User attempts sign-in with archived account
- Should receive 403 with `isArchived: true` and `userId`
-2. **Successful reactivation**
+2. **Successful reactivation via endpoint**
- User provides correct userId + currentPassword
- Account reactivated, tokens returned
@@ -378,13 +385,27 @@ Content explains:
4. **Social login archived user**
- User attempts Google/Facebook sign-in
- - Should receive 403 with contact support message
- - Cannot use password reactivation endpoint
+ - Should receive 403 with archived message
+ - Redirect to forgot password flow
5. **Invalid userId**
- User provides non-existent userId
- Should receive 400 "Invalid credentials"
+6. **Forgot password reactivation (NEW)**
+ - Archived user requests password reset via `/auth/forgotten-password`
+ - Email is sent successfully (archived users are NOT excluded)
+ - User resets password via `/auth/reset-password`
+ - Account is automatically reactivated
+ - `lastLogin` is updated
+ - `isArchived` is set to `false`
+ - `inactivityEmailSent` is reset to `false`
+
+7. **Active user forgot password**
+ - Active user resets password
+ - `lastLogin` is updated (resets inactivity timer)
+ - Inactivity flags are cleared
+
---
## Questions?
diff --git a/src/routes/auth/forgotten-password.js b/src/routes/auth/forgotten-password.js
index 7e4c61b..ca40550 100644
--- a/src/routes/auth/forgotten-password.js
+++ b/src/routes/auth/forgotten-password.js
@@ -18,7 +18,8 @@ module.exports = async (req, res, next) => {
let user;
try {
- user = await User.findOne({ email, isArchived: false });
+ // Include archived users - they can recover via forgot password
+ user = await User.findOne({ email });
} catch (err) {
console.log(
`User with email ${email} failed to be found at forgotten-password.`
diff --git a/src/routes/auth/reset-password.js b/src/routes/auth/reset-password.js
index c1250b4..c8956c2 100644
--- a/src/routes/auth/reset-password.js
+++ b/src/routes/auth/reset-password.js
@@ -46,9 +46,9 @@ module.exports = async (req, res, next) => {
}
let user;
try {
+ // Find user by email - include archived users so they can recover via forgot password
user = await User.findOne({
email: passwordTicket.email,
- isArchived: false,
});
} catch (err) {
console.log(
@@ -80,8 +80,22 @@ module.exports = async (req, res, next) => {
}
}
+ // Track if user was archived before we reset flags
+ const wasArchived = user.isArchived;
+
+ // Update password and reset inactivity/archived flags
+ // User has proven ownership via email verification, so reactivate if archived
user.password = password;
user.updatedAt = moment.utc().toDate();
+ user.lastLogin = new Date();
+ user.isArchived = false;
+ user.inactivityEmailSent = false;
+ user.inactivityEmailSentAt = null;
+
+ // If user was archived, set reactivatedAt
+ if (wasArchived) {
+ user.reactivatedAt = new Date();
+ }
try {
await user.save();
diff --git a/src/routes/users/reactivate-user.js b/src/routes/users/reactivate-user.js
index 5d60caf..9d648c8 100644
--- a/src/routes/users/reactivate-user.js
+++ b/src/routes/users/reactivate-user.js
@@ -39,8 +39,9 @@ module.exports = async (req, res, next) => {
// Verify current password to prove account ownership
if (!user.hashedPassword) {
// User signed up via social login, can't use password reactivation
+ // They should use the forgot password flow instead
return res.status(400).json({
- general: "This account was created with social login. Please use Google or Facebook to sign in, then contact support if your account is archived."
+ general: "This account was created with social login. Please use the 'Forgot Password' feature to set a password and reactivate your account."
});
}
From 3a9fa7432bf2942b9a736962fb2e712a77da3744 Mon Sep 17 00:00:00 2001
From: Muhammad Saffi Ullah <42832684+saffiullah200@users.noreply.github.com>
Date: Sun, 25 Jan 2026 12:34:49 +0100
Subject: [PATCH 6/6] refactor: consolidate reactivation to single
/auth/reactivate-account endpoint
BREAKING CHANGE: /auth/reactivate-account now requires currentPassword
Removed duplicate endpoint:
- Deleted /users/reactivate endpoint and reactivate-user.js
- Removed validateReactivateUser from users/validations.js
Updated /auth/reactivate-account with security improvements:
- Now requires currentPassword to prove account ownership
- Uses newPassword instead of password for clarity
- Generic "Invalid credentials" error prevents enumeration
- Social login users directed to forgot password flow
- Uses user.save() with model's virtual password setter
Updated frontend guide with correct endpoint path.
---
FRONTEND_INACTIVITY_GUIDE.md | 24 ++++-
src/routes/auth/reactivate-account.js | 130 ++++++++++++--------------
src/routes/users/index.js | 4 -
src/routes/users/reactivate-user.js | 109 ---------------------
src/routes/users/validations.js | 49 ----------
5 files changed, 82 insertions(+), 234 deletions(-)
delete mode 100644 src/routes/users/reactivate-user.js
diff --git a/FRONTEND_INACTIVITY_GUIDE.md b/FRONTEND_INACTIVITY_GUIDE.md
index 11f3984..9207559 100644
--- a/FRONTEND_INACTIVITY_GUIDE.md
+++ b/FRONTEND_INACTIVITY_GUIDE.md
@@ -69,7 +69,7 @@ When a user attempts to sign in and their account is archived, the API returns:
### 2. Reactivate Account Endpoint
-**POST /users/reactivate**
+**POST /auth/reactivate-account**
Allows archived users to reactivate their account by verifying their identity with their current password.
@@ -84,6 +84,20 @@ Allows archived users to reactivate their account by verifying their identity wi
}
```
+**Optional Fields** (can be updated during reactivation):
+- `email` - Update email address
+- `disabilities` - Disability information
+- `gender` - Gender
+- `zip` - Zip code
+- `phone` - Phone number
+- `showDisabilities` - Privacy setting
+- `showEmail` - Privacy setting
+- `showPhone` - Privacy setting
+- `aboutMe` - Bio/description
+- `birthday` - Date of birth
+- `race` - Race/ethnicity
+- `disability` - Disability type
+
**Success Response (200):**
```json
{
@@ -97,7 +111,9 @@ Allows archived users to reactivate their account by verifying their identity wi
| Status | Scenario | Response |
|--------|----------|----------|
-| 400 | Validation error | `{ "userId": "Is required", "currentPassword": "Is required", ... }` |
+| 400 | Missing userId | `{ "userId": "User ID is required" }` |
+| 400 | Missing currentPassword | `{ "currentPassword": "Current password is required" }` |
+| 400 | Invalid newPassword | `{ "newPassword": "New password must be at least 8 characters" }` |
| 400 | Invalid userId or password | `{ "general": "Invalid credentials" }` |
| 400 | Social login user | `{ "general": "This account was created with social login..." }` |
@@ -154,7 +170,7 @@ const ReactivateAccountPage = () => {
setLoading(true);
try {
- const response = await api.post('/users/reactivate', {
+ const response = await api.post('/auth/reactivate-account', {
userId,
currentPassword: formData.currentPassword,
newPassword: formData.newPassword,
@@ -314,7 +330,7 @@ User attempts login (email/password or social)
- lastName
│
▼
- POST /users/reactivate
+ POST /auth/reactivate-account
│
┌──────┴──────┐
│ │
diff --git a/src/routes/auth/reactivate-account.js b/src/routes/auth/reactivate-account.js
index 85dd55d..4504f34 100644
--- a/src/routes/auth/reactivate-account.js
+++ b/src/routes/auth/reactivate-account.js
@@ -1,4 +1,3 @@
-const bcrypt = require("bcrypt-nodejs");
const crypto = require("crypto");
const jwt = require("jsonwebtoken");
@@ -9,13 +8,14 @@ const { User } = require("../../models/user");
/**
* Reactivate an archived user account
- * Requires: userId, new password, and updated profile information
- * Sets isArchived to false and updates lastLogin
+ * Requires: userId (from 403 sign-in response), currentPassword (to prove ownership), newPassword
+ * Security: User must know their original password to reactivate
*/
module.exports = async (req, res, next) => {
const {
userId,
- password,
+ currentPassword,
+ newPassword,
firstName,
lastName,
email,
@@ -34,119 +34,113 @@ module.exports = async (req, res, next) => {
// Validation
if (!userId) {
- return res.status(400).json({ general: "User ID is required" });
+ return res.status(400).json({ userId: "User ID is required" });
}
- if (!password || password.length < 6) {
+ if (!currentPassword) {
+ return res.status(400).json({ currentPassword: "Current password is required" });
+ }
+
+ if (!newPassword || newPassword.length < 8) {
return res.status(400).json({
- password: "Password must be at least 6 characters"
+ newPassword: "New password must be at least 8 characters"
});
}
- if (!firstName || !lastName) {
+ if (newPassword.length > 30) {
return res.status(400).json({
- general: "First name and last name are required"
+ newPassword: "New password must be less than 31 characters"
});
}
- if (!email) {
- return res.status(400).json({ email: "Email is required" });
+ if (!firstName || !lastName) {
+ return res.status(400).json({
+ general: "First name and last name are required"
+ });
}
- // Find user
+ // Find user - use generic error to prevent enumeration
let user;
try {
- user = await User.findById(userId);
+ user = await User.findOne({ _id: userId, isArchived: true });
} catch (err) {
- console.log(`User with ID ${userId} failed to be found at reactivation.`);
+ console.log(`Reactivation lookup failed for userId ${userId}`);
return next(err);
}
if (!user) {
- return res.status(404).json({ general: "User not found" });
+ return res.status(400).json({ general: "Invalid credentials" });
}
- if (!user.isArchived) {
+ // Verify current password to prove account ownership
+ if (!user.hashedPassword) {
+ // User signed up via social login, redirect to forgot password
return res.status(400).json({
- general: "Account is not archived"
+ general: "This account was created with social login. Please use the 'Forgot Password' feature to set a password and reactivate your account."
});
}
- // Hash new password
- let hashedPassword;
- try {
- hashedPassword = await new Promise((resolve, reject) => {
- bcrypt.genSalt(10, (saltErr, salt) => {
- if (saltErr) return reject(saltErr);
-
- bcrypt.hash(password, salt, null, (hashErr, hash) => {
- if (hashErr) return reject(hashErr);
- resolve(hash);
- });
- });
- });
- } catch (err) {
- console.log("Failed to hash password during reactivation");
- return next(err);
+ const passwordMatches = user.comparePassword(currentPassword);
+ if (!passwordMatches) {
+ return res.status(400).json({ general: "Invalid credentials" });
}
- // Prepare update data
- const updateData = {
- hashedPassword,
- firstName,
- lastName,
- email,
- isArchived: false,
- lastLogin: new Date(),
- inactivityEmailSent: false,
- inactivityEmailSentAt: null,
- };
+ // Update user - set new password via the model's virtual setter
+ user.password = newPassword;
+ user.firstName = firstName;
+ user.lastName = lastName;
+ user.isArchived = false;
+ user.lastLogin = new Date();
+ user.inactivityEmailSent = false;
+ user.inactivityEmailSentAt = null;
+ user.reactivatedAt = new Date();
+ user.updatedAt = moment.utc().toDate();
// Add optional fields if provided
- if (disabilities !== undefined) updateData.disabilities = disabilities;
- if (gender !== undefined) updateData.gender = gender;
- if (zip !== undefined) updateData.zip = zip;
- if (phone !== undefined) updateData.phone = phone;
- if (showDisabilities !== undefined) updateData.showDisabilities = showDisabilities;
- if (showEmail !== undefined) updateData.showEmail = showEmail;
- if (showPhone !== undefined) updateData.showPhone = showPhone;
- if (aboutMe !== undefined) updateData.aboutMe = aboutMe;
- if (birthday !== undefined) updateData.birthday = birthday;
- if (race !== undefined) updateData.race = race;
- if (disability !== undefined) updateData.disability = disability;
-
- // Update user
+ if (email !== undefined) user.email = email;
+ if (disabilities !== undefined) user.disabilities = disabilities;
+ if (gender !== undefined) user.gender = gender;
+ if (zip !== undefined) user.zip = zip;
+ if (phone !== undefined) user.phone = phone;
+ if (showDisabilities !== undefined) user.showDisabilities = showDisabilities;
+ if (showEmail !== undefined) user.showEmail = showEmail;
+ if (showPhone !== undefined) user.showPhone = showPhone;
+ if (aboutMe !== undefined) user.aboutMe = aboutMe;
+ if (birthday !== undefined) user.birthday = birthday;
+ if (race !== undefined) user.race = race;
+ if (disability !== undefined) user.disability = disability;
+
try {
- user = await User.findByIdAndUpdate(userId, updateData, { new: true });
+ await user.save();
} catch (err) {
- console.log(`Failed to reactivate user ${userId}`);
+ console.log(`Failed to reactivate user ${user.id}`);
return next(err);
}
- // Generate tokens
+ // Generate tokens - use 7 days as default (non-rememberMe)
const today = moment.utc();
- const expiresAt = today.add(30, "days").toDate();
- const key = `${userId}${crypto.randomBytes(28).toString("hex")}`;
+ const expiresAt = today.add(7, "days").toDate();
+ const key = `${user.id}${crypto.randomBytes(28).toString("hex")}`;
let refreshToken;
try {
refreshToken = await RefreshToken.findOneAndUpdate(
- { userId },
- { expiresAt, key, userId },
+ { userId: user.id },
+ { expiresAt, key, userId: user.id, rememberMe: false },
{ new: true, setDefaultsOnInsert: true, upsert: true }
);
} catch (err) {
- console.log(`Refresh Token for userId ${userId} failed to be created at reactivation.`);
+ console.log(`Refresh Token for userId ${user.id} failed to be created at reactivation.`);
return next(err);
}
- const token = jwt.sign({ userId }, process.env.JWT_SECRET, {
- expiresIn: '30d',
+ const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET, {
+ expiresIn: '7d',
});
return res.status(200).json({
refreshToken: refreshToken.key,
token,
- message: "Account reactivated successfully"
+ general: "Account reactivated successfully"
});
};
diff --git a/src/routes/users/index.js b/src/routes/users/index.js
index 3467cfa..eb38e7a 100644
--- a/src/routes/users/index.js
+++ b/src/routes/users/index.js
@@ -13,13 +13,9 @@ const getProfile = require('./get-profile');
const listUsers = require('./list-users');
const unblockUser = require('./unblock-user');
const deactivateUser = require('./deactivate-user');
-const reactivateUser = require('./reactivate-user');
const router = new express.Router();
-// Public endpoint for reactivating archived accounts (no auth required)
-router.post('/reactivate', reactivateUser);
-
router.get('/profile', isAuthenticated({ isOptional: false }), getProfile);
router.put('/password', isAuthenticated({ isOptional: false }), changePassword);
router.get('', isAuthenticated({ isOptional: false }), listUsers);
diff --git a/src/routes/users/reactivate-user.js b/src/routes/users/reactivate-user.js
deleted file mode 100644
index 9d648c8..0000000
--- a/src/routes/users/reactivate-user.js
+++ /dev/null
@@ -1,109 +0,0 @@
-const crypto = require("crypto");
-const jwt = require("jsonwebtoken");
-const moment = require("moment");
-
-const { RefreshToken } = require("../../models/refresh-token");
-const { User } = require("../../models/user");
-
-const { validateReactivateUser } = require("./validations");
-
-/**
- * Reactivate an archived user account
- * User must provide userId (from archived login response), current password, and new password
- * This prevents account takeover - user must:
- * 1. Attempt login first (to get userId from 403 response)
- * 2. Know their original password
- */
-module.exports = async (req, res, next) => {
- const { errors, isValid } = validateReactivateUser(req.body);
- if (!isValid) {
- return res.status(400).json(errors);
- }
-
- const { userId, currentPassword, newPassword, firstName, lastName } = req.body;
-
- let user;
- try {
- // Find the archived user by userId
- user = await User.findOne({ _id: userId, isArchived: true });
- } catch (err) {
- console.log(`Reactivation failed for userId ${userId}`);
- return next(err);
- }
-
- // Return generic error to prevent enumeration
- if (!user) {
- return res.status(400).json({ general: "Invalid credentials" });
- }
-
- // Verify current password to prove account ownership
- if (!user.hashedPassword) {
- // User signed up via social login, can't use password reactivation
- // They should use the forgot password flow instead
- return res.status(400).json({
- general: "This account was created with social login. Please use the 'Forgot Password' feature to set a password and reactivate your account."
- });
- }
-
- const passwordMatches = user.comparePassword(currentPassword);
- if (!passwordMatches) {
- return res.status(400).json({ general: "Invalid credentials" });
- }
-
- // Update user fields for reactivation
- user.isArchived = false;
- user.password = newPassword; // Will be hashed by the virtual setter
- user.firstName = firstName;
- user.lastName = lastName;
- user.lastLogin = new Date();
- user.inactivityEmailSent = false;
- user.inactivityEmailSentAt = null;
- user.reactivatedAt = new Date();
- user.updatedAt = moment.utc().toDate();
-
- // Update optional fields if provided
- if (req.body.phone) user.phone = req.body.phone;
- if (req.body.zip) user.zip = req.body.zip;
- if (req.body.gender) user.gender = req.body.gender;
- if (req.body.disabilities) user.disabilities = req.body.disabilities;
-
- try {
- await user.save();
- } catch (err) {
- console.log(`User with userId ${userId} failed to be reactivated.`);
- return next(err);
- }
-
- // Generate tokens for the reactivated user - use user.id instead of redeclaring
- const today = moment.utc();
- const expiresAt = today.add(7, "days").toDate();
- const key = `${user.id}${crypto.randomBytes(28).toString("hex")}`;
-
- let refreshToken;
- try {
- refreshToken = await RefreshToken.findOneAndUpdate(
- { userId: user.id },
- { expiresAt, key, userId: user.id, rememberMe: false },
- { new: true, setDefaultsOnInsert: true, upsert: true }
- );
- } catch (err) {
- console.log(`Refresh Token for userId ${user.id} failed to be created at reactivate-user.`);
- return next(err);
- }
-
- const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET, {
- expiresIn: '7d',
- });
-
- return res.status(200).json({
- general: "Account reactivated successfully",
- token,
- refreshToken: refreshToken.key,
- user: {
- id: user.id,
- email: user.email,
- firstName: user.firstName,
- lastName: user.lastName,
- }
- });
-};
diff --git a/src/routes/users/validations.js b/src/routes/users/validations.js
index 0138f27..d59b5f9 100644
--- a/src/routes/users/validations.js
+++ b/src/routes/users/validations.js
@@ -294,55 +294,6 @@ module.exports = {
errors.sortBy = 'Should be a valid sort';
}
- return { errors, isValid: isEmpty(errors) };
- },
- validateReactivateUser(data) {
- const errors = {};
-
- if (!data.userId) {
- errors.userId = 'Is required';
- } else if (typeof data.userId !== 'string') {
- errors.userId = 'Should be a string';
- } else if (data.userId.length !== 24) {
- errors.userId = 'Should be a valid user ID';
- }
-
- if (!data.currentPassword) {
- errors.currentPassword = 'Is required';
- } else if (typeof data.currentPassword !== 'string') {
- errors.currentPassword = 'Should be a string';
- }
-
- if (!data.newPassword) {
- errors.newPassword = 'Is required';
- } else if (typeof data.newPassword !== 'string') {
- errors.newPassword = 'Should be a string';
- } else if (data.newPassword.length < 8) {
- errors.newPassword = 'Should have more than 7 characters';
- } else if (data.newPassword.length > 30) {
- errors.newPassword = 'Should have less than 31 characters';
- }
-
- if (!data.firstName) {
- errors.firstName = 'Is required';
- } else if (typeof data.firstName !== 'string') {
- errors.firstName = 'Should be a string';
- } else if (/[~`!#$%^&*+=\-[\]\\';,./{}|\\":<>?\d]/g.test(data.firstName)) {
- errors.firstName = 'Should only have letters';
- } else if (cleanSpaces(data.firstName).length > 24) {
- errors.firstName = 'Should have less than 25 characters';
- }
-
- if (!data.lastName) {
- errors.lastName = 'Is required';
- } else if (typeof data.lastName !== 'string') {
- errors.lastName = 'Should be a string';
- } else if (/[~`!#$%^&*+=\-[\]\\';,./{}|\\":<>?\d]/g.test(data.lastName)) {
- errors.lastName = 'Should only have letters';
- } else if (cleanSpaces(data.lastName).length > 36) {
- errors.lastName = 'Should have less than 37 characters';
- }
-
return { errors, isValid: isEmpty(errors) };
}
};