Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

141 changes: 141 additions & 0 deletions backend/src/controllers/userController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
}
8 changes: 8 additions & 0 deletions backend/src/routes/userRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import {
deleteUserById,
approveUser,
rejectUser,
bulkDeleteUsers,
bulkApproveUsers,
bulkRejectUsers,
} from '../controllers/userController';
import { authMiddleware } from '../middleware/authMiddleware';

Expand All @@ -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;
94 changes: 93 additions & 1 deletion frontend/src/components/UserTable.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -14,11 +14,20 @@ interface User {

const UserTable: React.FC = () => {
const [users, setUsers] = useState<User[]>([]);
const [selectedUserIds, setSelectedUserIds] = useState<Set<number>>(new Set());
const selectAllRef = useRef<HTMLInputElement>(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...');
Expand Down Expand Up @@ -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<HTMLInputElement>) => {
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':
Expand Down Expand Up @@ -116,9 +169,41 @@ const UserTable: React.FC = () => {
return (
<>
<div id="tooltip" style={{ position: 'absolute', display: 'none' }}></div>
{selectedUserIds.size > 0 && (
<div className="bulk-actions">
<div className="bulk-selection-info">
<span className="selection-count">{selectedUserIds.size} item(s) selected</span>
</div>
<div className="bulk-action-buttons">
<select
className="action-select"
onChange={(e) => {
if (e.target.value) {
handleBulkAction(e.target.value as 'delete' | 'approve' | 'reject');
e.target.value = '';
}
}}
defaultValue=""
>
<option value="" disabled>Action</option>
<option value="approve">Approve</option>
<option value="reject">Reject</option>
<option value="delete">Delete</option>
</select>
</div>
</div>
)}
<table>
<thead>
<tr>
<th>
<input
type="checkbox"
ref={selectAllRef}
checked={selectedUserIds.size === users.length && users.length > 0}
onChange={handleSelectAll}
/>
</th>
<th>Username</th>
<th>Game Type</th>
<th>Approved</th>
Expand All @@ -128,6 +213,13 @@ const UserTable: React.FC = () => {
<tbody>
{users.map(user => (
<tr key={user.id}>
<td>
<input
type="checkbox"
checked={selectedUserIds.has(user.id)}
onChange={() => handleSelectUser(user.id)}
/>
</td>
<td
id={`username-${user.minecraftUsername}-${user.id}`}
onMouseEnter={event => handleMouseEnter(event, user)}
Expand Down
58 changes: 56 additions & 2 deletions frontend/src/styles/UserTable.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down