enki b3204ea07a
Some checks are pending
CI Pipeline / Run Tests (push) Waiting to run
CI Pipeline / Lint Code (push) Waiting to run
CI Pipeline / Security Scan (push) Waiting to run
CI Pipeline / Build Docker Images (push) Blocked by required conditions
CI Pipeline / E2E Tests (push) Blocked by required conditions
first commit
2025-08-18 00:40:15 -07:00

922 lines
37 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Dashboard - Blossom-BitTorrent Gateway</title>
<link rel="stylesheet" href="/static/style.css">
<style>
.admin-nav {
background: var(--bg-secondary);
border-radius: 12px;
padding: 20px;
margin-bottom: 30px;
display: flex;
gap: 15px;
flex-wrap: wrap;
}
.admin-nav-btn {
padding: 10px 20px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.admin-nav-btn.active,
.admin-nav-btn:hover {
background: var(--accent-primary);
color: white;
border-color: var(--accent-primary);
}
.admin-section {
display: none;
}
.admin-section.active {
display: block;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.admin-table {
background: var(--bg-secondary);
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--border-color);
}
.admin-table table {
width: 100%;
border-collapse: collapse;
}
.admin-table th,
.admin-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.admin-table th {
background: var(--bg-tertiary);
font-weight: 600;
color: var(--text-primary);
}
.admin-table td {
color: var(--text-secondary);
}
.admin-table tr:hover {
background: var(--bg-tertiary);
}
.admin-controls {
display: flex;
gap: 10px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.admin-form {
background: var(--bg-secondary);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 500;
color: var(--text-primary);
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-primary);
color: var(--text-primary);
}
.form-group textarea {
min-height: 80px;
resize: vertical;
}
.status-badge {
padding: 4px 8px;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 600;
}
.status-badge.banned {
background: var(--danger);
color: white;
}
.status-badge.active {
background: var(--success);
color: white;
}
.status-badge.pending {
background: var(--warning);
color: black;
}
.hash-short {
font-family: monospace;
font-size: 0.9rem;
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>🛡️ Admin Dashboard</h1>
<nav>
<a href="/">← Back to Gateway</a>
<div id="admin-auth-status" class="auth-status">
<span id="admin-user-info">Not logged in</span>
<button id="admin-logout-btn" onclick="adminLogout()" style="display: none;">Logout</button>
</div>
</nav>
</header>
<main id="admin-content" style="display: none;">
<div class="admin-nav">
<button class="admin-nav-btn active" onclick="showAdminSection('overview')">Overview</button>
<button class="admin-nav-btn" onclick="showAdminSection('users')">Users</button>
<button class="admin-nav-btn" onclick="showAdminSection('files')">Files</button>
<button class="admin-nav-btn" onclick="showAdminSection('reports')">Reports</button>
<button class="admin-nav-btn" onclick="showAdminSection('cleanup')">Cleanup</button>
<button class="admin-nav-btn" onclick="showAdminSection('logs')">Audit Log</button>
</div>
<!-- Overview Section -->
<div id="overview-section" class="admin-section active">
<h2>System Overview</h2>
<div class="stats-grid" id="admin-stats">
<!-- Dynamic content -->
</div>
<div class="admin-table">
<h3>Recent Uploads (24h)</h3>
<table>
<thead>
<tr>
<th>File Name</th>
<th>Size</th>
<th>Type</th>
<th>Owner</th>
<th>Upload Time</th>
</tr>
</thead>
<tbody id="recent-uploads-table">
<!-- Dynamic content -->
</tbody>
</table>
</div>
</div>
<!-- Users Section -->
<div id="users-section" class="admin-section">
<h2>User Management</h2>
<div class="admin-controls">
<button class="action-btn" onclick="refreshUsers()">↻ Refresh</button>
<button class="action-btn" onclick="exportUsers()">📥 Export</button>
</div>
<div class="admin-table">
<table>
<thead>
<tr>
<th>Public Key</th>
<th>Display Name</th>
<th>Files</th>
<th>Storage</th>
<th>Last Login</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="users-table">
<!-- Dynamic content -->
</tbody>
</table>
</div>
</div>
<!-- Files Section -->
<div id="files-section" class="admin-section">
<h2>File Management</h2>
<div class="admin-controls">
<select id="file-storage-filter">
<option value="">All Storage Types</option>
<option value="blob">Blobs</option>
<option value="torrent">Torrents</option>
</select>
<select id="file-access-filter">
<option value="">All Access Levels</option>
<option value="public">Public</option>
<option value="private">Private</option>
</select>
<button class="action-btn" onclick="refreshFiles()">↻ Refresh</button>
<button class="action-btn danger" onclick="bulkDeleteFiles()">🗑 Bulk Delete</button>
</div>
<div class="admin-table">
<table>
<thead>
<tr>
<th><input type="checkbox" id="select-all-files" onchange="toggleSelectAll()"></th>
<th>Name</th>
<th>Hash</th>
<th>Size</th>
<th>Type</th>
<th>Access</th>
<th>Owner</th>
<th>Reports</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="files-table">
<!-- Dynamic content -->
</tbody>
</table>
</div>
</div>
<!-- Reports Section -->
<div id="reports-section" class="admin-section">
<h2>Content Reports</h2>
<div class="admin-controls">
<select id="report-status-filter">
<option value="">All Reports</option>
<option value="pending">Pending</option>
<option value="resolved">Resolved</option>
<option value="dismissed">Dismissed</option>
</select>
<button class="action-btn" onclick="refreshReports()">↻ Refresh</button>
</div>
<div class="admin-table">
<table>
<thead>
<tr>
<th>ID</th>
<th>File</th>
<th>Reporter</th>
<th>Reason</th>
<th>Status</th>
<th>Date</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="reports-table">
<!-- Dynamic content -->
</tbody>
</table>
</div>
</div>
<!-- Cleanup Section -->
<div id="cleanup-section" class="admin-section">
<h2>System Cleanup</h2>
<div class="admin-form">
<h3>Cleanup Operations</h3>
<div class="form-group">
<label>Operation Type:</label>
<select id="cleanup-operation">
<option value="old_files">Remove Old Files</option>
<option value="orphaned_chunks">Clean Orphaned Chunks</option>
<option value="inactive_users">Remove Inactive Users</option>
</select>
</div>
<div class="form-group">
<label>Max Age / Days:</label>
<input type="text" id="cleanup-age" placeholder="e.g., 90d or 365 (days)">
</div>
<button class="action-btn danger" onclick="executeCleanup()">🧹 Execute Cleanup</button>
</div>
<div id="cleanup-results" class="admin-table" style="display: none;">
<h3>Cleanup Results</h3>
<div id="cleanup-output"></div>
</div>
</div>
<!-- Logs Section -->
<div id="logs-section" class="admin-section">
<h2>Admin Action Log</h2>
<div class="admin-controls">
<button class="action-btn" onclick="refreshLogs()">↻ Refresh</button>
<button class="action-btn" onclick="exportLogs()">📥 Export</button>
</div>
<div class="admin-table">
<table>
<thead>
<tr>
<th>ID</th>
<th>Admin</th>
<th>Action</th>
<th>Target</th>
<th>Reason</th>
<th>Timestamp</th>
</tr>
</thead>
<tbody id="logs-table">
<!-- Dynamic content -->
</tbody>
</table>
</div>
</div>
</main>
<!-- Admin Login Form -->
<div id="admin-login" class="modal" style="position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); display: flex; justify-content: center; align-items: center;">
<div class="admin-form" style="max-width: 500px; width: 90%;">
<h2>Admin Access Required</h2>
<p>Please authenticate with your admin Nostr key to access the admin dashboard.</p>
<button id="admin-nip07-login" class="login-btn">
Login with Browser Extension (NIP-07)
</button>
<div id="admin-login-status" class="status" style="display: none;"></div>
</div>
</div>
</div>
<div id="toast-container" class="toast-container"></div>
<!-- Ban User Modal -->
<div id="ban-modal" class="modal" style="display: none;">
<div class="modal-content">
<span class="close" onclick="hideBanModal()">&times;</span>
<h2>Ban User</h2>
<div class="form-group">
<label>User Public Key:</label>
<input type="text" id="ban-user-pubkey" readonly>
</div>
<div class="form-group">
<label>Reason for Ban:</label>
<textarea id="ban-reason" placeholder="Enter reason for banning this user..."></textarea>
</div>
<div class="modal-actions">
<button class="action-btn danger" onclick="confirmBanUser()">Ban User</button>
<button class="action-btn" onclick="hideBanModal()">Cancel</button>
</div>
</div>
</div>
<script src="/static/nostr-auth.js"></script>
<script>
let adminUser = null;
let currentAdminSection = 'overview';
// Admin authentication
async function checkAdminAuth() {
if (!window.nostrAuth || !window.nostrAuth.isAuthenticated()) {
showAdminLogin();
return false;
}
// Check if user is admin by trying to access admin stats
try {
const response = await fetch('/api/admin/stats', {
credentials: 'include',
headers: {
'Authorization': `Bearer ${window.nostrAuth.sessionToken}`
}
});
if (response.ok) {
adminUser = window.nostrAuth.getCurrentUser();
showAdminDashboard();
return true;
} else {
showAdminLogin();
return false;
}
} catch (error) {
showAdminLogin();
return false;
}
}
function showAdminLogin() {
document.getElementById('admin-login').style.display = 'block';
document.getElementById('admin-content').style.display = 'none';
}
async function showAdminDashboard() {
document.getElementById('admin-login').style.display = 'none';
document.getElementById('admin-content').style.display = 'block';
document.getElementById('admin-logout-btn').style.display = 'block';
// Set initial fallback display
document.getElementById('admin-user-info').textContent = `Admin: ${adminUser.substring(0, 8)}...`;
// Fetch admin profile information
try {
const response = await fetch(`/api/profile/${adminUser}`);
if (response.ok) {
const data = await response.json();
if (data.success && data.profile) {
const profile = data.profile;
const displayName = profile.display_name || profile.name || (adminUser.substring(0, 8) + '...');
if (profile.picture) {
document.getElementById('admin-user-info').innerHTML = `
<img src="${profile.picture}" style="width: 24px; height: 24px; border-radius: 50%; margin-right: 8px; vertical-align: middle;">
Admin: ${displayName}
`;
} else {
document.getElementById('admin-user-info').textContent = `Admin: ${displayName}`;
}
}
}
} catch (error) {
console.log('Could not fetch admin profile, using fallback');
}
loadAdminStats();
}
async function adminLogout() {
if (window.nostrAuth) {
await window.nostrAuth.logout();
adminUser = null;
showAdminLogin();
}
}
// Section navigation
function showAdminSection(section) {
currentAdminSection = section;
// Update navigation
document.querySelectorAll('.admin-nav-btn').forEach(btn => btn.classList.remove('active'));
event.target.classList.add('active');
// Show section
document.querySelectorAll('.admin-section').forEach(sec => sec.classList.remove('active'));
document.getElementById(section + '-section').classList.add('active');
// Load section data
switch (section) {
case 'overview': loadAdminStats(); break;
case 'users': loadUsers(); break;
case 'files': loadFiles(); break;
case 'reports': loadReports(); break;
case 'logs': loadLogs(); break;
}
}
// Admin data loading functions
async function loadAdminStats() {
// Load stats cards
try {
const response = await makeAdminRequest('/api/admin/stats');
const data = await response.json();
if (!response.ok || data.success === false) {
showToast('Failed to load admin stats: ' + (data.error || 'Unknown error'), 'error');
} else {
const stats = data;
const statsGrid = document.getElementById('admin-stats');
statsGrid.innerHTML = `
<div class="stat-card">
<div class="stat-number">${stats.total_files}</div>
<div class="stat-label">Total Files</div>
</div>
<div class="stat-card">
<div class="stat-number">${stats.total_users}</div>
<div class="stat-label">Total Users</div>
</div>
<div class="stat-card">
<div class="stat-number">${formatBytes(stats.total_storage)}</div>
<div class="stat-label">Total Storage</div>
</div>
<div class="stat-card">
<div class="stat-number">${stats.banned_users}</div>
<div class="stat-label">Banned Users</div>
</div>
<div class="stat-card">
<div class="stat-number">${stats.pending_reports}</div>
<div class="stat-label">Pending Reports</div>
</div>
`;
}
} catch (error) {
showToast('Failed to load admin stats: ' + error.message, 'error');
}
// Load recent uploads table
try {
const response = await makeAdminRequest('/api/admin/files?limit=20');
const data = await response.json();
if (!response.ok || data.success === false) {
showToast('Failed to load recent uploads: ' + (data.error || 'Unknown error'), 'error');
return;
}
const files = Array.isArray(data) ? data : [];
const tbody = document.getElementById('recent-uploads-table');
tbody.innerHTML = files.map(file => {
const ownerName = file.owner_profile?.display_name || file.owner_profile?.name ||
(file.owner_pubkey ? file.owner_pubkey.substring(0, 8) + '...' : 'System');
const ownerPic = file.owner_profile?.picture ?
`<img src="${file.owner_profile.picture}" style="width: 20px; height: 20px; border-radius: 50%; margin-right: 6px;">` : '';
return `
<tr>
<td>${file.name}</td>
<td>${formatBytes(file.size)}</td>
<td>${file.storage_type}</td>
<td>${ownerPic}${ownerName}</td>
<td>${new Date(file.created_at).toLocaleDateString()}</td>
</tr>
`;
}).join('');
} catch (error) {
showToast('Failed to load recent uploads: ' + error.message, 'error');
}
}
async function loadUsers() {
try {
const response = await makeAdminRequest('/api/admin/users');
const data = await response.json();
if (!response.ok || data.success === false) {
showToast('Failed to load users: ' + (data.error || 'Unknown error'), 'error');
return;
}
const users = Array.isArray(data) ? data : [];
const tbody = document.getElementById('users-table');
tbody.innerHTML = users.map(user => {
const displayName = user.profile?.display_name || user.profile?.name || user.display_name || 'Anonymous';
const profilePic = user.profile?.picture ? `<img src="${user.profile.picture}" style="width: 24px; height: 24px; border-radius: 50%; margin-right: 8px;">` : '';
return `
<tr>
<td class="hash-short">${user.pubkey.substring(0, 16)}...</td>
<td>${profilePic}${displayName}</td>
<td>${user.file_count}</td>
<td>${formatBytes(user.storage_used)}</td>
<td>${new Date(user.last_login).toLocaleDateString()}</td>
<td>
<span class="status-badge ${user.is_banned ? 'banned' : 'active'}">
${user.is_banned ? 'Banned' : 'Active'}
</span>
</td>
<td>
${user.is_banned
? `<button class="action-btn" onclick="unbanUser('${user.pubkey}')">Unban</button>`
: `<button class="action-btn danger" onclick="banUser('${user.pubkey}')">Ban</button>`
}
</td>
</tr>
`;
}).join('');
} catch (error) {
showToast('Failed to load users: ' + error.message, 'error');
}
}
async function loadFiles() {
try {
const storageFilter = document.getElementById('file-storage-filter').value;
const accessFilter = document.getElementById('file-access-filter').value;
let url = '/api/admin/files?limit=50';
if (storageFilter) url += `&storage_type=${storageFilter}`;
if (accessFilter) url += `&access_level=${accessFilter}`;
const response = await makeAdminRequest(url);
const data = await response.json();
if (!response.ok || data.success === false) {
showToast('Failed to load files: ' + (data.error || 'Unknown error'), 'error');
return;
}
const files = Array.isArray(data) ? data : [];
const tbody = document.getElementById('files-table');
tbody.innerHTML = files.map(file => {
const ownerName = file.owner_profile?.display_name || file.owner_profile?.name ||
(file.owner_pubkey ? file.owner_pubkey.substring(0, 8) + '...' : 'System');
const ownerPic = file.owner_profile?.picture ?
`<img src="${file.owner_profile.picture}" style="width: 20px; height: 20px; border-radius: 50%; margin-right: 6px;">` : '';
return `
<tr>
<td><input type="checkbox" class="file-select" value="${file.hash}"></td>
<td>${file.name}</td>
<td class="hash-short">${file.hash.substring(0, 12)}...</td>
<td>${formatBytes(file.size)}</td>
<td>${file.storage_type}</td>
<td>
<span class="status-badge ${file.access_level === 'private' ? 'banned' : 'active'}">
${file.access_level}
</span>
</td>
<td>${ownerPic}${ownerName}</td>
<td>${file.report_count > 0 ? `<span class="status-badge pending">${file.report_count}</span>` : '0'}</td>
<td>
<button class="action-btn danger" onclick="deleteFile('${file.hash}', '${file.name}')">Delete</button>
</td>
</tr>
`;
}).join('');
} catch (error) {
showToast('Failed to load files: ' + error.message, 'error');
}
}
async function loadReports() {
try {
const statusFilter = document.getElementById('report-status-filter').value;
let url = '/api/admin/reports?limit=50';
if (statusFilter) url += `&status=${statusFilter}`;
const response = await makeAdminRequest(url);
const data = await response.json();
if (!response.ok || data.success === false) {
showToast('Failed to load reports: ' + (data.error || 'Unknown error'), 'error');
return;
}
const reports = Array.isArray(data) ? data : [];
const tbody = document.getElementById('reports-table');
tbody.innerHTML = reports.map(report => `
<tr>
<td>${report.id}</td>
<td>${report.file_name || 'Unknown'}</td>
<td class="hash-short">${report.reporter_pubkey.substring(0, 8)}...</td>
<td>${report.reason}</td>
<td>
<span class="status-badge ${report.status}">
${report.status}
</span>
</td>
<td>${new Date(report.created_at).toLocaleDateString()}</td>
<td>
<button class="action-btn danger" onclick="deleteReportedFile('${report.file_hash}')">Delete File</button>
<button class="action-btn" onclick="dismissReport(${report.id})">Dismiss</button>
</td>
</tr>
`).join('');
} catch (error) {
showToast('Failed to load reports: ' + error.message, 'error');
}
}
async function loadLogs() {
try {
const response = await makeAdminRequest('/api/admin/logs?limit=100');
const logs = await response.json();
const tbody = document.getElementById('logs-table');
tbody.innerHTML = logs.map(log => `
<tr>
<td>${log.id}</td>
<td class="hash-short">${log.admin_pubkey.substring(0, 8)}...</td>
<td>${log.action_type}</td>
<td class="hash-short">${log.target_id ? log.target_id.substring(0, 12) + '...' : '-'}</td>
<td>${log.reason || '-'}</td>
<td>${new Date(log.timestamp).toLocaleString()}</td>
</tr>
`).join('');
} catch (error) {
showToast('Failed to load logs: ' + error.message, 'error');
}
}
// Admin actions
function banUser(pubkey) {
document.getElementById('ban-user-pubkey').value = pubkey;
document.getElementById('ban-modal').style.display = 'block';
}
function hideBanModal() {
document.getElementById('ban-modal').style.display = 'none';
document.getElementById('ban-reason').value = '';
}
async function confirmBanUser() {
const pubkey = document.getElementById('ban-user-pubkey').value;
const reason = document.getElementById('ban-reason').value;
if (!reason.trim()) {
showToast('Please provide a reason for the ban', 'error');
return;
}
try {
const response = await makeAdminRequest(`/api/admin/users/${pubkey}/ban`, {
method: 'POST',
body: JSON.stringify({ reason: reason })
});
if (response.ok) {
showToast('User banned successfully', 'success');
hideBanModal();
loadUsers();
} else {
const error = await response.text();
showToast('Failed to ban user: ' + error, 'error');
}
} catch (error) {
showToast('Error banning user: ' + error.message, 'error');
}
}
async function unbanUser(pubkey) {
const reason = prompt('Reason for unbanning (optional):');
try {
const response = await makeAdminRequest(`/api/admin/users/${pubkey}/unban`, {
method: 'POST',
body: JSON.stringify({ reason: reason || 'Admin unban' })
});
if (response.ok) {
showToast('User unbanned successfully', 'success');
loadUsers();
} else {
const error = await response.text();
showToast('Failed to unban user: ' + error, 'error');
}
} catch (error) {
showToast('Error unbanning user: ' + error.message, 'error');
}
}
async function deleteFile(hash, name) {
const reason = prompt(`Reason for deleting "${name}":`);
if (!reason) return;
try {
const response = await makeAdminRequest(`/api/admin/files/${hash}`, {
method: 'DELETE',
body: JSON.stringify({ reason: reason })
});
if (response.ok) {
showToast('File deleted successfully', 'success');
loadFiles();
} else {
const error = await response.text();
showToast('Failed to delete file: ' + error, 'error');
}
} catch (error) {
showToast('Error deleting file: ' + error.message, 'error');
}
}
async function executeCleanup() {
const operation = document.getElementById('cleanup-operation').value;
const maxAge = document.getElementById('cleanup-age').value;
if (!confirm(`Are you sure you want to execute cleanup operation: ${operation}?`)) {
return;
}
try {
const response = await makeAdminRequest('/api/admin/cleanup', {
method: 'POST',
body: JSON.stringify({
operation: operation,
max_age: maxAge
})
});
const result = await response.json();
if (response.ok) {
const resultsDiv = document.getElementById('cleanup-results');
const outputDiv = document.getElementById('cleanup-output');
outputDiv.innerHTML = `
<p><strong>Operation:</strong> ${result.operation}</p>
<p><strong>Items Deleted:</strong> ${result.result.deleted_count}</p>
<pre>${JSON.stringify(result.result, null, 2)}</pre>
`;
resultsDiv.style.display = 'block';
showToast('Cleanup completed successfully', 'success');
} else {
showToast('Cleanup failed: ' + result.message, 'error');
}
} catch (error) {
showToast('Error executing cleanup: ' + error.message, 'error');
}
}
// Utility functions
async function makeAdminRequest(url, options = {}) {
const defaultOptions = {
credentials: 'include',
headers: {
'Authorization': `Bearer ${window.nostrAuth.sessionToken}`,
'Content-Type': 'application/json',
...options.headers
}
};
return fetch(url, { ...defaultOptions, ...options });
}
function refreshUsers() { loadUsers(); }
function refreshFiles() { loadFiles(); }
function refreshReports() { loadReports(); }
function refreshLogs() { loadLogs(); }
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => {
toast.remove();
}, 5000);
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
// Admin login event listener
document.getElementById('admin-nip07-login').addEventListener('click', async () => {
const result = await window.nostrAuth.loginNIP07();
if (result.success) {
checkAdminAuth();
} else {
document.getElementById('admin-login-status').textContent = result.message;
document.getElementById('admin-login-status').className = 'status error';
document.getElementById('admin-login-status').style.display = 'block';
}
});
// File filter change listeners
document.getElementById('file-storage-filter').addEventListener('change', loadFiles);
document.getElementById('file-access-filter').addEventListener('change', loadFiles);
document.getElementById('report-status-filter').addEventListener('change', loadReports);
// Check admin auth on load
checkAdminAuth();
});
// Close modals when clicking outside
window.addEventListener('click', (event) => {
const banModal = document.getElementById('ban-modal');
if (event.target === banModal) {
hideBanModal();
}
});
</script>
</body>
</html>