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}} + +
+ 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, 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;