diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..dee5a9b
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,37 @@
+# Production Dockerfile for Golf Card Game
+FROM python:3.11-slim as base
+
+# Set environment variables
+ENV PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1 \
+ PIP_NO_CACHE_DIR=1 \
+ PIP_DISABLE_PIP_VERSION_CHECK=1
+
+WORKDIR /app
+
+# Install system dependencies
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ curl \
+ && rm -rf /var/lib/apt/lists/*
+
+# Install Python dependencies
+COPY server/requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+# Copy application code
+COPY server/ ./server/
+COPY client/ ./client/
+
+# Create non-root user
+RUN useradd --create-home --shell /bin/bash appuser \
+ && chown -R appuser:appuser /app
+USER appuser
+
+# Health check
+HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
+ CMD curl -f http://localhost:8000/health || exit 1
+
+EXPOSE 8000
+
+# Run with uvicorn
+CMD ["python", "-m", "uvicorn", "server.main:app", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/V2_BUILD_PLAN.md b/V2_BUILD_PLAN.md
new file mode 100644
index 0000000..c760349
--- /dev/null
+++ b/V2_BUILD_PLAN.md
@@ -0,0 +1,522 @@
+# Golf Card Game - V2 Build Plan
+
+## Vision
+
+Transform the current single-server Golf game into a production-ready, hostable platform with:
+- **Event-sourced architecture** for full game replay and audit trails
+- **Leaderboards** with player statistics
+- **Scalable hosting** options (self-hosted or cloud)
+- **Export/playback** for sharing memorable games
+
+---
+
+## Current State (V1)
+
+```
+Client (Vanilla JS) ◄──WebSocket──► FastAPI Server ◄──► SQLite
+ │
+ In-memory rooms
+ (lost on restart)
+```
+
+**What works well:**
+- Game logic is solid and well-tested
+- CPU AI with multiple personalities
+- House rules system is flexible
+- Real-time multiplayer via WebSockets
+
+**Limitations:**
+- Single server, no horizontal scaling
+- Game state lost on server restart
+- Move logging exists but duplicates state
+- No player accounts with persistent stats
+
+---
+
+## V2 Architecture
+
+```
+┌─────────────────────────────────────────────────────────────────────┐
+│ Clients │
+│ (Browser / Future: Mobile) │
+└───────────────────────────────┬─────────────────────────────────────┘
+ │ WebSocket + REST API
+ ▼
+┌─────────────────────────────────────────────────────────────────────┐
+│ FastAPI Application │
+│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌────────────┐ │
+│ │ Command │ │ Event │ │ State │ │ Query │ │
+│ │ Handler │──► Store │──► Builder │ │ Service │ │
+│ └─────────────┘ └─────────────┘ └─────────────┘ └────────────┘ │
+└───────┬───────────────────┬───────────────────┬───────────────┬─────┘
+ │ │ │ │
+ ▼ ▼ ▼ ▼
+┌──────────────┐ ┌──────────────┐ ┌─────────────┐ ┌───────────┐
+│ Redis │ │ PostgreSQL │ │ PostgreSQL │ │ Postgres │
+│ (Live State) │ │ (Events) │ │ (Users) │ │ (Stats) │
+│ (Pub/Sub) │ │ │ │ │ │ │
+└──────────────┘ └──────────────┘ └─────────────┘ └───────────┘
+```
+
+---
+
+## Data Model
+
+### Event Store
+
+All game actions stored as immutable events:
+
+```sql
+-- Core event log
+CREATE TABLE events (
+ id BIGSERIAL PRIMARY KEY,
+ game_id UUID NOT NULL,
+ sequence_num INT NOT NULL,
+ event_type VARCHAR(50) NOT NULL,
+ event_data JSONB NOT NULL,
+ player_id VARCHAR(50),
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+
+ UNIQUE(game_id, sequence_num)
+);
+
+-- Game metadata (denormalized for queries)
+CREATE TABLE games (
+ id UUID PRIMARY KEY,
+ room_code VARCHAR(10),
+ status VARCHAR(20) DEFAULT 'active', -- active, completed, abandoned
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ completed_at TIMESTAMPTZ,
+ num_players INT,
+ num_rounds INT,
+ options JSONB,
+ winner_id VARCHAR(50),
+
+ -- Denormalized for leaderboard queries
+ player_ids VARCHAR(50)[]
+);
+
+CREATE INDEX idx_events_game ON events(game_id, sequence_num);
+CREATE INDEX idx_games_status ON games(status, completed_at);
+CREATE INDEX idx_games_players ON games USING GIN(player_ids);
+```
+
+### Event Types
+
+```python
+@dataclass
+class GameEvent:
+ game_id: str
+ sequence_num: int
+ event_type: str
+ player_id: Optional[str]
+ timestamp: datetime
+ data: dict
+
+# Lifecycle events
+GameCreated(room_code, options, host_id)
+PlayerJoined(player_id, player_name, is_cpu, profile_name?)
+PlayerLeft(player_id, reason)
+GameStarted(deck_seed, player_order)
+RoundStarted(round_num)
+RoundEnded(scores: dict, winner_id)
+GameEnded(final_scores: dict, winner_id)
+
+# Gameplay events
+InitialCardsFlipped(player_id, positions: list[int])
+CardDrawn(player_id, source: "deck"|"discard", card: Card)
+CardSwapped(player_id, position: int, new_card: Card, old_card: Card)
+CardDiscarded(player_id, card: Card)
+CardFlipped(player_id, position: int, card: Card)
+FlipSkipped(player_id)
+FlipAsAction(player_id, position: int, card: Card)
+```
+
+### User & Stats Schema
+
+```sql
+-- User accounts (expand existing auth)
+CREATE TABLE users (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ username VARCHAR(50) UNIQUE NOT NULL,
+ email VARCHAR(255) UNIQUE,
+ password_hash VARCHAR(255),
+ role VARCHAR(20) DEFAULT 'player',
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ last_seen_at TIMESTAMPTZ,
+ is_active BOOLEAN DEFAULT true,
+ preferences JSONB DEFAULT '{}'
+);
+
+-- Player statistics (materialized from events)
+CREATE TABLE player_stats (
+ user_id UUID PRIMARY KEY REFERENCES users(id),
+ games_played INT DEFAULT 0,
+ games_won INT DEFAULT 0,
+ rounds_played INT DEFAULT 0,
+ rounds_won INT DEFAULT 0,
+ total_points INT DEFAULT 0, -- Lower is better
+ best_round_score INT,
+ worst_round_score INT,
+ total_knockouts INT DEFAULT 0, -- Times going out first
+ total_blunders INT DEFAULT 0, -- From AI analyzer
+ updated_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- Leaderboard views
+CREATE VIEW leaderboard_by_wins AS
+SELECT
+ u.username,
+ s.games_played,
+ s.games_won,
+ ROUND(s.games_won::numeric / NULLIF(s.games_played, 0) * 100, 1) as win_rate,
+ s.rounds_won,
+ ROUND(s.total_points::numeric / NULLIF(s.rounds_played, 0), 1) as avg_score
+FROM player_stats s
+JOIN users u ON s.user_id = u.id
+WHERE s.games_played >= 25 -- Minimum games for ranking
+ORDER BY win_rate DESC, games_won DESC;
+
+CREATE VIEW leaderboard_by_games AS
+SELECT
+ u.username,
+ s.games_played,
+ s.games_won,
+ s.rounds_won
+FROM player_stats s
+JOIN users u ON s.user_id = u.id
+ORDER BY games_played DESC;
+```
+
+---
+
+## Components to Build
+
+### Phase 1: Event Infrastructure (Foundation)
+
+| Component | Description | Effort |
+|-----------|-------------|--------|
+| Event classes | Python dataclasses for all event types | S |
+| Event store | PostgreSQL table + write functions | S |
+| State rebuilder | Fold events into GameState | M |
+| Dual-write migration | Emit events alongside current mutations | M |
+| Event validation | Ensure events can recreate identical state | M |
+
+### Phase 2: Persistence & Recovery
+
+| Component | Description | Effort |
+|-----------|-------------|--------|
+| Redis state cache | Store live game state in Redis | M |
+| Pub/sub for multi-server | Redis pub/sub for WebSocket fan-out | M |
+| Game recovery | Rebuild in-progress games from events on restart | S |
+| Graceful shutdown | Save state before shutdown | S |
+
+### Phase 3: User System & Stats
+
+| Component | Description | Effort |
+|-----------|-------------|--------|
+| User registration flow | Proper signup/login UI | M |
+| Guest-to-user conversion | Play as guest, register to save stats | S |
+| Stats aggregation worker | Process events → update player_stats | M |
+| Leaderboard API | REST endpoints for leaderboards | S |
+| Leaderboard UI | Display in client | M |
+
+### Phase 4: Replay & Export
+
+| Component | Description | Effort |
+|-----------|-------------|--------|
+| Export API | `GET /api/games/{id}/export` returns event JSON | S |
+| Import/load | Load exported game for replay | S |
+| Replay UI | Playback controls, scrubbing, speed control | L |
+| Share links | `/replay/{game_id}` public URLs | S |
+
+### Phase 5: Production Hardening
+
+| Component | Description | Effort |
+|-----------|-------------|--------|
+| Rate limiting | Prevent abuse | S |
+| Health checks | `/health` with dependency checks | S |
+| Metrics | Prometheus metrics for monitoring | M |
+| Error tracking | Sentry or similar | S |
+| Backup strategy | Automated PostgreSQL backups | S |
+
+---
+
+## Tech Stack
+
+### Recommended Stack
+
+| Layer | Technology | Reasoning |
+|-------|------------|-----------|
+| **Web framework** | FastAPI (keep) | Already using, async, fast |
+| **WebSockets** | Starlette (keep) | Built into FastAPI |
+| **Live state cache** | Redis | Fast, pub/sub, TTL, battle-tested |
+| **Event store** | PostgreSQL | JSONB, robust, great tooling |
+| **User database** | PostgreSQL | Same instance, keep it simple |
+| **Background jobs** | `arq` or `rq` | Stats aggregation, cleanup |
+| **Containerization** | Docker | Consistent deployment |
+| **Orchestration** | Docker Compose (small) / K8s (large) | Start simple |
+
+### Dependencies to Add
+
+```txt
+# requirements.txt additions
+redis>=5.0.0
+asyncpg>=0.29.0 # Async PostgreSQL
+sqlalchemy>=2.0.0 # ORM (optional, can use raw SQL)
+alembic>=1.13.0 # Migrations
+arq>=0.26.0 # Background tasks
+pydantic-settings>=2.0 # Config management
+```
+
+---
+
+## Hosting Options
+
+### Option A: Single VPS (Simplest, $5-20/mo)
+
+```
+┌─────────────────────────────────────┐
+│ VPS (2-4GB RAM) │
+│ ┌─────────┐ ┌─────────┐ ┌───────┐ │
+│ │ FastAPI │ │ Redis │ │Postgres│ │
+│ │ :8000 │ │ :6379 │ │ :5432 │ │
+│ └─────────┘ └─────────┘ └───────┘ │
+│ Docker Compose │
+└─────────────────────────────────────┘
+
+Providers: DigitalOcean, Linode, Hetzner, Vultr
+Capacity: ~100-500 concurrent users
+```
+
+**docker-compose.yml:**
+```yaml
+version: '3.8'
+services:
+ app:
+ build: .
+ ports:
+ - "8000:8000"
+ environment:
+ - DATABASE_URL=postgresql://golf:secret@db:5432/golf
+ - REDIS_URL=redis://redis:6379
+ depends_on:
+ - db
+ - redis
+
+ redis:
+ image: redis:7-alpine
+ volumes:
+ - redis_data:/data
+
+ db:
+ image: postgres:16-alpine
+ environment:
+ POSTGRES_USER: golf
+ POSTGRES_PASSWORD: secret
+ POSTGRES_DB: golf
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+
+ nginx:
+ image: nginx:alpine
+ ports:
+ - "80:80"
+ - "443:443"
+ volumes:
+ - ./nginx.conf:/etc/nginx/nginx.conf
+ - ./certs:/etc/nginx/certs
+
+volumes:
+ redis_data:
+ postgres_data:
+```
+
+### Option B: Managed Services ($20-50/mo)
+
+```
+┌──────────────┐ ┌─────────────────┐ ┌──────────────┐
+│ Fly.io │ │ Upstash Redis │ │ Neon or │
+│ App │◄───►│ (Serverless) │ │ Supabase │
+│ $5-10/mo │ │ Free-$10/mo │ │ PostgreSQL │
+└──────────────┘ └─────────────────┘ │ Free-$25/mo │
+ └──────────────┘
+
+Alternative compute: Railway, Render, Google Cloud Run
+```
+
+**Pros:** Less ops, automatic SSL, easy scaling
+**Cons:** Slightly higher latency, vendor lock-in
+
+### Option C: Self-Hosted (Home Server / NAS)
+
+```
+┌─────────────────────────────────────┐
+│ Home Server / Raspberry Pi 5 │
+│ Docker Compose (same as Option A) │
+└───────────────────┬─────────────────┘
+ │
+┌───────────────────▼─────────────────┐
+│ Cloudflare Tunnel (free) │
+│ • No port forwarding needed │
+│ • Free SSL │
+│ • DDoS protection │
+└─────────────────────────────────────┘
+
+Domain: golf.yourdomain.com
+```
+
+### Option D: Kubernetes (Overkill Unless Scaling Big)
+
+Only if you're expecting 5000+ concurrent users or need multi-region.
+
+---
+
+## Migration Strategy
+
+### Step 1: Add Event Emission (Non-Breaking)
+
+Keep current code working, add event logging in parallel:
+
+```python
+# In game.py or main.py
+def draw_card(self, player_id: str, source: str) -> Optional[Card]:
+ # Existing logic
+ card = self._do_draw(player_id, source)
+
+ if card:
+ # NEW: Emit event (doesn't affect gameplay)
+ self.emit_event(CardDrawn(
+ player_id=player_id,
+ source=source,
+ card=card.to_dict()
+ ))
+
+ return card
+```
+
+### Step 2: Validate Event Replay
+
+Build a test that:
+1. Plays a game normally
+2. Captures all events
+3. Replays events into fresh state
+4. Asserts final state matches
+
+```python
+def test_event_replay_matches():
+ # Play a game, collect events
+ game, events = play_test_game()
+ final_state = game.get_state()
+
+ # Rebuild from events
+ rebuilt = GameState()
+ for event in events:
+ rebuilt.apply(event)
+
+ assert rebuilt == final_state
+```
+
+### Step 3: Switch to Event-Sourced
+
+Once validation passes:
+1. Commands produce events
+2. Events applied to state
+3. State derived, not mutated directly
+
+### Step 4: Deploy New Infrastructure
+
+1. Set up PostgreSQL + Redis
+2. Deploy with feature flag (old vs new storage)
+3. Run both in parallel, compare
+4. Cut over when confident
+
+---
+
+## Milestones & Timeline
+
+| Phase | Milestone | Dependencies |
+|-------|-----------|--------------|
+| **1** | Events emitting alongside current code | None |
+| **1** | Event replay test passing | Events emitting |
+| **2** | Redis state cache working | None |
+| **2** | Server survives restart (games recover) | Events + Redis |
+| **3** | User accounts with persistent stats | PostgreSQL |
+| **3** | Leaderboards displaying | Stats aggregation |
+| **4** | Export API working | Events stored |
+| **4** | Replay UI functional | Export API |
+| **5** | Dockerized deployment | All above |
+| **5** | Production deployment | Docker + hosting |
+
+---
+
+## Open Questions
+
+1. **Guest play vs required accounts?**
+ - Recommendation: Allow guest play, prompt to register to save stats
+
+2. **Game history retention?**
+ - Keep all events forever? Or archive after 90 days?
+ - Events are small (~500 bytes each), storage is cheap
+
+3. **Replay visibility?**
+ - All games public? Only if shared? Privacy setting per game?
+
+4. **CPU games count for leaderboards?**
+ - Recommendation: Yes, but flag them. Separate "vs humans" stats later.
+
+5. **i18n approach?**
+ - Client-side translation files (JSON)
+ - Server messages are mostly game state, not text
+
+---
+
+## Appendix: File Structure (Proposed)
+
+```
+golfgame/
+├── client/ # Frontend (keep as-is for now)
+│ ├── index.html
+│ ├── app.js
+│ └── ...
+├── server/
+│ ├── main.py # FastAPI app, WebSocket handlers
+│ ├── config.py # Settings (env vars)
+│ ├── models/
+│ │ ├── events.py # Event dataclasses
+│ │ ├── game_state.py # State rebuilt from events
+│ │ └── user.py # User model
+│ ├── stores/
+│ │ ├── event_store.py # PostgreSQL event persistence
+│ │ ├── state_cache.py # Redis live state
+│ │ └── user_store.py # User/auth persistence
+│ ├── services/
+│ │ ├── game_service.py # Command handling, event emission
+│ │ ├── replay_service.py # Export, import, playback
+│ │ ├── stats_service.py # Leaderboard queries
+│ │ └── auth_service.py # Authentication
+│ ├── workers/
+│ │ └── stats_worker.py # Background stats aggregation
+│ ├── ai/
+│ │ ├── profiles.py # CPU personalities
+│ │ └── decisions.py # AI logic
+│ └── tests/
+│ ├── test_events.py
+│ ├── test_replay.py
+│ └── ...
+├── migrations/ # Alembic migrations
+├── docker-compose.yml
+├── Dockerfile
+└── V2_BUILD_PLAN.md # This file
+```
+
+---
+
+## Next Steps
+
+1. **Review this plan** - Any adjustments to scope or priorities?
+2. **Set up PostgreSQL locally** - For development
+3. **Define event classes** - Start with Phase 1
+4. **Add event emission** - Non-breaking change to current code
+
+Ready to start building when you are.
diff --git a/client/admin.css b/client/admin.css
new file mode 100644
index 0000000..5833e77
--- /dev/null
+++ b/client/admin.css
@@ -0,0 +1,633 @@
+/* Golf Admin Dashboard Styles */
+
+:root {
+ --color-primary: #2563eb;
+ --color-primary-dark: #1d4ed8;
+ --color-success: #059669;
+ --color-warning: #d97706;
+ --color-danger: #dc2626;
+ --color-bg: #f8fafc;
+ --color-surface: #ffffff;
+ --color-border: #e2e8f0;
+ --color-text: #1e293b;
+ --color-text-muted: #64748b;
+ --color-text-light: #94a3b8;
+ --radius: 8px;
+ --shadow: 0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06);
+ --shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -2px rgba(0,0,0,0.05);
+}
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
+ background: var(--color-bg);
+ color: var(--color-text);
+ line-height: 1.5;
+}
+
+/* Screens */
+.screen {
+ min-height: 100vh;
+}
+
+.hidden {
+ display: none !important;
+}
+
+/* Login Screen */
+.login-container {
+ max-width: 400px;
+ margin: 100px auto;
+ padding: 2rem;
+ background: var(--color-surface);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow-lg);
+}
+
+.login-container h1 {
+ text-align: center;
+ margin-bottom: 1.5rem;
+ color: var(--color-primary);
+}
+
+/* Forms */
+.form-group {
+ margin-bottom: 1rem;
+}
+
+.form-group label {
+ display: block;
+ margin-bottom: 0.5rem;
+ font-weight: 500;
+ color: var(--color-text);
+}
+
+.form-group input,
+.form-group textarea,
+.form-group select {
+ width: 100%;
+ padding: 0.75rem;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius);
+ font-size: 1rem;
+ transition: border-color 0.2s;
+}
+
+.form-group input:focus,
+.form-group textarea:focus,
+.form-group select:focus {
+ outline: none;
+ border-color: var(--color-primary);
+}
+
+.form-group textarea {
+ min-height: 100px;
+ resize: vertical;
+}
+
+.form-actions {
+ display: flex;
+ gap: 1rem;
+ justify-content: flex-end;
+ margin-top: 1.5rem;
+}
+
+.error {
+ color: var(--color-danger);
+ margin-top: 1rem;
+ text-align: center;
+}
+
+/* Buttons */
+.btn {
+ padding: 0.75rem 1.5rem;
+ border: none;
+ border-radius: var(--radius);
+ font-size: 0.875rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background-color 0.2s, transform 0.1s;
+ background: var(--color-border);
+ color: var(--color-text);
+}
+
+.btn:hover {
+ filter: brightness(0.95);
+}
+
+.btn:active {
+ transform: scale(0.98);
+}
+
+.btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.btn-primary {
+ background: var(--color-primary);
+ color: white;
+}
+
+.btn-primary:hover {
+ background: var(--color-primary-dark);
+}
+
+.btn-success {
+ background: var(--color-success);
+ color: white;
+}
+
+.btn-warning {
+ background: var(--color-warning);
+ color: white;
+}
+
+.btn-danger {
+ background: var(--color-danger);
+ color: white;
+}
+
+.btn-small {
+ padding: 0.5rem 1rem;
+ font-size: 0.75rem;
+}
+
+/* Navigation */
+.admin-nav {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 1rem 2rem;
+ background: var(--color-surface);
+ border-bottom: 1px solid var(--color-border);
+ box-shadow: var(--shadow);
+}
+
+.nav-brand h1 {
+ font-size: 1.5rem;
+ color: var(--color-primary);
+}
+
+.nav-links {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.nav-link {
+ padding: 0.5rem 1rem;
+ text-decoration: none;
+ color: var(--color-text-muted);
+ border-radius: var(--radius);
+ transition: background-color 0.2s, color 0.2s;
+}
+
+.nav-link:hover {
+ background: var(--color-bg);
+ color: var(--color-text);
+}
+
+.nav-link.active {
+ background: var(--color-primary);
+ color: white;
+}
+
+.nav-user {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ color: var(--color-text-muted);
+}
+
+/* Content */
+.admin-content {
+ max-width: 1400px;
+ margin: 0 auto;
+ padding: 2rem;
+}
+
+/* Panels */
+.panel {
+ background: var(--color-surface);
+ border-radius: var(--radius);
+ padding: 1.5rem;
+ box-shadow: var(--shadow);
+}
+
+.panel h2 {
+ margin-bottom: 1.5rem;
+ color: var(--color-text);
+}
+
+.panel-section {
+ margin-top: 2rem;
+}
+
+.panel-section h3 {
+ margin-bottom: 1rem;
+ color: var(--color-text);
+}
+
+.panel-toolbar {
+ display: flex;
+ gap: 1rem;
+ align-items: center;
+ margin-bottom: 1rem;
+ flex-wrap: wrap;
+}
+
+/* Stats Grid */
+.stats-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
+ gap: 1rem;
+ margin-bottom: 2rem;
+}
+
+.stat-card {
+ background: var(--color-bg);
+ padding: 1.25rem;
+ border-radius: var(--radius);
+ text-align: center;
+}
+
+.stat-value {
+ display: block;
+ font-size: 2rem;
+ font-weight: 700;
+ color: var(--color-primary);
+}
+
+.stat-label {
+ display: block;
+ font-size: 0.875rem;
+ color: var(--color-text-muted);
+ margin-top: 0.25rem;
+}
+
+/* Data Tables */
+.data-table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.data-table th,
+.data-table td {
+ padding: 0.75rem;
+ text-align: left;
+ border-bottom: 1px solid var(--color-border);
+}
+
+.data-table th {
+ background: var(--color-bg);
+ font-weight: 600;
+ color: var(--color-text-muted);
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.data-table tbody tr:hover {
+ background: var(--color-bg);
+}
+
+.data-table.small {
+ font-size: 0.875rem;
+}
+
+.data-table.small th,
+.data-table.small td {
+ padding: 0.5rem;
+}
+
+/* Search Bar */
+.search-bar {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.search-bar input {
+ padding: 0.5rem 1rem;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius);
+ min-width: 250px;
+}
+
+.search-bar input:focus {
+ outline: none;
+ border-color: var(--color-primary);
+}
+
+/* Filter Bar */
+.filter-bar {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.filter-bar select {
+ padding: 0.5rem 1rem;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius);
+ background: white;
+}
+
+/* Checkbox */
+.checkbox-label {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ cursor: pointer;
+ color: var(--color-text-muted);
+ font-size: 0.875rem;
+}
+
+/* Create Invite Form */
+.create-invite-form {
+ display: flex;
+ gap: 1rem;
+ align-items: center;
+}
+
+.create-invite-form label {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ color: var(--color-text-muted);
+ font-size: 0.875rem;
+}
+
+.create-invite-form input {
+ width: 80px;
+ padding: 0.5rem;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius);
+}
+
+/* Pagination */
+.pagination {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 1rem;
+ margin-top: 1rem;
+ padding-top: 1rem;
+ border-top: 1px solid var(--color-border);
+}
+
+/* Status Badges */
+.badge {
+ display: inline-block;
+ padding: 0.25rem 0.5rem;
+ border-radius: 999px;
+ font-size: 0.75rem;
+ font-weight: 500;
+}
+
+.badge-success {
+ background: #dcfce7;
+ color: #166534;
+}
+
+.badge-danger {
+ background: #fee2e2;
+ color: #991b1b;
+}
+
+.badge-warning {
+ background: #fef3c7;
+ color: #92400e;
+}
+
+.badge-info {
+ background: #dbeafe;
+ color: #1e40af;
+}
+
+.badge-muted {
+ background: #f1f5f9;
+ color: #64748b;
+}
+
+/* Modals */
+.modal {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+}
+
+.modal-content {
+ background: var(--color-surface);
+ border-radius: var(--radius);
+ max-width: 600px;
+ width: 90%;
+ max-height: 90vh;
+ overflow-y: auto;
+ box-shadow: var(--shadow-lg);
+}
+
+.modal-content.modal-small {
+ max-width: 400px;
+}
+
+.modal-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 1rem 1.5rem;
+ border-bottom: 1px solid var(--color-border);
+}
+
+.modal-header h3 {
+ margin: 0;
+}
+
+.modal-close {
+ background: none;
+ border: none;
+ font-size: 1.5rem;
+ cursor: pointer;
+ color: var(--color-text-muted);
+ padding: 0;
+ line-height: 1;
+}
+
+.modal-close:hover {
+ color: var(--color-text);
+}
+
+.modal-body {
+ padding: 1.5rem;
+}
+
+/* User Detail Modal */
+.user-detail-grid {
+ display: grid;
+ gap: 0.75rem;
+}
+
+.detail-row {
+ display: flex;
+ gap: 1rem;
+}
+
+.detail-label {
+ font-weight: 500;
+ color: var(--color-text-muted);
+ min-width: 120px;
+}
+
+.detail-value {
+ color: var(--color-text);
+}
+
+.user-actions {
+ margin-top: 1.5rem;
+ padding-top: 1.5rem;
+ border-top: 1px solid var(--color-border);
+}
+
+.user-actions h4 {
+ margin-bottom: 1rem;
+}
+
+.action-buttons {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+
+#ban-history-section {
+ margin-top: 1.5rem;
+ padding-top: 1.5rem;
+ border-top: 1px solid var(--color-border);
+}
+
+#ban-history-section h4 {
+ margin-bottom: 1rem;
+}
+
+/* Toast Notifications */
+#toast-container {
+ position: fixed;
+ bottom: 2rem;
+ right: 2rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ z-index: 2000;
+}
+
+.toast {
+ padding: 1rem 1.5rem;
+ border-radius: var(--radius);
+ background: var(--color-text);
+ color: white;
+ box-shadow: var(--shadow-lg);
+ animation: slideIn 0.3s ease;
+}
+
+.toast.success {
+ background: var(--color-success);
+}
+
+.toast.error {
+ background: var(--color-danger);
+}
+
+.toast.warning {
+ background: var(--color-warning);
+}
+
+@keyframes slideIn {
+ from {
+ transform: translateX(100%);
+ opacity: 0;
+ }
+ to {
+ transform: translateX(0);
+ opacity: 1;
+ }
+}
+
+/* Responsive */
+@media (max-width: 768px) {
+ .admin-nav {
+ flex-direction: column;
+ gap: 1rem;
+ }
+
+ .nav-links {
+ flex-wrap: wrap;
+ justify-content: center;
+ }
+
+ .admin-content {
+ padding: 1rem;
+ }
+
+ .panel-toolbar {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .search-bar {
+ flex-direction: column;
+ }
+
+ .search-bar input {
+ min-width: auto;
+ width: 100%;
+ }
+
+ .create-invite-form {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .data-table {
+ font-size: 0.875rem;
+ }
+
+ .data-table th,
+ .data-table td {
+ padding: 0.5rem;
+ }
+}
+
+/* Utility Classes */
+.text-muted {
+ color: var(--color-text-muted);
+}
+
+.text-success {
+ color: var(--color-success);
+}
+
+.text-danger {
+ color: var(--color-danger);
+}
+
+.text-warning {
+ color: var(--color-warning);
+}
+
+.text-small {
+ font-size: 0.875rem;
+}
+
+.mt-1 { margin-top: 0.5rem; }
+.mt-2 { margin-top: 1rem; }
+.mb-1 { margin-bottom: 0.5rem; }
+.mb-2 { margin-bottom: 1rem; }
diff --git a/client/admin.html b/client/admin.html
new file mode 100644
index 0000000..154a0ba
--- /dev/null
+++ b/client/admin.html
@@ -0,0 +1,368 @@
+
+
+
+
+
+ Golf Admin Dashboard
+
+
+
+
+
+
+
+
+
+
+
+
+
+ System Overview
+
+
+ -
+ Active Users (1h)
+
+
+ -
+ Active Games
+
+
+ -
+ Total Users
+
+
+ -
+ Games Today
+
+
+ -
+ Registrations Today
+
+
+ -
+ Registrations (7d)
+
+
+ -
+ Total Games
+
+
+ -
+ Events (1h)
+
+
+
+
+
Top Players
+
+
+
+ | # |
+ Username |
+ Wins |
+ Games |
+ Win Rate |
+
+
+
+
+
+
+
+
+
+ User Management
+
+
+
+
+ | Username |
+ Email |
+ Role |
+ Status |
+ Games |
+ Joined |
+ Actions |
+
+
+
+
+
+
+
+
+
+ Active Games
+
+
+
+
+ | Room Code |
+ Players |
+ Phase |
+ Round |
+ Status |
+ Created |
+ Actions |
+
+
+
+
+
+
+
+
+ Invite Codes
+
+
+
+
+ | Code |
+ Uses |
+ Remaining |
+ Created By |
+ Expires |
+ Status |
+ Actions |
+
+
+
+
+
+
+
+
+ Audit Log
+
+
+
+
+ | Time |
+ Admin |
+ Action |
+ Target |
+ Details |
+ IP |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Username:
+
+
+
+ Email:
+
+
+
+ Role:
+
+
+
+ Status:
+
+
+
+ Games Played:
+
+
+
+ Games Won:
+
+
+
+ Joined:
+
+
+
+ Last Login:
+
+
+
+
+
+
Actions
+
+
+
+
+
+
+
+
+
+
+
+
Ban History
+
+
+
+ | Date |
+ Reason |
+ By |
+ Status |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/admin.js b/client/admin.js
new file mode 100644
index 0000000..766057d
--- /dev/null
+++ b/client/admin.js
@@ -0,0 +1,809 @@
+/**
+ * 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();
+});
diff --git a/client/app.js b/client/app.js
index 1380840..3d91997 100644
--- a/client/app.js
+++ b/client/app.js
@@ -2257,4 +2257,197 @@ class GolfGame {
// Initialize game when page loads
document.addEventListener('DOMContentLoaded', () => {
window.game = new GolfGame();
+ window.auth = new AuthManager(window.game);
});
+
+
+// ===========================================
+// AUTH MANAGER
+// ===========================================
+
+class AuthManager {
+ constructor(game) {
+ this.game = game;
+ this.token = localStorage.getItem('authToken');
+ this.user = JSON.parse(localStorage.getItem('authUser') || 'null');
+
+ this.initElements();
+ this.bindEvents();
+ this.updateUI();
+ }
+
+ initElements() {
+ this.authBar = document.getElementById('auth-bar');
+ this.authUsername = document.getElementById('auth-username');
+ this.logoutBtn = document.getElementById('auth-logout-btn');
+ this.authButtons = document.getElementById('auth-buttons');
+ this.loginBtn = document.getElementById('login-btn');
+ this.signupBtn = document.getElementById('signup-btn');
+ this.modal = document.getElementById('auth-modal');
+ this.modalClose = document.getElementById('auth-modal-close');
+ this.loginFormContainer = document.getElementById('login-form-container');
+ this.loginForm = document.getElementById('login-form');
+ this.loginUsername = document.getElementById('login-username');
+ this.loginPassword = document.getElementById('login-password');
+ this.loginError = document.getElementById('login-error');
+ this.signupFormContainer = document.getElementById('signup-form-container');
+ this.signupForm = document.getElementById('signup-form');
+ this.signupUsername = document.getElementById('signup-username');
+ this.signupEmail = document.getElementById('signup-email');
+ this.signupPassword = document.getElementById('signup-password');
+ this.signupError = document.getElementById('signup-error');
+ this.showSignupLink = document.getElementById('show-signup');
+ this.showLoginLink = document.getElementById('show-login');
+ }
+
+ bindEvents() {
+ this.loginBtn?.addEventListener('click', () => this.showModal('login'));
+ this.signupBtn?.addEventListener('click', () => this.showModal('signup'));
+ this.modalClose?.addEventListener('click', () => this.hideModal());
+ this.modal?.addEventListener('click', (e) => {
+ if (e.target === this.modal) this.hideModal();
+ });
+ this.showSignupLink?.addEventListener('click', (e) => {
+ e.preventDefault();
+ this.showForm('signup');
+ });
+ this.showLoginLink?.addEventListener('click', (e) => {
+ e.preventDefault();
+ this.showForm('login');
+ });
+ this.loginForm?.addEventListener('submit', (e) => this.handleLogin(e));
+ this.signupForm?.addEventListener('submit', (e) => this.handleSignup(e));
+ this.logoutBtn?.addEventListener('click', () => this.logout());
+ }
+
+ showModal(form = 'login') {
+ this.modal.classList.remove('hidden');
+ this.showForm(form);
+ this.clearErrors();
+ }
+
+ hideModal() {
+ this.modal.classList.add('hidden');
+ this.clearForms();
+ }
+
+ showForm(form) {
+ if (form === 'login') {
+ this.loginFormContainer.classList.remove('hidden');
+ this.signupFormContainer.classList.add('hidden');
+ this.loginUsername.focus();
+ } else {
+ this.loginFormContainer.classList.add('hidden');
+ this.signupFormContainer.classList.remove('hidden');
+ this.signupUsername.focus();
+ }
+ }
+
+ clearForms() {
+ this.loginForm.reset();
+ this.signupForm.reset();
+ this.clearErrors();
+ }
+
+ clearErrors() {
+ this.loginError.textContent = '';
+ this.signupError.textContent = '';
+ }
+
+ async handleLogin(e) {
+ e.preventDefault();
+ this.clearErrors();
+
+ const username = this.loginUsername.value.trim();
+ const password = this.loginPassword.value;
+
+ try {
+ const response = await fetch('/api/auth/login', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ username, password }),
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ this.loginError.textContent = data.detail || 'Login failed';
+ return;
+ }
+
+ this.setAuth(data.token, data.user);
+ this.hideModal();
+
+ if (data.user.username && this.game.playerNameInput) {
+ this.game.playerNameInput.value = data.user.username;
+ }
+ } catch (err) {
+ this.loginError.textContent = 'Connection error';
+ }
+ }
+
+ async handleSignup(e) {
+ e.preventDefault();
+ this.clearErrors();
+
+ const username = this.signupUsername.value.trim();
+ const email = this.signupEmail.value.trim() || null;
+ const password = this.signupPassword.value;
+
+ try {
+ const response = await fetch('/api/auth/register', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ username, email, password }),
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ this.signupError.textContent = data.detail || 'Signup failed';
+ return;
+ }
+
+ this.setAuth(data.token, data.user);
+ this.hideModal();
+
+ if (data.user.username && this.game.playerNameInput) {
+ this.game.playerNameInput.value = data.user.username;
+ }
+ } catch (err) {
+ this.signupError.textContent = 'Connection error';
+ }
+ }
+
+ setAuth(token, user) {
+ this.token = token;
+ this.user = user;
+ localStorage.setItem('authToken', token);
+ localStorage.setItem('authUser', JSON.stringify(user));
+ this.updateUI();
+ }
+
+ logout() {
+ this.token = null;
+ this.user = null;
+ localStorage.removeItem('authToken');
+ localStorage.removeItem('authUser');
+ this.updateUI();
+ }
+
+ updateUI() {
+ if (this.user) {
+ this.authBar?.classList.remove('hidden');
+ this.authButtons?.classList.add('hidden');
+ if (this.authUsername) {
+ this.authUsername.textContent = this.user.username;
+ }
+ if (this.game.playerNameInput && !this.game.playerNameInput.value) {
+ this.game.playerNameInput.value = this.user.username;
+ }
+ } else {
+ this.authBar?.classList.add('hidden');
+ this.authButtons?.classList.remove('hidden');
+ }
+ }
+}
diff --git a/client/index.html b/client/index.html
index 8cfb1da..9b685c7 100644
--- a/client/index.html
+++ b/client/index.html
@@ -8,10 +8,22 @@
+
+
+
+
+
+
🏌️⚪ Golf
-
6-Card Golf Card Game
+
6-Card Golf Card Game
+
+
+
+
+
+
@@ -637,6 +649,83 @@ TOTAL: 0 + 8 + 16 = 24 points
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0 / 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -651,9 +740,53 @@ TOTAL: 0 + 8 + 16 = 24 points
+
+
+
+
+