From 69fbb015abe084771b4db4e3f1f83f68468392a6 Mon Sep 17 00:00:00 2001 From: Tronax Date: Mon, 17 Aug 2026 15:07:02 +0200 Subject: [PATCH] feat(admin): live admin dashboard with solo presence tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Solo games run entirely in the browser, so the server previously had no idea who was playing right now (it only saw logins and finished games). This adds lightweight presence reporting and an admin dashboard. Presence (server): - POST /api/presence (logged-in users): heartbeat while a solo game runs, stores username, map (validated), difficulty, wave, since/lastSeen per user; entries expire automatically after 90s without a heartbeat - POST /api/presence/stop: explicit removal when the player exits Admin API (ADMIN_USERS env, comma-separated usernames): - GET /api/admin/overview: live solo players, multiplayer room summaries (code, mode, map, players — the server already tracks rooms), and global stats (accounts, rounds, crystals in circulation) - GET /api/admin/users?limit=100: user list with stats, newest login first - publicUser now carries an admin flag; non-admins get 403 Admin UI (src/components/AdminDashboard.vue, served at /admin): - Login gate for guests/non-admins (guest profiles are detected via isLoggedIn, not just user presence) - KPI cards, live solo table (player, map, difficulty, wave, duration), room table, and account table; auto-refresh every 5 seconds - App.vue renders the dashboard for /admin instead of the game and runs a screen watcher that starts/stops the solo presence heartbeat Config: ADMIN_USERS documented in docker-compose.yml and README. Tests: 6 new integration checks (admin flag, presence report/stop, 403 guard, overview contents, user list) — 37/37 green, build clean. Verified end-to-end in the browser: guest gate, admin login, and a live second player (map/difficulty/wave) appearing in the dashboard. --- README.md | 11 + docker-compose.yml | 3 + scripts/test-auth.mjs | 57 +++- server/db.mjs | 49 ++++ server/server.mjs | 87 +++++++ src/App.vue | 23 +- src/components/AdminDashboard.vue | 416 ++++++++++++++++++++++++++++++ src/game/auth.ts | 36 +++ 8 files changed, 679 insertions(+), 3 deletions(-) create mode 100644 src/components/AdminDashboard.vue diff --git a/README.md b/README.md index e90dd1c..8548e7f 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,17 @@ npm run build npm run server # serves dist/ + ws auf :3001 ``` +### 🛡️ Admin-Dashboard (`/admin`) + +Live-Übersicht über den Server: wer gerade **Solo spielt** (Karte, Schwierigkeit, Welle — angemeldete Spieler senden dazu alle 20 s einen Heartbeat), offene **Multiplayer-Räume**, Account-**Statistiken** und die **Nutzerliste**. Aktualisiert sich alle 5 s automatisch. + +Freischalten über die Umgebungsvariable `ADMIN_USERS` (Komma-getrennte Benutzernamen): +```yaml +environment: + - ADMIN_USERS=deinaccount +``` +Dann als eines dieser Konten unter `https://deine-domain/admin` anmelden. Solo-Spiele laufen zwar lokal im Browser, aber angemeldete Spieler melden ihren Spielstatus an den Server; Gäste bleiben unsichtbar. + --- ## Spielmodi diff --git a/docker-compose.yml b/docker-compose.yml index 5562058..272b701 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,9 @@ services: - ./data:/app/data environment: - PORT=3001 + # --- Optional: Admin-Dashboard (/admin) --- + # Komma-getrennte Benutzernamen mit Admin-Zugriff auf das Live-Dashboard + # - ADMIN_USERS=deinaccount # --- Optional: OpenID Connect (Single Sign-On) --- # - OIDC_ENABLED=true # - OIDC_ISSUER=https://auth.example.com diff --git a/scripts/test-auth.mjs b/scripts/test-auth.mjs index ec63b3a..81af8ad 100644 --- a/scripts/test-auth.mjs +++ b/scripts/test-auth.mjs @@ -23,7 +23,7 @@ let child = null function startServer() { child = spawn(process.execPath, ['server/server.mjs'], { cwd: ROOT, - env: { ...process.env, PORT: String(PORT), DATA_DIR: tmp, OIDC_ENABLED: 'false' }, + env: { ...process.env, PORT: String(PORT), DATA_DIR: tmp, OIDC_ENABLED: 'false', ADMIN_USERS: 'siteadmin' }, stdio: ['ignore', 'ignore', 'pipe'], }) child.stderr.on('data', () => {}) // drain (experimental-warning etc.) @@ -247,6 +247,61 @@ try { is(json.user.upgrades.wave_bonus === 1 && json.user.crystals === 42, JSON.stringify(json)) }) + // ------------------------------------------------------- admin & presence + const admin = makeClient() + await check('Admin: ADMIN_USERS-Konto bekommt admin-Flag', async () => { + const { status, json } = await admin.req('POST', '/api/auth/register', { + username: 'siteadmin', + password: 'adminpass123', + displayName: 'Site Admin', + }) + is(status === 200 && json.user.admin === true, JSON.stringify(json)) + const me = await client.req('GET', '/api/auth/me') + is(me.json.user.admin === false, 'Normaler Account darf kein Admin sein') + }) + + await check('Presence: Solo-Spiel wird gemeldet', async () => { + const { status, json } = await client.req('POST', '/api/presence', { + mode: 'solo', + map: 'volcano', + difficulty: 'hard', + wave: 7, + }) + eq({ status, json }, { status: 200, json: { ok: true } }) + }) + + await check('Admin: Übersicht ohne Admin-Rechte -> 403', async () => { + const { status } = await client.req('GET', '/api/admin/overview') + is(status === 403, `status ${status}`) + }) + + await check('Admin: Übersicht zeigt Live-Solo-Spieler & Räume', async () => { + const { status, json } = await admin.req('GET', '/api/admin/overview') + is(status === 200, `status ${status}`) + const p = json.soloPlayers.find((x) => x.username === 'testuser') + if (!p) throw new Error('testuser nicht in soloPlayers') + is(p.map === 'volcano', `map ${p.map}`) + is(p.difficulty === 'hard', `difficulty ${p.difficulty}`) + is(p.wave === 7, `wave ${p.wave}`) + is(Array.isArray(json.rooms), 'rooms fehlen') + is(json.stats.users >= 2, `stats.users ${json.stats.users}`) + }) + + await check('Admin: Nutzerliste mit Statistiken', async () => { + const { status, json } = await admin.req('GET', '/api/admin/users') + is(status === 200 && json.users.length >= 2, JSON.stringify({ status, n: json.users?.length })) + const t = json.users.find((u) => u.username === 'testuser') + if (!t) throw new Error('testuser nicht in Nutzerliste') + is(t.stats.gamesPlayed === 3, `gamesPlayed ${t.stats.gamesPlayed}`) + }) + + await check('Presence: Stop entfernt Spieler aus Live-Übersicht', async () => { + const stop = await client.req('POST', '/api/presence/stop') + is(stop.status === 200, `status ${stop.status}`) + const { json } = await admin.req('GET', '/api/admin/overview') + is(!json.soloPlayers.some((x) => x.username === 'testuser'), 'testuser noch aktiv') + }) + await check('OIDC: Login-Endpunkt ohne Aktivierung -> 400', async () => { const { status, json } = await anon.req('GET', '/api/auth/oidc/login') is(status === 400 && json.error.includes('nicht aktiviert'), JSON.stringify(json)) diff --git a/server/db.mjs b/server/db.mjs index 90cda15..3be203c 100644 --- a/server/db.mjs +++ b/server/db.mjs @@ -316,3 +316,52 @@ export function recordGameResult(userId, { win, score, wave, kills, crystalsEarn throw e } } + +// ---------------------------------------------------------------- admin dashboard + +export function listUsers(limit = 100) { + const rows = db + .prepare( + `SELECT u.id, u.username, u.display_name, u.crystals, u.created_at, u.last_login, + s.games_played, s.games_won, s.total_kills, s.total_score, s.highest_wave + FROM users u + LEFT JOIN user_stats s ON s.user_id = u.id + ORDER BY u.last_login DESC + LIMIT ?`, + ) + .all(Math.max(1, Math.min(500, Math.floor(Number(limit) || 100)))) + return rows.map((r) => ({ + id: r.id, + username: r.username, + displayName: r.display_name, + crystals: r.crystals, + createdAt: r.created_at, + lastLogin: r.last_login, + stats: { + gamesPlayed: r.games_played ?? 0, + gamesWon: r.games_won ?? 0, + totalKills: r.total_kills ?? 0, + totalScore: r.total_score ?? 0, + highestWave: r.highest_wave ?? 0, + }, + })) +} + +export function globalStats() { + const r = db + .prepare( + `SELECT COUNT(*) AS users, + COALESCE(SUM(s.games_played), 0) AS gamesPlayed, + COALESCE(SUM(s.games_won), 0) AS gamesWon, + COALESCE(SUM(u.crystals), 0) AS crystalsInCirculation + FROM users u + LEFT JOIN user_stats s ON s.user_id = u.id`, + ) + .get() + return { + users: r.users, + gamesPlayed: r.gamesPlayed, + gamesWon: r.gamesWon, + crystalsInCirculation: r.crystalsInCirculation, + } +} diff --git a/server/server.mjs b/server/server.mjs index 80ee93a..54ff9d1 100644 --- a/server/server.mjs +++ b/server/server.mjs @@ -29,7 +29,9 @@ import { getSession, getUserById, getUserByUsername, + globalStats, hashPassword, + listUsers, mergeGuest, recordGameResult, verifyPassword, @@ -63,6 +65,51 @@ const oidcPending = new Map() let oidcDiscovery = null let oidcDiscoveryAt = 0 +// ------------------------------------------------------------------ admin & presence + +/** usernames (comma-separated via ADMIN_USERS env) allowed to open /admin */ +const ADMIN_USERS = String(process.env.ADMIN_USERS || '') + .toLowerCase() + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + +function isAdminUser(username) { + return ADMIN_USERS.includes(String(username || '').toLowerCase()) +} + +/** live solo presence: userId -> { username, displayName, map, difficulty, wave, since, lastSeen } */ +const soloPresence = new Map() +const PRESENCE_TTL_MS = 90 * 1000 + +function activeSoloPlayers() { + const now = Date.now() + const out = [] + for (const [userId, p] of soloPresence.entries()) { + if (now - p.lastSeen > PRESENCE_TTL_MS) { + soloPresence.delete(userId) // expired: missed heartbeats + continue + } + out.push({ userId, ...p }) + } + out.sort((a, b) => a.since - b.since) + return out +} + +function roomSummaries() { + const out = [] + for (const room of rooms.values()) { + out.push({ + code: room.code, + mode: room.mode, + mapId: room.mapId, + created: room.created, + players: room.players.map((p) => ({ id: p.id, name: p.name })), + }) + } + return out +} + // periodic session & oidc-state cleanup setInterval(() => { cleanExpiredSessions() @@ -206,6 +253,7 @@ function publicUser(user) { upgrades: user.upgrades, stats: user.stats, oidc: Boolean(user.oidc_sub), + admin: isAdminUser(user.username), } } @@ -402,6 +450,45 @@ async function handleApi(req, res, url) { const user = getUserById(sess.user_id) if (!user) return json(res, 401, { error: 'Nicht angemeldet.' }) + // --- live presence: solo players heartbeat here while playing --- + if (p === '/api/presence' && req.method === 'POST') { + const body = await readJsonBody(req).catch(() => null) + const now = Date.now() + const prev = soloPresence.get(user.id) + soloPresence.set(user.id, { + username: user.username, + displayName: user.display_name, + map: sanitizeMapId(String(body?.map || 'meadow')), + difficulty: String(body?.difficulty || 'normal').replace(/[^a-z_]/g, '').slice(0, 12) || 'normal', + wave: Math.max(0, Math.min(9999, Number(body?.wave) || 0)), + since: prev?.since ?? now, + lastSeen: now, + }) + return json(res, 200, { ok: true }) + } + + if (p === '/api/presence/stop' && req.method === 'POST') { + soloPresence.delete(user.id) + return json(res, 200, { ok: true }) + } + + // --- admin dashboard data (ADMIN_USERS only) --- + if (p.startsWith('/api/admin/')) { + if (!isAdminUser(user.username)) return json(res, 403, { error: 'Kein Administrator.' }) + if (p === '/api/admin/overview' && req.method === 'GET') { + return json(res, 200, { + soloPlayers: activeSoloPlayers(), + rooms: roomSummaries(), + stats: globalStats(), + }) + } + if (p === '/api/admin/users' && req.method === 'GET') { + const limit = Number(url.searchParams.get('limit')) || 100 + return json(res, 200, { users: listUsers(limit) }) + } + return json(res, 404, { error: 'Nicht gefunden.' }) + } + // --- buy meta upgrade --- if (p === '/api/upgrades/buy' && req.method === 'POST') { const body = await readJsonBody(req).catch(() => null) diff --git a/src/App.vue b/src/App.vue index 6106aad..72b3aec 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,4 +1,5 @@