From b56e031ee5499f6dd33bd6df3d4cd600926b6d16 Mon Sep 17 00:00:00 2001 From: jackiettran <41605212+jackiettran@users.noreply.github.com> Date: Wed, 7 Jan 2026 00:39:20 -0500 Subject: [PATCH] ability to ban and unban users --- backend/middleware/auth.js | 14 + .../20260106000001-add-user-ban-fields.js | 41 +++ backend/models/User.js | 40 +++ backend/routes/auth.js | 8 + backend/routes/items.js | 4 + backend/routes/users.js | 152 +++++++++- .../services/email/core/TemplateManager.js | 16 + .../domain/UserEngagementEmailService.js | 51 ++++ .../emails/userBannedNotification.html | 277 ++++++++++++++++++ frontend/src/components/BanUserModal.tsx | 215 ++++++++++++++ frontend/src/pages/PublicProfile.tsx | 97 +++++- frontend/src/services/api.ts | 4 + frontend/src/types/index.ts | 5 + 13 files changed, 919 insertions(+), 5 deletions(-) create mode 100644 backend/migrations/20260106000001-add-user-ban-fields.js create mode 100644 backend/templates/emails/userBannedNotification.html create mode 100644 frontend/src/components/BanUserModal.tsx diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index 7edbf72..428dc3b 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -33,6 +33,14 @@ const authenticateToken = async (req, res, next) => { }); } + // Check if user is banned + if (user.isBanned) { + return res.status(403).json({ + error: "Your account has been suspended. Please contact support for more information.", + code: "USER_BANNED", + }); + } + // Validate JWT version to invalidate old tokens after password change if (decoded.jwtVersion !== user.jwtVersion) { return res.status(401).json({ @@ -93,6 +101,12 @@ const optionalAuth = async (req, res, next) => { return next(); } + // Banned users are treated as unauthenticated for optional auth + if (user.isBanned) { + req.user = null; + return next(); + } + // Validate JWT version to invalidate old tokens after password change if (decoded.jwtVersion !== user.jwtVersion) { req.user = null; diff --git a/backend/migrations/20260106000001-add-user-ban-fields.js b/backend/migrations/20260106000001-add-user-ban-fields.js new file mode 100644 index 0000000..6d7d93a --- /dev/null +++ b/backend/migrations/20260106000001-add-user-ban-fields.js @@ -0,0 +1,41 @@ +"use strict"; + +module.exports = { + up: async (queryInterface, Sequelize) => { + // isBanned - boolean flag indicating if user is banned + await queryInterface.addColumn("Users", "isBanned", { + type: Sequelize.BOOLEAN, + defaultValue: false, + allowNull: false, + }); + + // bannedAt - timestamp when ban was applied + await queryInterface.addColumn("Users", "bannedAt", { + type: Sequelize.DATE, + allowNull: true, + }); + + // bannedBy - UUID of admin who applied the ban + await queryInterface.addColumn("Users", "bannedBy", { + type: Sequelize.UUID, + allowNull: true, + references: { + model: "Users", + key: "id", + }, + }); + + // banReason - reason provided by admin for the ban + await queryInterface.addColumn("Users", "banReason", { + type: Sequelize.TEXT, + allowNull: true, + }); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.removeColumn("Users", "banReason"); + await queryInterface.removeColumn("Users", "bannedBy"); + await queryInterface.removeColumn("Users", "bannedAt"); + await queryInterface.removeColumn("Users", "isBanned"); + }, +}; diff --git a/backend/models/User.js b/backend/models/User.js index f8e11af..d5bd7c7 100644 --- a/backend/models/User.js +++ b/backend/models/User.js @@ -142,6 +142,23 @@ const User = sequelize.define( defaultValue: "user", allowNull: false, }, + isBanned: { + type: DataTypes.BOOLEAN, + defaultValue: false, + allowNull: false, + }, + bannedAt: { + type: DataTypes.DATE, + allowNull: true, + }, + bannedBy: { + type: DataTypes.UUID, + allowNull: true, + }, + banReason: { + type: DataTypes.TEXT, + allowNull: true, + }, itemRequestNotificationRadius: { type: DataTypes.INTEGER, defaultValue: 10, @@ -343,4 +360,27 @@ User.prototype.resetPassword = async function (newPassword) { }); }; +// Ban user method - sets ban fields and invalidates all sessions +User.prototype.banUser = async function (adminId, reason) { + return this.update({ + isBanned: true, + bannedAt: new Date(), + bannedBy: adminId, + banReason: reason, + // Increment JWT version to immediately invalidate all sessions + jwtVersion: this.jwtVersion + 1, + }); +}; + +// Unban user method - clears ban fields +User.prototype.unbanUser = async function () { + return this.update({ + isBanned: false, + bannedAt: null, + bannedBy: null, + banReason: null, + // Note: We don't increment jwtVersion on unban - user will need to log in fresh + }); +}; + module.exports = User; diff --git a/backend/routes/auth.js b/backend/routes/auth.js index e214a5e..975e2c7 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -219,6 +219,14 @@ router.post( }); } + // Check if user is banned + if (user.isBanned) { + return res.status(403).json({ + error: "Your account has been suspended. Please contact support for more information.", + code: "USER_BANNED", + }); + } + // Verify password const isPasswordValid = await user.comparePassword(password); diff --git a/backend/routes/items.js b/backend/routes/items.js index 3778e9f..3439b10 100644 --- a/backend/routes/items.js +++ b/backend/routes/items.js @@ -137,6 +137,10 @@ router.get("/", async (req, res, next) => { model: User, as: "owner", attributes: ["id", "firstName", "lastName", "imageFilename"], + where: { + isBanned: { [Op.ne]: true } + }, + required: true, }, ], limit: parseInt(limit), diff --git a/backend/routes/users.js b/backend/routes/users.js index fb3fb1f..1d76777 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -1,6 +1,6 @@ const express = require('express'); const { User, UserAddress } = require('../models'); // Import from models/index.js to get models with associations -const { authenticateToken } = require('../middleware/auth'); +const { authenticateToken, optionalAuth, requireAdmin } = require('../middleware/auth'); const logger = require('../utils/logger'); const userService = require('../services/UserService'); const { validateS3Keys } = require('../utils/s3KeyValidator'); @@ -210,10 +210,20 @@ router.put('/availability', authenticateToken, async (req, res, next) => { } }); -router.get('/:id', async (req, res, next) => { +router.get('/:id', optionalAuth, async (req, res, next) => { try { + const isAdmin = req.user?.role === 'admin'; + + // Base attributes to exclude + const excludedAttributes = ['password', 'email', 'phone', 'address', 'verificationToken', 'passwordResetToken']; + + // If not admin, also exclude ban-related fields + if (!isAdmin) { + excludedAttributes.push('isBanned', 'bannedAt', 'bannedBy', 'banReason'); + } + const user = await User.findByPk(req.params.id, { - attributes: { exclude: ['password', 'email', 'phone', 'address'] } + attributes: { exclude: excludedAttributes } }); if (!user) { @@ -222,7 +232,8 @@ router.get('/:id', async (req, res, next) => { const reqLogger = logger.withRequestId(req.id); reqLogger.info("Public user profile fetched", { - requestedUserId: req.params.id + requestedUserId: req.params.id, + viewerIsAdmin: isAdmin }); res.json(user); @@ -263,4 +274,137 @@ router.put('/profile', authenticateToken, async (req, res, next) => { } }); +// Admin: Ban a user +router.post('/admin/:id/ban', authenticateToken, requireAdmin, async (req, res, next) => { + try { + const { reason } = req.body; + const targetUserId = req.params.id; + + // Validate reason is provided + if (!reason || !reason.trim()) { + return res.status(400).json({ error: "Ban reason is required" }); + } + + // Prevent banning yourself + if (targetUserId === req.user.id) { + return res.status(400).json({ error: "You cannot ban yourself" }); + } + + const targetUser = await User.findByPk(targetUserId); + + if (!targetUser) { + return res.status(404).json({ error: "User not found" }); + } + + // Prevent banning other admins + if (targetUser.role === 'admin') { + return res.status(403).json({ error: "Cannot ban admin users" }); + } + + // Check if already banned + if (targetUser.isBanned) { + return res.status(400).json({ error: "User is already banned" }); + } + + // Ban the user (this also invalidates sessions via jwtVersion increment) + await targetUser.banUser(req.user.id, reason.trim()); + + // Send ban notification email + try { + const emailServices = require("../services/email"); + await emailServices.userEngagement.sendUserBannedNotification( + targetUser, + req.user, + reason.trim() + ); + const reqLogger = logger.withRequestId(req.id); + reqLogger.info("User ban notification email sent", { + bannedUserId: targetUserId, + adminId: req.user.id + }); + } catch (emailError) { + // Log but don't fail the ban operation + const reqLogger = logger.withRequestId(req.id); + reqLogger.error('Failed to send user ban notification email', { + error: emailError.message, + stack: emailError.stack, + bannedUserId: targetUserId, + adminId: req.user.id + }); + } + + const reqLogger = logger.withRequestId(req.id); + reqLogger.info("User banned by admin", { + targetUserId, + adminId: req.user.id, + reason: reason.trim() + }); + + // Return updated user data (excluding sensitive fields) + const updatedUser = await User.findByPk(targetUserId, { + attributes: { exclude: ['password', 'verificationToken', 'passwordResetToken'] } + }); + + res.json({ + message: "User has been banned successfully", + user: updatedUser + }); + } catch (error) { + const reqLogger = logger.withRequestId(req.id); + reqLogger.error("Admin ban user failed", { + error: error.message, + stack: error.stack, + targetUserId: req.params.id, + adminId: req.user.id + }); + next(error); + } +}); + +// Admin: Unban a user +router.post('/admin/:id/unban', authenticateToken, requireAdmin, async (req, res, next) => { + try { + const targetUserId = req.params.id; + + const targetUser = await User.findByPk(targetUserId); + + if (!targetUser) { + return res.status(404).json({ error: "User not found" }); + } + + // Check if user is actually banned + if (!targetUser.isBanned) { + return res.status(400).json({ error: "User is not banned" }); + } + + // Unban the user + await targetUser.unbanUser(); + + const reqLogger = logger.withRequestId(req.id); + reqLogger.info("User unbanned by admin", { + targetUserId, + adminId: req.user.id + }); + + // Return updated user data (excluding sensitive fields) + const updatedUser = await User.findByPk(targetUserId, { + attributes: { exclude: ['password', 'verificationToken', 'passwordResetToken'] } + }); + + res.json({ + message: "User has been unbanned successfully", + user: updatedUser + }); + } catch (error) { + const reqLogger = logger.withRequestId(req.id); + reqLogger.error("Admin unban user failed", { + error: error.message, + stack: error.stack, + targetUserId: req.params.id, + adminId: req.user.id + }); + next(error); + } +}); + module.exports = router; \ No newline at end of file diff --git a/backend/services/email/core/TemplateManager.js b/backend/services/email/core/TemplateManager.js index d92c626..dec4e03 100644 --- a/backend/services/email/core/TemplateManager.js +++ b/backend/services/email/core/TemplateManager.js @@ -104,6 +104,7 @@ class TemplateManager { "forumCommentDeletionToAuthor.html", "paymentDeclinedToRenter.html", "paymentMethodUpdatedToOwner.html", + "userBannedNotification.html", ]; const failedTemplates = []; @@ -526,6 +527,21 @@ class TemplateManager {

