35 KiB
35 KiB
V2-04: Admin Tools & Moderation
Overview
This document covers admin capabilities: user management, game moderation, system monitoring, and audit logging.
Dependencies: V2-03 (User Accounts) Dependents: None (end feature)
Goals
- Admin dashboard with system overview
- User management (search, view, ban, unban)
- Force password reset capability
- Game moderation (view any game, end stuck games)
- System statistics and monitoring
- Invite code management
- Audit logging for admin actions
Current State
Basic admin exists:
- Admin role in users table
- Some admin endpoints in
main.py - Invite code creation
Missing:
- Admin dashboard UI
- User search/management
- Game moderation
- System stats
- Audit logging
Admin Capabilities Matrix
| Capability | Description | Risk Level |
|---|---|---|
| View users | List, search, view user details | Low |
| Ban user | Prevent login, kick from games | Medium |
| Unban user | Restore access | Low |
| Force password reset | Invalidate password, require reset | Medium |
| Impersonate user | View as user (read-only) | High |
| View any game | See any game state | Low |
| End stuck game | Force-end a game | Medium |
| View system stats | Metrics and monitoring | Low |
| Manage invite codes | Create, revoke codes | Low |
| View audit log | See admin actions | Low |
Database Schema
-- migrations/versions/003_admin_tools.sql
-- Audit log for admin actions
CREATE TABLE admin_audit_log (
id BIGSERIAL PRIMARY KEY,
admin_user_id UUID NOT NULL REFERENCES users(id),
action VARCHAR(50) NOT NULL,
target_type VARCHAR(50), -- 'user', 'game', 'invite_code', etc.
target_id VARCHAR(100),
details JSONB DEFAULT '{}',
ip_address INET,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- User bans
CREATE TABLE user_bans (
id BIGSERIAL PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id),
banned_by UUID NOT NULL REFERENCES users(id),
reason TEXT,
banned_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ, -- NULL = permanent
unbanned_at TIMESTAMPTZ,
unbanned_by UUID REFERENCES users(id)
);
-- Extend users table
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_banned BOOLEAN DEFAULT false;
ALTER TABLE users ADD COLUMN IF NOT EXISTS ban_reason TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS force_password_reset BOOLEAN DEFAULT false;
-- System metrics snapshots (for historical data)
CREATE TABLE system_metrics (
id BIGSERIAL PRIMARY KEY,
recorded_at TIMESTAMPTZ DEFAULT NOW(),
active_users INT,
active_games INT,
events_last_hour INT,
registrations_today INT,
games_completed_today INT,
metrics JSONB DEFAULT '{}'
);
-- Indexes
CREATE INDEX idx_audit_admin ON admin_audit_log(admin_user_id);
CREATE INDEX idx_audit_target ON admin_audit_log(target_type, target_id);
CREATE INDEX idx_audit_created ON admin_audit_log(created_at);
CREATE INDEX idx_bans_user ON user_bans(user_id);
CREATE INDEX idx_bans_active ON user_bans(user_id) WHERE unbanned_at IS NULL;
CREATE INDEX idx_metrics_time ON system_metrics(recorded_at);
Admin Service
# server/services/admin_service.py
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Optional, List
import asyncpg
from services.auth_service import User
@dataclass
class UserDetails:
id: str
username: str
email: Optional[str]
role: str
email_verified: bool
is_banned: bool
ban_reason: Optional[str]
force_password_reset: bool
created_at: datetime
last_seen_at: Optional[datetime]
games_played: int
games_won: int
@dataclass
class AuditEntry:
id: int
admin_username: str
action: str
target_type: Optional[str]
target_id: Optional[str]
details: dict
ip_address: str
created_at: datetime
@dataclass
class SystemStats:
active_users_now: int
active_games_now: int
total_users: int
total_games_completed: int
registrations_today: int
registrations_week: int
games_today: int
events_last_hour: int
top_players: List[dict]
class AdminService:
"""Admin operations and moderation."""
def __init__(self, db_pool: asyncpg.Pool, state_cache):
self.db = db_pool
self.state_cache = state_cache
# --- Audit Logging ---
async def _audit(
self,
admin_id: str,
action: str,
target_type: Optional[str] = None,
target_id: Optional[str] = None,
details: dict = None,
ip_address: str = None,
) -> None:
"""Log an admin action."""
async with self.db.acquire() as conn:
await conn.execute("""
INSERT INTO admin_audit_log
(admin_user_id, action, target_type, target_id, details, ip_address)
VALUES ($1, $2, $3, $4, $5, $6)
""", admin_id, action, target_type, target_id,
details or {}, ip_address)
async def get_audit_log(
self,
limit: int = 100,
offset: int = 0,
admin_id: Optional[str] = None,
action: Optional[str] = None,
target_type: Optional[str] = None,
) -> List[AuditEntry]:
"""Get audit log entries with filtering."""
async with self.db.acquire() as conn:
query = """
SELECT a.id, u.username as admin_username, a.action,
a.target_type, a.target_id, a.details,
a.ip_address, a.created_at
FROM admin_audit_log a
JOIN users u ON a.admin_user_id = u.id
WHERE 1=1
"""
params = []
param_num = 1
if admin_id:
query += f" AND a.admin_user_id = ${param_num}"
params.append(admin_id)
param_num += 1
if action:
query += f" AND a.action = ${param_num}"
params.append(action)
param_num += 1
if target_type:
query += f" AND a.target_type = ${param_num}"
params.append(target_type)
param_num += 1
query += f" ORDER BY a.created_at DESC LIMIT ${param_num} OFFSET ${param_num + 1}"
params.extend([limit, offset])
rows = await conn.fetch(query, *params)
return [
AuditEntry(
id=row["id"],
admin_username=row["admin_username"],
action=row["action"],
target_type=row["target_type"],
target_id=row["target_id"],
details=row["details"] or {},
ip_address=str(row["ip_address"]) if row["ip_address"] else "",
created_at=row["created_at"],
)
for row in rows
]
# --- User Management ---
async def search_users(
self,
query: str = "",
limit: int = 50,
offset: int = 0,
include_banned: bool = True,
include_deleted: bool = False,
) -> List[UserDetails]:
"""Search users by username or email."""
async with self.db.acquire() as conn:
sql = """
SELECT u.id, u.username, u.email, u.role,
u.email_verified, u.is_banned, u.ban_reason,
u.force_password_reset, u.created_at, u.last_seen_at,
COALESCE(s.games_played, 0) as games_played,
COALESCE(s.games_won, 0) as games_won
FROM users u
LEFT JOIN player_stats s ON u.id = s.user_id
WHERE 1=1
"""
params = []
param_num = 1
if query:
sql += f" AND (u.username ILIKE ${param_num} OR u.email ILIKE ${param_num})"
params.append(f"%{query}%")
param_num += 1
if not include_banned:
sql += " AND u.is_banned = false"
if not include_deleted:
sql += " AND u.deleted_at IS NULL"
sql += f" ORDER BY u.created_at DESC LIMIT ${param_num} OFFSET ${param_num + 1}"
params.extend([limit, offset])
rows = await conn.fetch(sql, *params)
return [
UserDetails(
id=row["id"],
username=row["username"],
email=row["email"],
role=row["role"],
email_verified=row["email_verified"],
is_banned=row["is_banned"],
ban_reason=row["ban_reason"],
force_password_reset=row["force_password_reset"],
created_at=row["created_at"],
last_seen_at=row["last_seen_at"],
games_played=row["games_played"],
games_won=row["games_won"],
)
for row in rows
]
async def get_user(self, user_id: str) -> Optional[UserDetails]:
"""Get detailed user info."""
users = await self.search_users() # Simplified; would filter by ID
async with self.db.acquire() as conn:
row = await conn.fetchrow("""
SELECT u.id, u.username, u.email, u.role,
u.email_verified, u.is_banned, u.ban_reason,
u.force_password_reset, u.created_at, u.last_seen_at,
COALESCE(s.games_played, 0) as games_played,
COALESCE(s.games_won, 0) as games_won
FROM users u
LEFT JOIN player_stats s ON u.id = s.user_id
WHERE u.id = $1
""", user_id)
if not row:
return None
return UserDetails(
id=row["id"],
username=row["username"],
email=row["email"],
role=row["role"],
email_verified=row["email_verified"],
is_banned=row["is_banned"],
ban_reason=row["ban_reason"],
force_password_reset=row["force_password_reset"],
created_at=row["created_at"],
last_seen_at=row["last_seen_at"],
games_played=row["games_played"],
games_won=row["games_won"],
)
async def ban_user(
self,
admin_id: str,
user_id: str,
reason: str,
duration_days: Optional[int] = None,
ip_address: str = None,
) -> bool:
"""Ban a user."""
expires_at = None
if duration_days:
expires_at = datetime.utcnow() + timedelta(days=duration_days)
async with self.db.acquire() as conn:
# Check user exists and isn't admin
user = await conn.fetchrow("""
SELECT role FROM users WHERE id = $1
""", user_id)
if not user:
return False
if user["role"] == "admin":
return False # Can't ban admins
# Create ban record
await conn.execute("""
INSERT INTO user_bans (user_id, banned_by, reason, expires_at)
VALUES ($1, $2, $3, $4)
""", user_id, admin_id, reason, expires_at)
# Update user
await conn.execute("""
UPDATE users
SET is_banned = true, ban_reason = $1
WHERE id = $2
""", reason, user_id)
# Revoke all sessions
await conn.execute("""
UPDATE user_sessions
SET revoked_at = NOW()
WHERE user_id = $1
""", user_id)
# Kick from any active games
await self._kick_from_games(user_id)
# Audit
await self._audit(
admin_id, "ban_user", "user", user_id,
{"reason": reason, "duration_days": duration_days},
ip_address,
)
return True
async def unban_user(
self,
admin_id: str,
user_id: str,
ip_address: str = None,
) -> bool:
"""Unban a user."""
async with self.db.acquire() as conn:
# Update ban record
await conn.execute("""
UPDATE user_bans
SET unbanned_at = NOW(), unbanned_by = $1
WHERE user_id = $2
AND unbanned_at IS NULL
""", admin_id, user_id)
# Update user
result = await conn.execute("""
UPDATE users
SET is_banned = false, ban_reason = NULL
WHERE id = $1
""", user_id)
if result == "UPDATE 0":
return False
await self._audit(
admin_id, "unban_user", "user", user_id,
ip_address=ip_address,
)
return True
async def force_password_reset(
self,
admin_id: str,
user_id: str,
ip_address: str = None,
) -> bool:
"""Force user to reset password on next login."""
async with self.db.acquire() as conn:
result = await conn.execute("""
UPDATE users
SET force_password_reset = true,
password_hash = ''
WHERE id = $1
""", user_id)
if result == "UPDATE 0":
return False
# Revoke all sessions
await conn.execute("""
UPDATE user_sessions
SET revoked_at = NOW()
WHERE user_id = $1
""", user_id)
await self._audit(
admin_id, "force_password_reset", "user", user_id,
ip_address=ip_address,
)
return True
async def change_user_role(
self,
admin_id: str,
user_id: str,
new_role: str,
ip_address: str = None,
) -> bool:
"""Change user role (user/admin)."""
if new_role not in ("user", "admin"):
return False
async with self.db.acquire() as conn:
# Get old role for audit
old = await conn.fetchrow("""
SELECT role FROM users WHERE id = $1
""", user_id)
if not old:
return False
await conn.execute("""
UPDATE users SET role = $1 WHERE id = $2
""", new_role, user_id)
await self._audit(
admin_id, "change_role", "user", user_id,
{"old_role": old["role"], "new_role": new_role},
ip_address,
)
return True
# --- Game Moderation ---
async def get_active_games(self) -> List[dict]:
"""Get all active games."""
rooms = await self.state_cache.get_active_rooms()
games = []
for room_code in rooms:
room = await self.state_cache.get_room(room_code)
if room:
game_id = room.get("game_id")
state = None
if game_id:
state = await self.state_cache.get_game_state(game_id)
games.append({
"room_code": room_code,
"game_id": game_id,
"status": room.get("status"),
"created_at": room.get("created_at"),
"player_count": len(await self.state_cache.get_room_players(room_code)),
"phase": state.get("phase") if state else None,
"current_round": state.get("current_round") if state else None,
})
return games
async def get_game_details(
self,
admin_id: str,
game_id: str,
ip_address: str = None,
) -> Optional[dict]:
"""Get full game state (admin view)."""
state = await self.state_cache.get_game_state(game_id)
if state:
await self._audit(
admin_id, "view_game", "game", game_id,
ip_address=ip_address,
)
return state
async def end_game(
self,
admin_id: str,
game_id: str,
reason: str,
ip_address: str = None,
) -> bool:
"""Force-end a stuck game."""
state = await self.state_cache.get_game_state(game_id)
if not state:
return False
room_code = state.get("room_code")
# Mark game as ended
state["phase"] = "game_over"
state["admin_ended"] = True
state["admin_end_reason"] = reason
await self.state_cache.save_game_state(game_id, state)
# Update games table
async with self.db.acquire() as conn:
await conn.execute("""
UPDATE games_v2
SET status = 'abandoned',
completed_at = NOW()
WHERE id = $1
""", game_id)
# Notify players via pub/sub
# (Implementation depends on pub/sub setup)
await self._audit(
admin_id, "end_game", "game", game_id,
{"reason": reason, "room_code": room_code},
ip_address,
)
return True
async def _kick_from_games(self, user_id: str) -> None:
"""Kick user from any active games."""
player_room = await self.state_cache.get_player_room(user_id)
if player_room:
await self.state_cache.remove_player_from_room(player_room, user_id)
# Additional game-specific kick logic
# --- System Stats ---
async def get_system_stats(self) -> SystemStats:
"""Get current system statistics."""
# Active counts from Redis
active_rooms = await self.state_cache.get_active_rooms()
active_games = len([r for r in active_rooms]) # Could filter by status
async with self.db.acquire() as conn:
# Total users
total_users = await conn.fetchval("""
SELECT COUNT(*) FROM users WHERE deleted_at IS NULL
""")
# Total completed games
total_games = await conn.fetchval("""
SELECT COUNT(*) FROM games_v2 WHERE status = 'completed'
""")
# Registrations today
reg_today = await conn.fetchval("""
SELECT COUNT(*) FROM users
WHERE created_at >= CURRENT_DATE
AND deleted_at IS NULL
""")
# Registrations this week
reg_week = await conn.fetchval("""
SELECT COUNT(*) FROM users
WHERE created_at >= CURRENT_DATE - INTERVAL '7 days'
AND deleted_at IS NULL
""")
# Games today
games_today = await conn.fetchval("""
SELECT COUNT(*) FROM games_v2
WHERE created_at >= CURRENT_DATE
""")
# Events last hour
events_hour = await conn.fetchval("""
SELECT COUNT(*) FROM events
WHERE created_at >= NOW() - INTERVAL '1 hour'
""")
# Top players (by wins)
top_players = await conn.fetch("""
SELECT u.username, s.games_won, s.games_played
FROM player_stats s
JOIN users u ON s.user_id = u.id
WHERE s.games_played >= 5
ORDER BY s.games_won DESC
LIMIT 10
""")
# Active users (sessions used in last hour)
active_users = await conn.fetchval("""
SELECT COUNT(DISTINCT user_id)
FROM user_sessions
WHERE last_used_at >= NOW() - INTERVAL '1 hour'
AND revoked_at IS NULL
""")
return SystemStats(
active_users_now=active_users or 0,
active_games_now=active_games,
total_users=total_users or 0,
total_games_completed=total_games or 0,
registrations_today=reg_today or 0,
registrations_week=reg_week or 0,
games_today=games_today or 0,
events_last_hour=events_hour or 0,
top_players=[
{
"username": p["username"],
"games_won": p["games_won"],
"games_played": p["games_played"],
}
for p in top_players
],
)
# --- Invite Codes ---
async def create_invite_code(
self,
admin_id: str,
max_uses: int = 1,
expires_days: int = 7,
ip_address: str = None,
) -> str:
"""Create a new invite code."""
import secrets
code = secrets.token_urlsafe(8).upper()[:8]
expires_at = datetime.utcnow() + timedelta(days=expires_days)
async with self.db.acquire() as conn:
await conn.execute("""
INSERT INTO invite_codes
(code, created_by, expires_at, max_uses)
VALUES ($1, $2, $3, $4)
""", code, admin_id, expires_at, max_uses)
await self._audit(
admin_id, "create_invite", "invite_code", code,
{"max_uses": max_uses, "expires_days": expires_days},
ip_address,
)
return code
async def get_invite_codes(self, include_expired: bool = False) -> List[dict]:
"""Get all invite codes."""
async with self.db.acquire() as conn:
query = """
SELECT c.code, c.created_at, c.expires_at,
c.max_uses, c.use_count, c.is_active,
u.username as created_by
FROM invite_codes c
JOIN users u ON c.created_by = u.id
"""
if not include_expired:
query += " WHERE c.expires_at > NOW() AND c.is_active = true"
query += " ORDER BY c.created_at DESC"
rows = await conn.fetch(query)
return [
{
"code": row["code"],
"created_at": row["created_at"].isoformat(),
"expires_at": row["expires_at"].isoformat(),
"max_uses": row["max_uses"],
"use_count": row["use_count"],
"is_active": row["is_active"],
"created_by": row["created_by"],
}
for row in rows
]
async def revoke_invite_code(
self,
admin_id: str,
code: str,
ip_address: str = None,
) -> bool:
"""Revoke an invite code."""
async with self.db.acquire() as conn:
result = await conn.execute("""
UPDATE invite_codes
SET is_active = false
WHERE code = $1
""", code)
if result == "UPDATE 0":
return False
await self._audit(
admin_id, "revoke_invite", "invite_code", code,
ip_address=ip_address,
)
return True
API Endpoints
# server/routers/admin.py
from fastapi import APIRouter, Depends, HTTPException
from typing import Optional
router = APIRouter(prefix="/api/admin", tags=["admin"])
def require_admin(user: User = Depends(get_current_user)) -> User:
"""Dependency that requires admin role."""
if user.role != "admin":
raise HTTPException(status_code=403, detail="Admin access required")
return user
# --- User Management ---
@router.get("/users")
async def list_users(
query: str = "",
limit: int = 50,
offset: int = 0,
include_banned: bool = True,
admin: User = Depends(require_admin),
service: AdminService = Depends(get_admin_service),
):
users = await service.search_users(query, limit, offset, include_banned)
return {"users": [u.__dict__ for u in users]}
@router.get("/users/{user_id}")
async def get_user(
user_id: str,
admin: User = Depends(require_admin),
service: AdminService = Depends(get_admin_service),
):
user = await service.get_user(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user.__dict__
@router.post("/users/{user_id}/ban")
async def ban_user(
user_id: str,
reason: str,
duration_days: Optional[int] = None,
admin: User = Depends(require_admin),
request: Request = None,
service: AdminService = Depends(get_admin_service),
):
success = await service.ban_user(
admin.id, user_id, reason, duration_days, request.client.host
)
if not success:
raise HTTPException(status_code=400, detail="Cannot ban user")
return {"message": "User banned"}
@router.post("/users/{user_id}/unban")
async def unban_user(
user_id: str,
admin: User = Depends(require_admin),
request: Request = None,
service: AdminService = Depends(get_admin_service),
):
success = await service.unban_user(admin.id, user_id, request.client.host)
if not success:
raise HTTPException(status_code=400, detail="Cannot unban user")
return {"message": "User unbanned"}
@router.post("/users/{user_id}/force-password-reset")
async def force_password_reset(
user_id: str,
admin: User = Depends(require_admin),
request: Request = None,
service: AdminService = Depends(get_admin_service),
):
success = await service.force_password_reset(
admin.id, user_id, request.client.host
)
if not success:
raise HTTPException(status_code=400, detail="Cannot force password reset")
return {"message": "Password reset required for user"}
@router.put("/users/{user_id}/role")
async def change_role(
user_id: str,
role: str,
admin: User = Depends(require_admin),
request: Request = None,
service: AdminService = Depends(get_admin_service),
):
success = await service.change_user_role(
admin.id, user_id, role, request.client.host
)
if not success:
raise HTTPException(status_code=400, detail="Cannot change role")
return {"message": f"Role changed to {role}"}
# --- Game Moderation ---
@router.get("/games")
async def list_games(
admin: User = Depends(require_admin),
service: AdminService = Depends(get_admin_service),
):
games = await service.get_active_games()
return {"games": games}
@router.get("/games/{game_id}")
async def get_game(
game_id: str,
admin: User = Depends(require_admin),
request: Request = None,
service: AdminService = Depends(get_admin_service),
):
game = await service.get_game_details(
admin.id, game_id, request.client.host
)
if not game:
raise HTTPException(status_code=404, detail="Game not found")
return game
@router.post("/games/{game_id}/end")
async def end_game(
game_id: str,
reason: str,
admin: User = Depends(require_admin),
request: Request = None,
service: AdminService = Depends(get_admin_service),
):
success = await service.end_game(
admin.id, game_id, reason, request.client.host
)
if not success:
raise HTTPException(status_code=400, detail="Cannot end game")
return {"message": "Game ended"}
# --- System Stats ---
@router.get("/stats")
async def get_stats(
admin: User = Depends(require_admin),
service: AdminService = Depends(get_admin_service),
):
stats = await service.get_system_stats()
return stats.__dict__
# --- Audit Log ---
@router.get("/audit")
async def get_audit_log(
limit: int = 100,
offset: int = 0,
action: Optional[str] = None,
target_type: Optional[str] = None,
admin: User = Depends(require_admin),
service: AdminService = Depends(get_admin_service),
):
entries = await service.get_audit_log(limit, offset, action=action, target_type=target_type)
return {"entries": [e.__dict__ for e in entries]}
# --- Invite Codes ---
@router.post("/invites")
async def create_invite(
max_uses: int = 1,
expires_days: int = 7,
admin: User = Depends(require_admin),
request: Request = None,
service: AdminService = Depends(get_admin_service),
):
code = await service.create_invite_code(
admin.id, max_uses, expires_days, request.client.host
)
return {"code": code}
@router.get("/invites")
async def list_invites(
include_expired: bool = False,
admin: User = Depends(require_admin),
service: AdminService = Depends(get_admin_service),
):
codes = await service.get_invite_codes(include_expired)
return {"codes": codes}
@router.delete("/invites/{code}")
async def revoke_invite(
code: str,
admin: User = Depends(require_admin),
request: Request = None,
service: AdminService = Depends(get_admin_service),
):
success = await service.revoke_invite_code(admin.id, code, request.client.host)
if not success:
raise HTTPException(status_code=404, detail="Invite code not found")
return {"message": "Invite revoked"}
Admin Dashboard UI
<!-- client/admin.html -->
<!DOCTYPE html>
<html>
<head>
<title>Golf Admin</title>
<link rel="stylesheet" href="admin.css">
</head>
<body>
<nav class="admin-nav">
<h1>Golf Admin</h1>
<div class="nav-links">
<a href="#dashboard" class="active">Dashboard</a>
<a href="#users">Users</a>
<a href="#games">Games</a>
<a href="#invites">Invites</a>
<a href="#audit">Audit Log</a>
</div>
<button id="logout-btn">Logout</button>
</nav>
<main class="admin-content">
<!-- Dashboard -->
<section id="dashboard" class="panel">
<h2>System Overview</h2>
<div class="stats-grid">
<div class="stat-card">
<span class="stat-value" id="active-users">-</span>
<span class="stat-label">Active Users</span>
</div>
<div class="stat-card">
<span class="stat-value" id="active-games">-</span>
<span class="stat-label">Active Games</span>
</div>
<div class="stat-card">
<span class="stat-value" id="total-users">-</span>
<span class="stat-label">Total Users</span>
</div>
<div class="stat-card">
<span class="stat-value" id="games-today">-</span>
<span class="stat-label">Games Today</span>
</div>
</div>
<h3>Top Players</h3>
<table id="top-players">
<thead>
<tr>
<th>Username</th>
<th>Wins</th>
<th>Games</th>
</tr>
</thead>
<tbody></tbody>
</table>
</section>
<!-- Users -->
<section id="users" class="panel hidden">
<h2>User Management</h2>
<div class="search-bar">
<input type="text" id="user-search" placeholder="Search by username or email...">
<button id="search-btn">Search</button>
</div>
<table id="users-table">
<thead>
<tr>
<th>Username</th>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
</section>
<!-- Games -->
<section id="games" class="panel hidden">
<h2>Active Games</h2>
<table id="games-table">
<thead>
<tr>
<th>Room</th>
<th>Players</th>
<th>Phase</th>
<th>Round</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
</section>
<!-- Invites -->
<section id="invites" class="panel hidden">
<h2>Invite Codes</h2>
<div class="create-invite">
<input type="number" id="invite-uses" value="1" min="1">
<input type="number" id="invite-days" value="7" min="1">
<button id="create-invite-btn">Create Invite</button>
</div>
<table id="invites-table">
<thead>
<tr>
<th>Code</th>
<th>Uses</th>
<th>Expires</th>
<th>Created By</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
</section>
<!-- Audit Log -->
<section id="audit" class="panel hidden">
<h2>Audit Log</h2>
<table id="audit-table">
<thead>
<tr>
<th>Time</th>
<th>Admin</th>
<th>Action</th>
<th>Target</th>
<th>Details</th>
</tr>
</thead>
<tbody></tbody>
</table>
</section>
</main>
<script src="admin.js"></script>
</body>
</html>
Acceptance Criteria
-
User Management
- Can search users by username/email
- Can view user details
- Can ban users (with reason, optional duration)
- Can unban users
- Can force password reset
- Can change user roles
- Cannot ban other admins
-
Game Moderation
- Can list active games
- Can view any game state
- Can end stuck games
- Players notified when game ended
- Ended games marked as abandoned
-
System Stats
- Shows active users count
- Shows active games count
- Shows total users
- Shows registrations today/week
- Shows games today
- Shows top players
-
Invite Codes
- Can create invite codes
- Can set max uses and expiry
- Can list all codes
- Can revoke codes
-
Audit Logging
- All admin actions logged
- Log shows admin, action, target, timestamp
- Can filter audit log
- IP address captured
-
Admin Dashboard UI
- Dashboard shows overview stats
- Can navigate between sections
- Actions work correctly
- Responsive design
Implementation Order
- Create database migrations
- Implement AdminService (audit logging first)
- Add user management methods
- Add game moderation methods
- Add system stats
- Create API endpoints
- Build admin dashboard UI
- Test all flows
- Security review
Security Notes
- All admin actions are audited
- Cannot ban other admins
- Cannot delete your own admin account
- IP addresses logged for forensics
- Admin dashboard requires separate auth check
- Consider 2FA for admin accounts (future)