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

    +
      + ${archivedUsersList} +
    + +

    Warnings Sent To

    +
      + ${warningsSentList} +
    +
    +

    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}} + +
    + setFormData({...formData, currentPassword: e.target.value})} + error={errors.currentPassword} + helperText="Enter the password you used before your account was archived" + /> + + setFormData({...formData, newPassword: e.target.value})} + error={errors.newPassword} + helperText="8-30 characters" + /> + + setFormData({...formData, confirmPassword: e.target.value})} + error={errors.confirmPassword} + /> + + setFormData({...formData, firstName: e.target.value})} + error={errors.firstName} + /> + + setFormData({...formData, lastName: e.target.value})} + error={errors.lastName} + /> + + +
    + +

    + 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

    -
      - ${archivedUsersList} -
    - -

    Warnings Sent To

    -
      - ${warningsSentList} -
    -
    -

    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) }; } };