Review & Approve Rental

` ), + + userBannedNotification: baseTemplate.replace( + "{{content}}", + ` +

Hi {{userName}},

+

Your Account Has Been Suspended

+

Your Village Share account has been suspended by our moderation team.

+
+

Reason for Suspension:

+

{{banReason}}

+
+

You have been logged out of all devices and cannot log in to your account.

+

If you believe this suspension was made in error, please contact {{supportEmail}}.

+ ` + ), }; return ( diff --git a/backend/services/email/domain/UserEngagementEmailService.js b/backend/services/email/domain/UserEngagementEmailService.js index 6ba10b5..2deb4b9 100644 --- a/backend/services/email/domain/UserEngagementEmailService.js +++ b/backend/services/email/domain/UserEngagementEmailService.js @@ -118,6 +118,57 @@ class UserEngagementEmailService { return { success: false, error: error.message }; } } + + /** + * Send notification when a user's account is banned + * @param {Object} bannedUser - User who was banned + * @param {string} bannedUser.firstName - Banned user's first name + * @param {string} bannedUser.email - Banned user's email + * @param {Object} admin - Admin who performed the ban + * @param {string} admin.firstName - Admin's first name + * @param {string} admin.lastName - Admin's last name + * @param {string} banReason - Reason for the ban + * @returns {Promise<{success: boolean, messageId?: string, error?: string}>} + */ + async sendUserBannedNotification(bannedUser, admin, banReason) { + if (!this.initialized) { + await this.initialize(); + } + + try { + const supportEmail = process.env.SUPPORT_EMAIL; + + const variables = { + userName: bannedUser.firstName || "there", + banReason: banReason, + supportEmail: supportEmail, + }; + + const htmlContent = await this.templateManager.renderTemplate( + "userBannedNotification", + variables + ); + + const subject = "Important: Your Village Share Account Has Been Suspended"; + + const result = await this.emailClient.sendEmail( + bannedUser.email, + subject, + htmlContent + ); + + if (result.success) { + console.log( + `User banned notification email sent to ${bannedUser.email}` + ); + } + + return result; + } catch (error) { + console.error("Failed to send user banned notification email:", error); + return { success: false, error: error.message }; + } + } } module.exports = UserEngagementEmailService; diff --git a/backend/templates/emails/userBannedNotification.html b/backend/templates/emails/userBannedNotification.html new file mode 100644 index 0000000..60da67a --- /dev/null +++ b/backend/templates/emails/userBannedNotification.html @@ -0,0 +1,277 @@ + + + + + + + Account Suspended + + + +
+
+ +
Important Account Notice
+
+ +
+

Hi {{userName}},

+ +

Your Account Has Been Suspended

+ +

+ We're writing to inform you that your Village Share account has been + suspended by our moderation team. +

+ +
+

Reason for Suspension:

+

{{banReason}}

+
+ +
+

What this means:

+
    +
  • You have been logged out of all devices
  • +
  • You cannot log in to your account
  • +
  • Your listings are no longer visible to other users
  • +
  • Any pending rental requests have been affected
  • +
+
+ +

Need Help or Have Questions?

+

+ If you believe this suspension was made in error or if you would like + to appeal this decision, please contact our support team: +

+ +

+ Contact Support +

+ +

+ Best regards,
+ The Village Share Team +

+
+ + +
+ + diff --git a/frontend/src/components/BanUserModal.tsx b/frontend/src/components/BanUserModal.tsx new file mode 100644 index 0000000..e549669 --- /dev/null +++ b/frontend/src/components/BanUserModal.tsx @@ -0,0 +1,215 @@ +import React, { useState, useEffect } from "react"; +import { userAPI } from "../services/api"; +import { User } from "../types"; + +interface BanUserModalProps { + show: boolean; + onHide: () => void; + user: User; + onBanComplete: (updatedUser: User) => void; +} + +const BanUserModal: React.FC = ({ + show, + onHide, + user, + onBanComplete, +}) => { + const [processing, setProcessing] = useState(false); + const [error, setError] = useState(null); + const [reason, setReason] = useState(""); + const [success, setSuccess] = useState(false); + const [updatedUser, setUpdatedUser] = useState(null); + + const handleBan = async () => { + if (!reason.trim()) { + setError("Please provide a reason for banning this user"); + return; + } + + try { + setProcessing(true); + setError(null); + + const response = await userAPI.adminBanUser(user.id, reason.trim()); + + // Store updated user data for later callback + setUpdatedUser(response.data.user); + + // Show success confirmation + setSuccess(true); + } catch (error: any) { + setError(error.response?.data?.error || "Failed to ban user"); + } finally { + setProcessing(false); + } + }; + + const handleClose = () => { + // Call parent callback with updated user data if we have it + if (updatedUser) { + onBanComplete(updatedUser); + } + + // Reset all states when closing + setProcessing(false); + setError(null); + setReason(""); + setSuccess(false); + setUpdatedUser(null); + onHide(); + }; + + useEffect(() => { + if (show) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = "unset"; + } + + return () => { + document.body.style.overflow = "unset"; + }; + }, [show]); + + if (!show) return null; + + return ( +
+
+
+
+
+ {success + ? "User Banned" + : `Ban User ${user.firstName} ${user.lastName}`} +
+ +
+
+ {success ? ( +
+
+ +
+

User Banned

+
+

+ {user.firstName} {user.lastName} has been banned and logged + out of all sessions. If they had listings, they are no + longer available. They have been notified via email. +

+
+
+ ) : ( + <> + {error && ( +
+ {error} +
+ )} + +
{ + e.preventDefault(); + handleBan(); + }} + > +
+ +