golfgame/client/admin.js
Aaron D. Lee bea85e6b28 Huge v2 uplift, now deployable with real user management and tooling!
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 11:32:15 -05:00

810 lines
27 KiB
JavaScript

/**
* 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 '<span class="badge badge-danger">Banned</span>';
}
if (!user.is_active) {
return '<span class="badge badge-muted">Inactive</span>';
}
if (user.force_password_reset) {
return '<span class="badge badge-warning">Reset Required</span>';
}
if (!user.email_verified && user.email) {
return '<span class="badge badge-warning">Unverified</span>';
}
return '<span class="badge badge-success">Active</span>';
}
// =============================================================================
// 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 += `
<tr>
<td>${index + 1}</td>
<td>${escapeHtml(player.username)}</td>
<td>${player.games_won}</td>
<td>${player.games_played}</td>
<td>${winRate}%</td>
</tr>
`;
});
if (stats.top_players.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="text-muted">No players yet</td></tr>';
}
} 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 += `
<tr>
<td>${escapeHtml(user.username)}</td>
<td>${escapeHtml(user.email || '-')}</td>
<td><span class="badge badge-${user.role === 'admin' ? 'info' : 'muted'}">${user.role}</span></td>
<td>${getStatusBadge(user)}</td>
<td>${user.games_played} (${user.games_won} wins)</td>
<td>${formatDateShort(user.created_at)}</td>
<td>
<button class="btn btn-small" onclick="viewUser('${user.id}')">View</button>
</td>
</tr>
`;
});
if (data.users.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-muted">No users found</td></tr>';
}
// 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
? `<span class="badge badge-success">Unbanned</span>`
: (ban.expires_at && new Date(ban.expires_at) < new Date()
? `<span class="badge badge-muted">Expired</span>`
: `<span class="badge badge-danger">Active</span>`);
historyBody.innerHTML += `
<tr>
<td>${formatDateShort(ban.banned_at)}</td>
<td>${escapeHtml(ban.reason || '-')}</td>
<td>${escapeHtml(ban.banned_by)}</td>
<td>${status}</td>
</tr>
`;
});
if (history.history.length === 0) {
historyBody.innerHTML = '<tr><td colspan="4" class="text-muted">No ban history</td></tr>';
}
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 += `
<tr>
<td><strong>${escapeHtml(game.room_code)}</strong></td>
<td>${game.player_count}</td>
<td>${game.phase || game.status || '-'}</td>
<td>${game.current_round || '-'}</td>
<td><span class="badge badge-${game.status === 'playing' ? 'success' : 'info'}">${game.status}</span></td>
<td>${formatDate(game.created_at)}</td>
<td>
<button class="btn btn-small btn-danger" onclick="promptEndGame('${game.game_id}')">End</button>
</td>
</tr>
`;
});
if (data.games.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-muted">No active games</td></tr>';
}
} 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
? '<span class="badge badge-danger">Revoked</span>'
: isExpired
? '<span class="badge badge-muted">Expired</span>'
: invite.remaining_uses <= 0
? '<span class="badge badge-warning">Used Up</span>'
: '<span class="badge badge-success">Active</span>';
tbody.innerHTML += `
<tr>
<td><code>${escapeHtml(invite.code)}</code></td>
<td>${invite.use_count} / ${invite.max_uses}</td>
<td>${invite.remaining_uses}</td>
<td>${escapeHtml(invite.created_by_username)}</td>
<td>${formatDate(invite.expires_at)}</td>
<td>${status}</td>
<td>
${invite.is_active && !isExpired && invite.remaining_uses > 0
? `<button class="btn btn-small btn-danger" onclick="promptRevokeInvite('${invite.code}')">Revoke</button>`
: '-'
}
</td>
</tr>
`;
});
if (data.codes.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-muted">No invite codes</td></tr>';
}
} 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
? `<code class="text-small">${escapeHtml(JSON.stringify(entry.details))}</code>`
: '-';
tbody.innerHTML += `
<tr>
<td>${formatDate(entry.created_at)}</td>
<td>${escapeHtml(entry.admin_username)}</td>
<td><span class="badge badge-info">${entry.action}</span></td>
<td>${entry.target_type ? `${entry.target_type}: ${entry.target_id || '-'}` : '-'}</td>
<td>${details}</td>
<td class="text-muted text-small">${entry.ip_address || '-'}</td>
</tr>
`;
});
if (data.entries.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="text-muted">No audit entries</td></tr>';
}
// 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();
});