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/FRONTEND_INACTIVITY_GUIDE.md b/FRONTEND_INACTIVITY_GUIDE.md
new file mode 100644
index 0000000..9207559
--- /dev/null
+++ b/FRONTEND_INACTIVITY_GUIDE.md
@@ -0,0 +1,429 @@
+# 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.
+
+### 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
+
+### 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) should use the **Forgot Password** flow to reactivate, since they may not have a password set.
+
+---
+
+### 2. Reactivate Account Endpoint
+
+**POST /auth/reactivate-account**
+
+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"
+}
+```
+
+**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
+{
+ "general": "Account reactivated successfully",
+ "token": "eyJhbGciOiJIUzI1NiIs...",
+ "refreshToken": "507f1f77bcf86cd799439011abc123..."
+}
+```
+
+**Error Responses:**
+
+| Status | Scenario | Response |
+|--------|----------|----------|
+| 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..." }` |
+
+---
+
+## 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('/auth/reactivate-account', {
+ 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, they can use the **Forgot Password** flow to reactivate:
+
+```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 should use forgot password to reactivate
+ showModal({
+ title: 'Account Archived',
+ 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: 'Reset Password', onClick: () => window.location.href = '/forgotten-password' },
+ { label: 'Cancel', onClick: () => {} }
+ ]
+ });
+ } 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 /auth/reactivate-account
+ │
+ ┌──────┴──────┐
+ │ │
+ ▼ ▼
+ [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 via endpoint**
+ - 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 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?
+
+Contact the backend team for any integration questions.
diff --git a/src/helpers/inactivity-checker.js b/src/helpers/inactivity-checker.js
new file mode 100644
index 0000000..c4ed008
--- /dev/null
+++ b/src/helpers/inactivity-checker.js
@@ -0,0 +1,164 @@
+const moment = require("moment");
+const { User } = require("../models/user");
+const { RefreshToken } = require("../models/refresh-token");
+const { sendEmail } = require("../helpers");
+const {
+ inactivityWarningEmailTemplate,
+ accountArchivedEmailTemplate,
+} = require("../helpers/mail-template");
+
+const INACTIVITY_THRESHOLD_DAYS = 365; // 1 year
+const ARCHIVE_GRACE_PERIOD_DAYS = 7; // 7 days after warning email
+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 },
+ lastLogin: { $ne: null, $lt: oneYearAgo }
+ }).select("_id email firstName lastName lastLogin");
+
+ 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 displayName = user.firstName || "User";
+ const emailContent = inactivityWarningEmailTemplate(
+ displayName,
+ loginUrl
+ );
+
+ await sendEmail({
+ receiversEmails: [user.email],
+ subject: "We miss you! Your AXS Map account needs attention",
+ htmlContent: emailContent,
+ 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
+ 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 displayName = user.firstName || "User";
+ const emailContent = accountArchivedEmailTemplate(
+ displayName,
+ reactivateUrl
+ );
+
+ await sendEmail({
+ receiversEmails: [user.email],
+ subject: "Your AXS Map account has been archived",
+ htmlContent: emailContent,
+ textContent: `Hi ${displayName}, 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 [];
+ }
+}
+
+/**
+ * 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,
+ };
+}
+
+module.exports = {
+ runInactivityCheck,
+ sendInactivityWarnings,
+ archiveInactiveUsers,
+};
diff --git a/src/helpers/mail-template.js b/src/helpers/mail-template.js
index bbb4960..01a6860 100644
--- a/src/helpers/mail-template.js
+++ b/src/helpers/mail-template.js
@@ -226,9 +226,153 @@ 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.
+ |
+
+
+
+ |
+
+
+
+
+`;
+};
+
module.exports = {
activationEmailTemplate,
submitServeyUserMailTemplate,
adminServeyMailTemplate,
donationMailTemplate,
+ inactivityWarningEmailTemplate,
+ accountArchivedEmailTemplate,
};
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 8bcb50f..1ce95b3 100644
--- a/src/routes/auth/facebook-sign-in.js
+++ b/src/routes/auth/facebook-sign-in.js
@@ -63,17 +63,23 @@ 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({
- error: "Account archived",
+ general: "Account is archived due to inactivity",
isArchived: true,
- userId: user._id.toString()
+ requiresReactivation: true,
+ userId: user.id
});
}
- // 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/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/google-sign-in.js b/src/routes/auth/google-sign-in.js
index d424e4b..e5a6b44 100644
--- a/src/routes/auth/google-sign-in.js
+++ b/src/routes/auth/google-sign-in.js
@@ -64,17 +64,23 @@ 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({
- error: "Account archived",
+ general: "Account is archived due to inactivity",
isArchived: true,
- userId: user._id.toString()
+ requiresReactivation: true,
+ userId: user.id
});
}
- // 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/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/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/auth/sign-in.js b/src/routes/auth/sign-in.js
index 01a8529..aef992d 100644
--- a/src/routes/auth/sign-in.js
+++ b/src/routes/auth/sign-in.js
@@ -20,8 +20,8 @@ module.exports = async (req, res, next) => {
let user;
try {
- user = await User.findOne({ email, isArchived: false });
- console.log("User", user);
+ // First check if user exists (including archived users)
+ user = await User.findOne({ email });
} catch (err) {
console.log(`User with email ${email} failed to be found at sign-in.`);
return next(err);
@@ -31,6 +31,19 @@ module.exports = async (req, res, next) => {
return res.status(400).json({ general: "Email or password incorrect" });
}
+ // 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,
+ userId: user.id
+ });
+ }
+
if (user.isBlocked) {
return res.status(423).json({ general: "You are blocked" });
}
@@ -47,9 +60,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..76f94a3
--- /dev/null
+++ b/src/routes/others/inactivity-cron.js
@@ -0,0 +1,32 @@
+const { runInactivityCheck } = 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
+ // 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 {
+ 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" });
+ }
+ },
+};
diff --git a/src/routes/others/index.js b/src/routes/others/index.js
index 0ba2a70..95baa53 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 } = require("./inactivity-cron");
const { isAuthenticated } = require("../../helpers");
const router = new express.Router();
@@ -11,4 +12,7 @@ router.post("/contact", contact);
router.post("/survey", isAuthenticated({ isOptional: false }), survey);
router.get("/migrate-scores", migrateScores);
+// Cron job endpoint for inactivity tracking
+router.post("/cron/inactivity-check", runDailyCheck);
+
module.exports = router;