/** * Golf Admin Dashboard * JavaScript for admin interface functionality */ // State let authToken = null; let currentUser = null; let currentPanel = 'dashboard'; let selectedUserId = null; // Pagination state let usersPage = 0; let auditPage = 0; const PAGE_SIZE = 20; // ============================================================================= // API Functions // ============================================================================= async function apiRequest(endpoint, options = {}) { const headers = { 'Content-Type': 'application/json', ...options.headers, }; if (authToken) { headers['Authorization'] = `Bearer ${authToken}`; } const response = await fetch(endpoint, { ...options, headers, }); if (response.status === 401) { // Unauthorized - clear auth and show login logout(); throw new Error('Session expired. Please login again.'); } if (response.status === 403) { throw new Error('Admin access required'); } const data = await response.json(); if (!response.ok) { throw new Error(data.detail || 'Request failed'); } return data; } // Auth API async function login(username, password) { const data = await apiRequest('/api/auth/login', { method: 'POST', body: JSON.stringify({ username, password }), }); return data; } // Admin API async function getStats() { return apiRequest('/api/admin/stats'); } async function getUsers(query = '', offset = 0, includeBanned = true) { const params = new URLSearchParams({ query, offset, limit: PAGE_SIZE, include_banned: includeBanned, }); return apiRequest(`/api/admin/users?${params}`); } async function getUser(userId) { return apiRequest(`/api/admin/users/${userId}`); } async function getUserBanHistory(userId) { return apiRequest(`/api/admin/users/${userId}/ban-history`); } async function banUser(userId, reason, durationDays) { return apiRequest(`/api/admin/users/${userId}/ban`, { method: 'POST', body: JSON.stringify({ reason, duration_days: durationDays || null, }), }); } async function unbanUser(userId) { return apiRequest(`/api/admin/users/${userId}/unban`, { method: 'POST', }); } async function forcePasswordReset(userId) { return apiRequest(`/api/admin/users/${userId}/force-password-reset`, { method: 'POST', }); } async function changeUserRole(userId, role) { return apiRequest(`/api/admin/users/${userId}/role`, { method: 'PUT', body: JSON.stringify({ role }), }); } async function impersonateUser(userId) { return apiRequest(`/api/admin/users/${userId}/impersonate`, { method: 'POST', }); } async function getGames() { return apiRequest('/api/admin/games'); } async function getGameDetails(gameId) { return apiRequest(`/api/admin/games/${gameId}`); } async function endGame(gameId, reason) { return apiRequest(`/api/admin/games/${gameId}/end`, { method: 'POST', body: JSON.stringify({ reason }), }); } async function getInvites(includeExpired = false) { const params = new URLSearchParams({ include_expired: includeExpired }); return apiRequest(`/api/admin/invites?${params}`); } async function createInvite(maxUses, expiresDays) { return apiRequest('/api/admin/invites', { method: 'POST', body: JSON.stringify({ max_uses: maxUses, expires_days: expiresDays, }), }); } async function revokeInvite(code) { return apiRequest(`/api/admin/invites/${code}`, { method: 'DELETE', }); } async function getAuditLog(offset = 0, action = '', targetType = '') { const params = new URLSearchParams({ offset, limit: PAGE_SIZE, }); if (action) params.append('action', action); if (targetType) params.append('target_type', targetType); return apiRequest(`/api/admin/audit?${params}`); } // ============================================================================= // UI Functions // ============================================================================= function showScreen(screenId) { document.querySelectorAll('.screen').forEach(s => s.classList.add('hidden')); document.getElementById(screenId).classList.remove('hidden'); } function showPanel(panelId) { currentPanel = panelId; document.querySelectorAll('.panel').forEach(p => p.classList.add('hidden')); document.getElementById(`${panelId}-panel`).classList.remove('hidden'); document.querySelectorAll('.nav-link').forEach(link => { link.classList.toggle('active', link.dataset.panel === panelId); }); // Load panel data switch (panelId) { case 'dashboard': loadDashboard(); break; case 'users': loadUsers(); break; case 'games': loadGames(); break; case 'invites': loadInvites(); break; case 'audit': loadAuditLog(); break; } } function showModal(modalId) { document.getElementById(modalId).classList.remove('hidden'); } function hideModal(modalId) { document.getElementById(modalId).classList.add('hidden'); } function hideAllModals() { document.querySelectorAll('.modal').forEach(m => m.classList.add('hidden')); } 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(); }, 4000); } function formatDate(isoString) { if (!isoString) return '-'; const date = new Date(isoString); return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } function formatDateShort(isoString) { if (!isoString) return '-'; const date = new Date(isoString); return date.toLocaleDateString(); } function getStatusBadge(user) { if (user.is_banned) { return 'Banned'; } if (!user.is_active) { return 'Inactive'; } if (user.force_password_reset) { return 'Reset Required'; } if (!user.email_verified && user.email) { return 'Unverified'; } return 'Active'; } // ============================================================================= // Data Loading // ============================================================================= async function loadDashboard() { try { const stats = await getStats(); document.getElementById('stat-active-users').textContent = stats.active_users_now; document.getElementById('stat-active-games').textContent = stats.active_games_now; document.getElementById('stat-total-users').textContent = stats.total_users; document.getElementById('stat-games-today').textContent = stats.games_today; document.getElementById('stat-reg-today').textContent = stats.registrations_today; document.getElementById('stat-reg-week').textContent = stats.registrations_week; document.getElementById('stat-total-games').textContent = stats.total_games_completed; document.getElementById('stat-events-hour').textContent = stats.events_last_hour; // Top players table const tbody = document.querySelector('#top-players-table tbody'); tbody.innerHTML = ''; stats.top_players.forEach((player, index) => { const winRate = player.games_played > 0 ? Math.round((player.games_won / player.games_played) * 100) : 0; tbody.innerHTML += ` ${index + 1} ${escapeHtml(player.username)} ${player.games_won} ${player.games_played} ${winRate}% `; }); if (stats.top_players.length === 0) { tbody.innerHTML = 'No players yet'; } } catch (error) { showToast('Failed to load dashboard: ' + error.message, 'error'); } } async function loadUsers() { try { const query = document.getElementById('user-search').value; const includeBanned = document.getElementById('include-banned').checked; const data = await getUsers(query, usersPage * PAGE_SIZE, includeBanned); const tbody = document.querySelector('#users-table tbody'); tbody.innerHTML = ''; data.users.forEach(user => { tbody.innerHTML += ` ${escapeHtml(user.username)} ${escapeHtml(user.email || '-')} ${user.role} ${getStatusBadge(user)} ${user.games_played} (${user.games_won} wins) ${formatDateShort(user.created_at)} `; }); if (data.users.length === 0) { tbody.innerHTML = 'No users found'; } // Update pagination document.getElementById('users-page-info').textContent = `Page ${usersPage + 1}`; document.getElementById('users-prev').disabled = usersPage === 0; document.getElementById('users-next').disabled = data.users.length < PAGE_SIZE; } catch (error) { showToast('Failed to load users: ' + error.message, 'error'); } } async function viewUser(userId) { try { selectedUserId = userId; const user = await getUser(userId); const history = await getUserBanHistory(userId); // Populate details document.getElementById('detail-username').textContent = user.username; document.getElementById('detail-email').textContent = user.email || '-'; document.getElementById('detail-role').textContent = user.role; document.getElementById('detail-status').innerHTML = getStatusBadge(user); document.getElementById('detail-games-played').textContent = user.games_played; document.getElementById('detail-games-won').textContent = user.games_won; document.getElementById('detail-joined').textContent = formatDate(user.created_at); document.getElementById('detail-last-login').textContent = formatDate(user.last_login); // Update action buttons visibility document.getElementById('action-ban').classList.toggle('hidden', user.is_banned); document.getElementById('action-unban').classList.toggle('hidden', !user.is_banned); document.getElementById('action-make-admin').classList.toggle('hidden', user.role === 'admin'); document.getElementById('action-remove-admin').classList.toggle('hidden', user.role !== 'admin'); // Ban history const historyBody = document.querySelector('#ban-history-table tbody'); historyBody.innerHTML = ''; history.history.forEach(ban => { const status = ban.unbanned_at ? `Unbanned` : (ban.expires_at && new Date(ban.expires_at) < new Date() ? `Expired` : `Active`); historyBody.innerHTML += ` ${formatDateShort(ban.banned_at)} ${escapeHtml(ban.reason || '-')} ${escapeHtml(ban.banned_by)} ${status} `; }); if (history.history.length === 0) { historyBody.innerHTML = 'No ban history'; } showModal('user-modal'); } catch (error) { showToast('Failed to load user: ' + error.message, 'error'); } } async function loadGames() { try { const data = await getGames(); const tbody = document.querySelector('#games-table tbody'); tbody.innerHTML = ''; data.games.forEach(game => { tbody.innerHTML += ` ${escapeHtml(game.room_code)} ${game.player_count} ${game.phase || game.status || '-'} ${game.current_round || '-'} ${game.status} ${formatDate(game.created_at)} `; }); if (data.games.length === 0) { tbody.innerHTML = 'No active games'; } } catch (error) { showToast('Failed to load games: ' + error.message, 'error'); } } let selectedGameId = null; function promptEndGame(gameId) { selectedGameId = gameId; document.getElementById('end-game-reason').value = ''; showModal('end-game-modal'); } async function loadInvites() { try { const includeExpired = document.getElementById('include-expired').checked; const data = await getInvites(includeExpired); const tbody = document.querySelector('#invites-table tbody'); tbody.innerHTML = ''; data.codes.forEach(invite => { const isExpired = new Date(invite.expires_at) < new Date(); const status = !invite.is_active ? 'Revoked' : isExpired ? 'Expired' : invite.remaining_uses <= 0 ? 'Used Up' : 'Active'; tbody.innerHTML += ` ${escapeHtml(invite.code)} ${invite.use_count} / ${invite.max_uses} ${invite.remaining_uses} ${escapeHtml(invite.created_by_username)} ${formatDate(invite.expires_at)} ${status} ${invite.is_active && !isExpired && invite.remaining_uses > 0 ? ` ` : '-' } `; }); if (data.codes.length === 0) { tbody.innerHTML = 'No invite codes'; } } catch (error) { showToast('Failed to load invites: ' + error.message, 'error'); } } async function loadAuditLog() { try { const action = document.getElementById('audit-action-filter').value; const targetType = document.getElementById('audit-target-filter').value; const data = await getAuditLog(auditPage * PAGE_SIZE, action, targetType); const tbody = document.querySelector('#audit-table tbody'); tbody.innerHTML = ''; data.entries.forEach(entry => { const details = Object.keys(entry.details).length > 0 ? `${escapeHtml(JSON.stringify(entry.details))}` : '-'; tbody.innerHTML += ` ${formatDate(entry.created_at)} ${escapeHtml(entry.admin_username)} ${entry.action} ${entry.target_type ? `${entry.target_type}: ${entry.target_id || '-'}` : '-'} ${details} ${entry.ip_address || '-'} `; }); if (data.entries.length === 0) { tbody.innerHTML = 'No audit entries'; } // Update pagination document.getElementById('audit-page-info').textContent = `Page ${auditPage + 1}`; document.getElementById('audit-prev').disabled = auditPage === 0; document.getElementById('audit-next').disabled = data.entries.length < PAGE_SIZE; } catch (error) { showToast('Failed to load audit log: ' + error.message, 'error'); } } // ============================================================================= // Actions // ============================================================================= async function handleBanUser(event) { event.preventDefault(); const reason = document.getElementById('ban-reason').value; const duration = document.getElementById('ban-duration').value; try { await banUser(selectedUserId, reason, duration ? parseInt(duration) : null); showToast('User banned successfully', 'success'); hideAllModals(); loadUsers(); } catch (error) { showToast('Failed to ban user: ' + error.message, 'error'); } } async function handleUnbanUser() { if (!confirm('Are you sure you want to unban this user?')) return; try { await unbanUser(selectedUserId); showToast('User unbanned successfully', 'success'); hideAllModals(); loadUsers(); } catch (error) { showToast('Failed to unban user: ' + error.message, 'error'); } } async function handleForcePasswordReset() { if (!confirm('Are you sure you want to force a password reset for this user? They will be logged out.')) return; try { await forcePasswordReset(selectedUserId); showToast('Password reset required for user', 'success'); hideAllModals(); loadUsers(); } catch (error) { showToast('Failed to force password reset: ' + error.message, 'error'); } } async function handleMakeAdmin() { if (!confirm('Are you sure you want to make this user an admin?')) return; try { await changeUserRole(selectedUserId, 'admin'); showToast('User is now an admin', 'success'); hideAllModals(); loadUsers(); } catch (error) { showToast('Failed to change role: ' + error.message, 'error'); } } async function handleRemoveAdmin() { if (!confirm('Are you sure you want to remove admin privileges from this user?')) return; try { await changeUserRole(selectedUserId, 'user'); showToast('Admin privileges removed', 'success'); hideAllModals(); loadUsers(); } catch (error) { showToast('Failed to change role: ' + error.message, 'error'); } } async function handleImpersonate() { try { const data = await impersonateUser(selectedUserId); showToast(`Viewing as ${data.user.username} (read-only). Check console for details.`, 'success'); console.log('Impersonation data:', data); } catch (error) { showToast('Failed to impersonate: ' + error.message, 'error'); } } async function handleEndGame(event) { event.preventDefault(); const reason = document.getElementById('end-game-reason').value; try { await endGame(selectedGameId, reason); showToast('Game ended successfully', 'success'); hideAllModals(); loadGames(); } catch (error) { showToast('Failed to end game: ' + error.message, 'error'); } } async function handleCreateInvite() { const maxUses = parseInt(document.getElementById('invite-max-uses').value) || 1; const expiresDays = parseInt(document.getElementById('invite-expires-days').value) || 7; try { const data = await createInvite(maxUses, expiresDays); showToast(`Invite code created: ${data.code}`, 'success'); loadInvites(); } catch (error) { showToast('Failed to create invite: ' + error.message, 'error'); } } function copyInviteLink(code) { const link = `${window.location.origin}/?invite=${encodeURIComponent(code)}`; navigator.clipboard.writeText(link).then(() => { showToast('Invite link copied!', 'success'); }).catch(() => { // Fallback: select text for manual copy prompt('Copy this link:', link); }); } async function promptRevokeInvite(code) { if (!confirm(`Are you sure you want to revoke invite code ${code}?`)) return; try { await revokeInvite(code); showToast('Invite code revoked', 'success'); loadInvites(); } catch (error) { showToast('Failed to revoke invite: ' + error.message, 'error'); } } // ============================================================================= // Auth // ============================================================================= async function handleLogin(event) { event.preventDefault(); const username = document.getElementById('username').value; const password = document.getElementById('password').value; const errorEl = document.getElementById('login-error'); try { const data = await login(username, password); // Check if user is admin if (data.user.role !== 'admin') { errorEl.textContent = 'Admin access required'; return; } // Store auth authToken = data.token; currentUser = data.user; localStorage.setItem('adminToken', data.token); localStorage.setItem('adminUser', JSON.stringify(data.user)); // Show dashboard document.getElementById('admin-username').textContent = currentUser.username; showScreen('dashboard-screen'); showPanel('dashboard'); } catch (error) { errorEl.textContent = error.message; } } function logout() { authToken = null; currentUser = null; localStorage.removeItem('adminToken'); localStorage.removeItem('adminUser'); showScreen('login-screen'); } function checkAuth() { const savedToken = localStorage.getItem('adminToken'); const savedUser = localStorage.getItem('adminUser'); if (savedToken && savedUser) { authToken = savedToken; currentUser = JSON.parse(savedUser); if (currentUser.role === 'admin') { document.getElementById('admin-username').textContent = currentUser.username; showScreen('dashboard-screen'); showPanel('dashboard'); return; } } showScreen('login-screen'); } // ============================================================================= // Utilities // ============================================================================= function escapeHtml(text) { if (!text) return ''; const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } // ============================================================================= // Event Listeners // ============================================================================= document.addEventListener('DOMContentLoaded', () => { // Login form document.getElementById('login-form').addEventListener('submit', handleLogin); // Logout button document.getElementById('logout-btn').addEventListener('click', logout); // Navigation document.querySelectorAll('.nav-link').forEach(link => { link.addEventListener('click', (e) => { e.preventDefault(); showPanel(link.dataset.panel); }); }); // Users panel document.getElementById('user-search-btn').addEventListener('click', () => { usersPage = 0; loadUsers(); }); document.getElementById('user-search').addEventListener('keypress', (e) => { if (e.key === 'Enter') { usersPage = 0; loadUsers(); } }); document.getElementById('include-banned').addEventListener('change', () => { usersPage = 0; loadUsers(); }); document.getElementById('users-prev').addEventListener('click', () => { if (usersPage > 0) { usersPage--; loadUsers(); } }); document.getElementById('users-next').addEventListener('click', () => { usersPage++; loadUsers(); }); // User modal actions document.getElementById('action-ban').addEventListener('click', () => { document.getElementById('ban-reason').value = ''; document.getElementById('ban-duration').value = ''; showModal('ban-modal'); }); document.getElementById('action-unban').addEventListener('click', handleUnbanUser); document.getElementById('action-reset-pw').addEventListener('click', handleForcePasswordReset); document.getElementById('action-make-admin').addEventListener('click', handleMakeAdmin); document.getElementById('action-remove-admin').addEventListener('click', handleRemoveAdmin); document.getElementById('action-impersonate').addEventListener('click', handleImpersonate); // Ban form document.getElementById('ban-form').addEventListener('submit', handleBanUser); // Games panel document.getElementById('refresh-games-btn').addEventListener('click', loadGames); // End game form document.getElementById('end-game-form').addEventListener('submit', handleEndGame); // Invites panel document.getElementById('create-invite-btn').addEventListener('click', handleCreateInvite); document.getElementById('include-expired').addEventListener('change', loadInvites); // Audit panel document.getElementById('audit-filter-btn').addEventListener('click', () => { auditPage = 0; loadAuditLog(); }); document.getElementById('audit-prev').addEventListener('click', () => { if (auditPage > 0) { auditPage--; loadAuditLog(); } }); document.getElementById('audit-next').addEventListener('click', () => { auditPage++; loadAuditLog(); }); // Modal close buttons document.querySelectorAll('.modal-close').forEach(btn => { btn.addEventListener('click', hideAllModals); }); // Close modal on overlay click document.querySelectorAll('.modal').forEach(modal => { modal.addEventListener('click', (e) => { if (e.target === modal) { hideAllModals(); } }); }); // Check auth on load checkAuth(); });