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
+
+
+
+
+
+
+
+
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}
+
+ )}
+
+
+ >
+ )}
+
+
+ {success ? (
+
+ Done
+
+ ) : (
+ <>
+
+ Cancel
+
+
+ {processing ? (
+ <>
+
+ Loading...
+
+ Banning...
+ >
+ ) : (
+ "Ban User"
+ )}
+
+ >
+ )}
+
+
+
+
+ );
+};
+
+export default BanUserModal;
diff --git a/frontend/src/pages/PublicProfile.tsx b/frontend/src/pages/PublicProfile.tsx
index 64aa400..0d2b336 100644
--- a/frontend/src/pages/PublicProfile.tsx
+++ b/frontend/src/pages/PublicProfile.tsx
@@ -6,6 +6,8 @@ import { getImageUrl } from '../services/uploadService';
import { useAuth } from '../contexts/AuthContext';
import ChatWindow from '../components/ChatWindow';
import Avatar from '../components/Avatar';
+import BanUserModal from '../components/BanUserModal';
+import ConfirmationModal from '../components/ConfirmationModal';
const PublicProfile: React.FC = () => {
const { id } = useParams<{ id: string }>();
@@ -16,6 +18,9 @@ const PublicProfile: React.FC = () => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [showChat, setShowChat] = useState(false);
+ const [showBanModal, setShowBanModal] = useState(false);
+ const [showUnbanModal, setShowUnbanModal] = useState(false);
+ const [unbanning, setUnbanning] = useState(false);
useEffect(() => {
fetchUserProfile();
@@ -44,6 +49,28 @@ const PublicProfile: React.FC = () => {
}
};
+ const handleBanComplete = (updatedUser: User) => {
+ setUser(updatedUser);
+ };
+
+ const handleUnban = async () => {
+ if (!user) return;
+
+ try {
+ setUnbanning(true);
+ const response = await userAPI.adminUnbanUser(user.id);
+ setUser(response.data.user);
+ setShowUnbanModal(false);
+ } catch (err: any) {
+ setError(err.response?.data?.error || 'Failed to unban user');
+ } finally {
+ setUnbanning(false);
+ }
+ };
+
+ const isAdmin = currentUser?.role === 'admin';
+ const canBanUser = isAdmin && user && currentUser?.id !== user.id && user.role !== 'admin';
+
if (loading) {
return (
@@ -75,7 +102,21 @@ const PublicProfile: React.FC = () => {
{user.firstName} {user.lastName}
- {currentUser && currentUser.id !== user.id && (
+
+ {/* Show ban status badge for admins */}
+ {isAdmin && user.isBanned && (
+
+ Banned
+ {user.bannedAt && (
+
+ Banned on {new Date(user.bannedAt).toLocaleDateString()}
+
+ )}
+
+ )}
+
+ {/* Message button - hide for banned users */}
+ {currentUser && currentUser.id !== user.id && !user.isBanned && (
setShowChat(true)}
@@ -83,6 +124,35 @@ const PublicProfile: React.FC = () => {
Message
)}
+
+ {/* Admin Ban/Unban buttons */}
+ {canBanUser && (
+
+ {user.isBanned ? (
+ setShowUnbanModal(true)}
+ >
+ Unban User
+
+ ) : (
+ setShowBanModal(true)}
+ >
+ Ban User
+
+ )}
+
+ )}
+
+ {/* Show ban reason for admins */}
+ {isAdmin && user.isBanned && user.banReason && (
+
+
Ban Reason:
+
{user.banReason}
+
+ )}
@@ -153,6 +223,31 @@ const PublicProfile: React.FC = () => {
recipient={user}
/>
)}
+
+ {/* BanUserModal */}
+ {user && (
+ setShowBanModal(false)}
+ user={user}
+ onBanComplete={handleBanComplete}
+ />
+ )}
+
+ {/* UnbanModal */}
+ {user && (
+ setShowUnbanModal(false)}
+ onConfirm={handleUnban}
+ title={`Unban ${user.firstName} ${user.lastName}`}
+ message="Are you sure you want to unban this user? They will be able to log in and use the platform again."
+ confirmText="Unban User"
+ cancelText="Cancel"
+ confirmButtonClass="btn-success"
+ loading={unbanning}
+ />
+ )}
);
};
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index 6df35fe..4d640cd 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -184,6 +184,10 @@ export const userAPI = {
getPublicProfile: (id: string) => api.get(`/users/${id}`),
getAvailability: () => api.get("/users/availability"),
updateAvailability: (data: any) => api.put("/users/availability", data),
+ // Admin endpoints
+ adminBanUser: (id: string, reason: string) =>
+ api.post(`/users/admin/${id}/ban`, { reason }),
+ adminUnbanUser: (id: string) => api.post(`/users/admin/${id}/unban`),
};
export const addressAPI = {
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index c7218b3..e8c6724 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -33,6 +33,11 @@ export interface User {
stripeConnectedAccountId?: string;
addresses?: Address[];
itemRequestNotificationRadius?: number;
+ // Ban-related fields (only visible to admins)
+ isBanned?: boolean;
+ bannedAt?: string;
+ bannedBy?: string;
+ banReason?: string;
}
export interface Message {