diff --git a/backend/package-lock.json b/backend/package-lock.json index d50ac8b..5de2da8 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -14,7 +14,7 @@ "body-parser": "^1.20.4", "cookie-parser": "^1.4.7", "cors": "^2.8.5", - "csurf": "^1.2.2", + "csurf": "^1.11.0", "dotenv": "^16.4.7", "express": "^4.22.1", "express-rate-limit": "^7.5.0", diff --git a/backend/src/controllers/userController.ts b/backend/src/controllers/userController.ts index 44c0447..26608a9 100644 --- a/backend/src/controllers/userController.ts +++ b/backend/src/controllers/userController.ts @@ -203,4 +203,145 @@ export async function rejectUser(req: AuthenticatedRequest, res: Response) { } catch (error) { res.status(400).json({ error: (error as Error).message }); } +} + +export async function bulkDeleteUsers(req: AuthenticatedRequest, res: Response) { + try { + const { userIds } = req.body as { userIds: number[] }; + + if (!userIds || !Array.isArray(userIds) || userIds.length === 0) { + res.status(400).json({ error: 'Invalid or empty userIds array' }); + return; + } + + // Verify users exist before deletion + const users = await prisma.user.findMany({ + where: { id: { in: userIds } } + }); + + if (users.length === 0) { + res.status(404).json({ error: 'No users found' }); + return; + } + + await prisma.user.deleteMany({ + where: { + id: { in: userIds } + } + }); + + res.status(200).json({ message: 'Users deleted successfully', count: users.length }); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } +} + +export async function bulkApproveUsers(req: AuthenticatedRequest, res: Response) { + try { + const { userIds } = req.body as { userIds: number[] }; + + if (!userIds || !Array.isArray(userIds) || userIds.length === 0) { + res.status(400).json({ error: 'Invalid or empty userIds array' }); + return; + } + + const users = await prisma.user.findMany({ + where: { id: { in: userIds } } + }); + + if (users.length === 0) { + res.status(404).json({ error: 'No users found' }); + return; + } + + try { + await connectRcon(); + + for (const user of users) { + const sanitizedUsername = sanitizeUsername(user.minecraftUsername); + if (!sanitizedUsername) continue; + + if(user.gameType === 'Java Edition') { + await sendRconCommand(`easywl add ${sanitizedUsername}`); + } else if (user.gameType === 'Bedrock Edition') { + await sendRconCommand(`easywl add .${sanitizedUsername}`); + } + } + + await sendRconCommand(`easywl reload`); + } catch (rconError) { + const errorMessage = rconError instanceof Error ? rconError.message : String(rconError); + console.warn(`[bulkApproveUsers] RCON error (Minecraft server may not be running): ${errorMessage}`); + } finally { + try { + disconnectRcon(); + } catch (disconnectError) { + // Ignore disconnect errors + } + } + + await prisma.user.updateMany({ + where: { id: { in: userIds } }, + data: { approved: true } + }); + + res.status(200).json({ message: 'Users approved successfully', count: users.length }); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } +} + +export async function bulkRejectUsers(req: AuthenticatedRequest, res: Response) { + try { + const { userIds } = req.body as { userIds: number[] }; + + if (!userIds || !Array.isArray(userIds) || userIds.length === 0) { + res.status(400).json({ error: 'Invalid or empty userIds array' }); + return; + } + + const users = await prisma.user.findMany({ + where: { id: { in: userIds } } + }); + + if (users.length === 0) { + res.status(404).json({ error: 'No users found' }); + return; + } + + try { + await connectRcon(); + + for (const user of users) { + const sanitizedUsername = sanitizeUsername(user.minecraftUsername); + if (!sanitizedUsername) continue; + + if(user.gameType === 'Java Edition') { + await sendRconCommand(`easywl remove ${sanitizedUsername}`); + } else if (user.gameType === 'Bedrock Edition') { + await sendRconCommand(`easywl remove .${sanitizedUsername}`); + } + } + + await sendRconCommand(`easywl reload`); + } catch (rconError) { + const errorMessage = rconError instanceof Error ? rconError.message : String(rconError); + console.warn(`[bulkRejectUsers] RCON error (Minecraft server may not be running): ${errorMessage}`); + } finally { + try { + disconnectRcon(); + } catch (disconnectError) { + // Ignore disconnect errors + } + } + + await prisma.user.updateMany({ + where: { id: { in: userIds } }, + data: { approved: false } + }); + + res.status(200).json({ message: 'Users rejected successfully', count: users.length }); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } } \ No newline at end of file diff --git a/backend/src/routes/userRoutes.ts b/backend/src/routes/userRoutes.ts index 927b43f..4991e67 100644 --- a/backend/src/routes/userRoutes.ts +++ b/backend/src/routes/userRoutes.ts @@ -6,6 +6,9 @@ import { deleteUserById, approveUser, rejectUser, + bulkDeleteUsers, + bulkApproveUsers, + bulkRejectUsers, } from '../controllers/userController'; import { authMiddleware } from '../middleware/authMiddleware'; @@ -24,4 +27,9 @@ router.delete('/:userId', authMiddleware, deleteUserById); router.put('/:userId/approve', authMiddleware, approveUser); router.put('/:userId/reject', authMiddleware, rejectUser); +// Bulk operations +router.post('/bulk/delete', authMiddleware, bulkDeleteUsers); +router.post('/bulk/approve', authMiddleware, bulkApproveUsers); +router.post('/bulk/reject', authMiddleware, bulkRejectUsers); + export default router; \ No newline at end of file diff --git a/frontend/src/components/UserTable.tsx b/frontend/src/components/UserTable.tsx index 75e2c97..c92a4be 100644 --- a/frontend/src/components/UserTable.tsx +++ b/frontend/src/components/UserTable.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import { apiJwt } from '../api'; import { copyToClipboard } from '../utils/clipboardUtils'; import '../styles/UserTable.css'; @@ -14,11 +14,20 @@ interface User { const UserTable: React.FC = () => { const [users, setUsers] = useState([]); + const [selectedUserIds, setSelectedUserIds] = useState>(new Set()); + const selectAllRef = useRef(null); useEffect(() => { fetchUsers() }, []); + useEffect(() => { + // Update indeterminate state of select all checkbox + if (selectAllRef.current) { + selectAllRef.current.indeterminate = selectedUserIds.size > 0 && selectedUserIds.size < users.length; + } + }, [selectedUserIds, users]); + const fetchUsers = async () => { try { console.log('[UserTable] Fetching users...'); @@ -67,6 +76,50 @@ const UserTable: React.FC = () => { } }; + const handleSelectUser = (userId: number) => { + setSelectedUserIds(prev => { + const newSet = new Set(prev); + if (newSet.has(userId)) { + newSet.delete(userId); + } else { + newSet.add(userId); + } + return newSet; + }); + }; + + const handleSelectAll = (e: React.ChangeEvent) => { + if (e.target.checked) { + setSelectedUserIds(new Set(users.map(u => u.id))); + } else { + setSelectedUserIds(new Set()); + } + }; + + const handleBulkAction = async (action: 'delete' | 'approve' | 'reject') => { + if (selectedUserIds.size === 0) return; + + const userIds = Array.from(selectedUserIds); + const actionText = action === 'delete' ? 'delete' : (action === 'approve' ? 'approve' : 'reject'); + const confirmed = window.confirm(`Are you sure you want to ${actionText} ${userIds.length} user(s)?`); + + if (!confirmed) return; + + try { + if (action === 'delete') { + await apiJwt.post('/api/user/bulk/delete', { userIds }); + } else if (action === 'approve') { + await apiJwt.post('/api/user/bulk/approve', { userIds }); + } else if (action === 'reject') { + await apiJwt.post('/api/user/bulk/reject', { userIds }); + } + setSelectedUserIds(new Set()); + fetchUsers(); + } catch (error) { + console.error(`Error performing bulk ${action}:`, error); + } + }; + const formatGameType = (gameType: string) => { switch (gameType) { case 'Bedrock Edition': @@ -116,9 +169,41 @@ const UserTable: React.FC = () => { return ( <>
+ {selectedUserIds.size > 0 && ( +
+
+ {selectedUserIds.size} item(s) selected +
+
+ +
+
+ )} + @@ -128,6 +213,13 @@ const UserTable: React.FC = () => { {users.map(user => ( +
+ 0} + onChange={handleSelectAll} + /> + Username Game Type Approved
+ handleSelectUser(user.id)} + /> + handleMouseEnter(event, user)} diff --git a/frontend/src/styles/UserTable.css b/frontend/src/styles/UserTable.css index 94987e3..67074e9 100644 --- a/frontend/src/styles/UserTable.css +++ b/frontend/src/styles/UserTable.css @@ -28,18 +28,72 @@ th, td { height: 30px; } -td:nth-child(1):hover { +td:nth-child(2):hover { color: #FFFFA0; } -td:nth-child(3), td:nth-child(4) { +td:nth-child(4), td:nth-child(5) { text-align: center; } +.bulk-actions { + display: flex; + align-items: center; + justify-content: space-between; + background-color: rgba(0, 0, 0, 0.5); + padding: 10px 15px; + margin-bottom: 10px; + border-radius: 4px; +} + +.bulk-selection-info { + display: flex; + align-items: center; + gap: 10px; +} + +.selection-count { + font-size: 14px; + font-weight: bold; + color: #FFFFA0; +} + +.bulk-action-buttons { + display: flex; + gap: 10px; +} + +.action-select { + padding: 6px 12px; + border: 1px solid #555; + background-color: rgba(0, 0, 0, 0.7); + color: white; + cursor: pointer; + border-radius: 4px; + font-size: 14px; +} + +.action-select:hover { + background-color: rgba(0, 0, 0, 0.9); +} + @media (max-width: 600px) { table { font-size: 14px; } + + .bulk-actions { + flex-direction: column; + gap: 10px; + } + + .bulk-action-buttons { + width: 100%; + } + + .action-select { + width: 100%; + } } #tooltip {