golfgame/docs/v2/V2_04_ADMIN_TOOLS.md
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

1180 lines
35 KiB
Markdown

# 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
1. Admin dashboard with system overview
2. User management (search, view, ban, unban)
3. Force password reset capability
4. Game moderation (view any game, end stuck games)
5. System statistics and monitoring
6. Invite code management
7. 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
```sql
-- 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
```python
# 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
```python
# 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
```html
<!-- 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
1. **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
2. **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
3. **System Stats**
- [ ] Shows active users count
- [ ] Shows active games count
- [ ] Shows total users
- [ ] Shows registrations today/week
- [ ] Shows games today
- [ ] Shows top players
4. **Invite Codes**
- [ ] Can create invite codes
- [ ] Can set max uses and expiry
- [ ] Can list all codes
- [ ] Can revoke codes
5. **Audit Logging**
- [ ] All admin actions logged
- [ ] Log shows admin, action, target, timestamp
- [ ] Can filter audit log
- [ ] IP address captured
6. **Admin Dashboard UI**
- [ ] Dashboard shows overview stats
- [ ] Can navigate between sections
- [ ] Actions work correctly
- [ ] Responsive design
---
## Implementation Order
1. Create database migrations
2. Implement AdminService (audit logging first)
3. Add user management methods
4. Add game moderation methods
5. Add system stats
6. Create API endpoints
7. Build admin dashboard UI
8. Test all flows
9. 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)