/**
* 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');
}
}
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();
});