From 46d1954a6d56f406f696d66af2e05a561a873ea2 Mon Sep 17 00:00:00 2001 From: MrEgosar Date: Sun, 25 Oct 2026 23:16:44 +0100 Subject: [PATCH] Admin-Modus: Verwaltung aller Matches unter /admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anmeldung mit ADMIN_PASSWORD (12 h, Sitzung nur im Speicher, Sperre nach 5 Fehlversuchen pro Minute). Alle Matches mit Codes, Servern, Spielern, Veto und Verlauf. Aktionen: Veto starten bzw. Schritte für beide Teams, Warmup beenden, nächste Map, Server stoppen/neu starten, abschließen, abbrechen, Spieler entfernen, Codes erneuern, Match löschen. Ohne ADMIN_PASSWORD ist der Admin-Modus komplett aus. --- client/src/App.jsx | 3 + client/src/pages/AdminPage.jsx | 248 ++++++++++++++++++++++++++++ client/src/styles.css | 23 +++ server/src/adminRoutes.js | 111 +++++++++++++ server/src/config.js | 2 + server/src/http.js | 2 + server/src/servers/serverManager.js | 11 ++ server/test/api.test.js | 46 ++++++ 8 files changed, 446 insertions(+) create mode 100644 client/src/pages/AdminPage.jsx create mode 100644 server/src/adminRoutes.js diff --git a/client/src/App.jsx b/client/src/App.jsx index 16fdd7d..7fb403c 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -6,6 +6,7 @@ import CreateMatch from './pages/CreateMatch.jsx'; import MatchPage from './pages/MatchPage.jsx'; import StreamPage from './pages/StreamPage.jsx'; import StreamOverviewPage from './pages/StreamOverviewPage.jsx'; +import AdminPage from './pages/AdminPage.jsx'; import { JoinPage, WatchPage } from './pages/AccessPages.jsx'; export default function App() { @@ -42,6 +43,7 @@ function MainLayout() { @@ -50,6 +52,7 @@ function MainLayout() { } /> } /> + } /> } /> } /> } /> diff --git a/client/src/pages/AdminPage.jsx b/client/src/pages/AdminPage.jsx new file mode 100644 index 0000000..d3c9237 --- /dev/null +++ b/client/src/pages/AdminPage.jsx @@ -0,0 +1,248 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { api } from '../lib/api.js'; +import { FORMAT_LABEL, serverLabel } from '../lib/labels.js'; +import CopyField from '../components/CopyField.jsx'; + +const KEY = 'cs2mv:admin'; +const REFRESH_MS = 3000; + +const getToken = () => { + try { + return sessionStorage.getItem(KEY); + } catch { + return null; + } +}; +const storeToken = (t) => { + try { + if (t) sessionStorage.setItem(KEY, t); + else sessionStorage.removeItem(KEY); + } catch { + /* ignorieren */ + } +}; + +export default function AdminPage() { + const [token, setToken] = useState(getToken); + const [matches, setMatches] = useState(null); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [busy, setBusy] = useState(null); + + const logout = useCallback(() => { + api('/admin/logout', { method: 'POST', token }).catch(() => {}); + storeToken(null); + setToken(null); + setMatches(null); + }, [token]); + + const load = useCallback(async () => { + try { + const res = await api('/admin/matches', { token }); + setMatches(res.matches); + setError(null); + } catch (err) { + if (err.status === 401) { + storeToken(null); + setToken(null); + } else { + setError(err.message); + } + } + }, [token]); + + useEffect(() => { + if (!token) return undefined; + load(); + const t = setInterval(load, REFRESH_MS); + return () => clearInterval(t); + }, [token, load]); + + useEffect(() => { + if (!notice) return undefined; + const t = setTimeout(() => setNotice(null), 5000); + return () => clearTimeout(t); + }, [notice]); + + if (!token) return { storeToken(t); setToken(t); }} />; + + /** Aktion ausführen; destruktive Aktionen müssen bestätigt werden. */ + const run = async ({ method = 'POST', path, body, confirm: question, success }) => { + if (question && !window.confirm(question)) return; + setBusy(path); + try { + await api(`/admin${path}`, { method, body, token }); + setNotice({ tone: 'ok', text: success ?? 'Erledigt' }); + load(); + } catch (err) { + setNotice({ tone: 'error', text: err.message }); + } finally { + setBusy(null); + } + }; + + return ( +
+
+
+

Admin

+

Alle Matches, Server und Teams. Aktualisiert sich alle 3 Sekunden.

+
+ +
+ + {notice &&
{notice.text}
} + {error &&
{error}
} + {matches === null && !error &&

Lade Matches …

} + {matches && matches.length === 0 &&

Noch keine Matches.

} + +
+ {matches?.map((m) => ( + + ))} +
+
+ ); +} + +function AdminLogin({ onLogin }) { + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const submit = async (e) => { + e.preventDefault(); + setBusy(true); + setError(null); + try { + const res = await api('/admin/login', { method: 'POST', body: { password } }); + onLogin(res.token); + } catch (err) { + setError(err.message); + setBusy(false); + } + }; + return ( +
+

Admin-Anmeldung

+

Das Passwort ist in der Konfiguration unter ADMIN_PASSWORD hinterlegt.

+ + {error &&
{error}
} + +

Zur Übersicht

+
+ ); +} + +function MatchAdminCard({ m, run, busy }) { + const [open, setOpen] = useState(false); + const server = serverLabel(m.server); + const closed = ['finished', 'cancelled'].includes(m.status); + const id = m.id; + const btn = (label, cfg, cls = '') => ( + + ); + const hasNext = m.series.some((s) => s.number === m.currentMapNumber + 1); + + return ( +
+
+
+ {m.teams.A.name} + vs. + {m.teams.B.name} +
+ {m.modeLabel}, {FORMAT_LABEL[m.format]} · {m.statusLabel} · {server.text} + {m.format !== 'bo1' && <> · Serie {m.seriesScore.A}:{m.seriesScore.B}} +
+
+ +
+ +
+ Ansehen + {m.status === 'lobby' && btn('Veto starten', { path: '/veto/start' }, 'btn-primary')} + {(m.server?.status === 'configured') && btn('Aufwärmphase beenden', { path: '/server/warmup-end' })} + {(m.server?.status === 'configured') && hasNext && btn('Nächste Map', { path: '/series/next' })} + {m.server && ['start_failed', 'failed', 'stopped', 'config_failed', 'ready'].includes(m.server.status) && !closed && + btn('Server neu starten / wiederholen', { path: '/server/retry' })} + {m.server && !['stopped'].includes(m.server.status) && btn('Server stoppen', { path: '/server/stop', confirm: 'Server stoppen?' })} + {!closed && btn('Match abschließen', { path: '/finish', confirm: 'Match als abgeschlossen markieren?' })} + {!closed && btn('Match abbrechen', { path: '/cancel', confirm: 'Match abbrechen?' }, 'btn-ghost danger')} + {btn('Löschen', { method: 'DELETE', path: '', confirm: `Match ${m.teams.A.name} vs. ${m.teams.B.name} endgültig löschen?`, success: 'Match gelöscht' }, 'btn-ghost danger')} +
+ + {open && ( +
+
+
+

Einladungen

+
Team A
+ +
Team B
+ +
Zuschauer
+ +
Stream-Ansicht
+ +
+ {['A', 'B', 'spectator'].map((t) => ( + + ))} +
+
+
+

Server

+ {m.server?.connect && } + {m.server?.gotv && } +

{m.server?.message ?? 'kein Server'}

+

Versuche: {m.server?.attempts ?? 0} · Spieler auf dem Server: {m.server?.playerCount ?? '–'}

+
+
+ +

Spieler

+
+ {['A', 'B'].map((t) => ( +
+
{m.teams[t].name} ({m.teams[t].readiness.count}/{m.teams[t].teamSize})
+
    + {m.teams[t].players.map((p) => ( +
  • + {p.nickname} {p.isCaptain && Captain} {p.ready && bereit} + {!closed && ( + + )} +
  • + ))} + {m.teams[t].players.length === 0 &&
  • niemand
  • } +
+
+ ))} +
+ + {m.veto && !m.veto.done && m.veto.current && ( +

Veto: {m.teams[m.veto.current.team].name} ist am Zug ({m.veto.current.type}).

+ )} + +

Verlauf

+
    + {m.events.slice(0, 15).map((e, i) => ( +
  1. {e.message}
  2. + ))} +
+
+ )} +
+ ); +} diff --git a/client/src/styles.css b/client/src/styles.css index 864873c..d5a0258 100644 --- a/client/src/styles.css +++ b/client/src/styles.css @@ -483,3 +483,26 @@ input:focus, select:focus { border-color: var(--link); outline: none; } @media (max-width: 640px) { .series-score { grid-column: 2; grid-row: 2; font-size: 1.75rem; } } + +/* ---------- Admin ---------- */ +.admin-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 20px; } +.admin-list { display: grid; gap: 14px; } +.admin-card { border-left: 4px solid var(--line-strong); } +.admin-card.status-live { border-left-color: var(--brand-red); } +.admin-card.status-veto { border-left-color: var(--pending); } +.admin-card-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; } +.admin-card-head strong { font: 700 1.5rem var(--display); } +.admin-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; align-items: center; } +.admin-details { margin-top: 16px; border-top: 1px solid var(--line); padding-top: 14px; display: grid; gap: 10px; } +.admin-details h3 { margin-top: 6px; } +.admin-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; } +.admin-players { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } +.admin-players ul { list-style: none; padding: 0; margin: 4px 0 0; } +.admin-players li { display: flex; gap: 10px; align-items: baseline; padding: 4px 0; border-bottom: 1px solid var(--line); } +.admin-players em { font-style: normal; color: var(--pending); font-size: .8125rem; } +.admin-log { list-style: none; padding: 0; margin: 0; max-height: 260px; overflow-y: auto; font-size: .875rem; } +.admin-log li { padding: 4px 0; border-bottom: 1px solid var(--line); } +.admin-log time { color: var(--muted); margin-right: 8px; } +.admin-login { margin-top: 40px; } +.danger { color: var(--error); } +@media (max-width: 640px) { .admin-players { grid-template-columns: 1fr; } } diff --git a/server/src/adminRoutes.js b/server/src/adminRoutes.js new file mode 100644 index 0000000..2196b6f --- /dev/null +++ b/server/src/adminRoutes.js @@ -0,0 +1,111 @@ +import express from 'express'; +import { HttpError } from './matchService.js'; +import { createRateLimiter, randomToken, safeEqual } from './security.js'; + +const SESSION_HOURS = 12; + +function bearer(req) { + const header = req.get('authorization') ?? ''; + return header.startsWith('Bearer ') ? header.slice(7).trim() : null; +} + +/** + * Admin-Modus unter /api/admin. Anmeldung mit ADMIN_PASSWORD, danach ein zufälliger Sitzungs-Token + * (nur im Speicher, verfällt nach 12 Stunden oder beim Neustart). Admin-Aktionen nutzen die bestehenden + * Organisator-Funktionen mit einem Admin-Zugang, die Berechtigungen bleiben also an einer Stelle. + */ +export function createAdminRouter({ service, manager, config }) { + const router = express.Router(); + const sessions = new Map(); // Token -> Ablaufzeit (ms) + const loginLimit = createRateLimiter({ windowMs: 60_000, max: 5 }); + + const wrap = (fn) => async (req, res, next) => { + try { + const result = await fn(req, res); + res.json(result ?? { ok: true }); + } catch (err) { + next(err); + } + }; + + const requireEnabled = (req, res, next) => { + if (config.adminPassword) return next(); + return next(new HttpError(404, 'Admin-Modus ist nicht eingerichtet (ADMIN_PASSWORD fehlt)', 'admin_disabled')); + }; + + const requireAdmin = (req, res, next) => { + const token = bearer(req); + const expires = token && sessions.get(token); + if (expires && expires > Date.now()) return next(); + if (token) sessions.delete(token); + return next(new HttpError(401, 'Admin-Anmeldung erforderlich', 'unauthorized')); + }; + + // Admin-Zugang für eine Match-Aktion: wie ein Organisator, bei Bedarf als Captain eines Teams + const adminViewer = (matchId, extra = {}) => ({ + matchId, + role: 'organizer', + isOrganizer: true, + isCaptain: false, + isSpectator: false, + playerId: null, + team: null, + ...extra, + }); + + router.use(requireEnabled); + + router.post('/login', (req, res, next) => { + if (!loginLimit(`admin:${req.ip}`)) { + return next(new HttpError(429, 'Zu viele Anmeldeversuche – bitte eine Minute warten', 'rate_limited')); + } + if (!safeEqual(String(req.body?.password ?? ''), config.adminPassword)) { + return next(new HttpError(401, 'Falsches Passwort', 'unauthorized')); + } + const token = randomToken(); + const expiresAt = Date.now() + SESSION_HOURS * 3_600_000; + sessions.set(token, expiresAt); + return res.json({ token, expiresAt }); + }); + + router.post('/logout', (req, res) => { + const token = bearer(req); + if (token) sessions.delete(token); + res.json({ ok: true }); + }); + + router.get('/session', requireAdmin, (req, res) => res.json({ ok: true })); + + // Alle Matches mit allen Details (Codes, Verbindungsdaten, Spieler, Veto, Serie, Ereignisse) + router.get('/matches', requireAdmin, wrap(() => { + const ids = service.db.prepare('SELECT id FROM matches ORDER BY created_at DESC LIMIT 100').all(); + return { matches: ids.map(({ id }) => service.snapshot(id, adminViewer(id))) }; + })); + + const action = (path, fn) => router.post(`/matches/:id/${path}`, requireAdmin, wrap((req) => { + service.getMatch(req.params.id); + return fn(adminViewer(req.params.id), req.body ?? {}, req); + })); + + action('finish', (v) => service.close(v, 'finished')); + action('cancel', (v) => service.close(v, 'cancelled')); + action('server/stop', (v) => manager.stopByOrganizer(v)); + action('server/retry', (v) => manager.retry(v)); + action('server/warmup-end', (v) => manager.endWarmup(v)); + action('series/next', (v) => manager.loadNextMap(v)); + action('veto/start', (v) => service.startVeto(v)); + action('veto/action', (v, b) => service.vetoAction( + adminViewer(v.matchId, { team: b.team, isCaptain: true, role: 'captain' }), + { stepIndex: b.stepIndex, map: b.map, side: b.side }, + )); + action('kick', (v, b) => service.kick(v, b.playerId)); + action('codes/regenerate', (v, b) => service.regenerateCode(v, b.target)); + + // Match endgültig löschen (Server wird vorher freigegeben) + router.delete('/matches/:id', requireAdmin, wrap(async (req) => { + await manager.deleteMatch(req.params.id); + return { ok: true }; + })); + + return router; +} diff --git a/server/src/config.js b/server/src/config.js index 950e631..2a2cf1d 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -41,6 +41,8 @@ export function loadConfig(overrides = {}) { createMatchPassword: str('CREATE_MATCH_PASSWORD'), // Optionaler Schlüssel für die Stream-Übersicht aller Matches (/stream?key=…); leer = offen streamKey: str('STREAM_KEY'), + // Passwort für den Admin-Modus (/admin). Leer = Admin-Modus ist aus. + adminPassword: str('ADMIN_PASSWORD'), requireFullTeams: bool('REQUIRE_FULL_TEAMS', false), trustProxy: bool('TRUST_PROXY', false), diff --git a/server/src/http.js b/server/src/http.js index c8fc8a6..20badc7 100644 --- a/server/src/http.js +++ b/server/src/http.js @@ -2,6 +2,7 @@ import express from 'express'; import { HttpError } from './matchService.js'; import { publicPools } from './mapPools.js'; import { createRateLimiter, safeEqual } from './security.js'; +import { createAdminRouter } from './adminRoutes.js'; function bearer(req) { const header = req.get('authorization') ?? ''; @@ -31,6 +32,7 @@ export function createApiRouter({ service, manager, config, pools, adapter }) { }); router.use(express.json({ limit: '32kb' })); + router.use('/admin', createAdminRouter({ service, manager, config })); const limiters = { create: createRateLimiter({ windowMs: 60_000, max: 10 }), diff --git a/server/src/servers/serverManager.js b/server/src/servers/serverManager.js index c5fe199..70be4ec 100644 --- a/server/src/servers/serverManager.js +++ b/server/src/servers/serverManager.js @@ -86,6 +86,17 @@ export class ServerManager { this.timers.push(setInterval(() => this.reap().catch((e) => this.logger.error(e)), this.config.reaperIntervalSeconds * 1000)); } + /** Löscht ein Match samt Server-Datensatz. Ein laufender Server wird vorher freigegeben. */ + async deleteMatch(matchId) { + this.service.getMatch(matchId); + clearTimeout(this.autoAdvance.get(matchId)); + this.autoAdvance.delete(matchId); + this.simulator.stop(matchId); + await this.stop(matchId, 'Match vom Admin gelöscht'); + this.db.prepare('DELETE FROM matches WHERE id = ?').run(matchId); + this.service.emit('changed', matchId); + } + /** Wartet, bis keine Server-Aktion mehr läuft (höchstens timeoutMs). */ async idle(timeoutMs = 5000) { const deadline = Date.now() + timeoutMs; diff --git a/server/test/api.test.js b/server/test/api.test.js index 30b16dd..c6d396b 100644 --- a/server/test/api.test.js +++ b/server/test/api.test.js @@ -377,6 +377,52 @@ test('Stream-Übersicht aller Matches: Inhalte, keine Geheimnisse, optionaler Sc } }); +test('Admin-Modus: Anmeldung, Verwaltung aller Matches, Sperre ohne Anmeldung', async () => { + const { app, call, close } = await setup({ adminPassword: 'Admin123' }); + try { + const { id } = (await call('POST', '/matches', { + body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo1' }, + })).body; + const adminListed = (await call('GET', '/admin/matches')).body; // ohne Anmeldung + assert.equal(adminListed.error, 'Admin-Anmeldung erforderlich'); + + assert.equal((await call('POST', '/admin/login', { body: { password: 'falsch' } })).status, 401); + const login = (await call('POST', '/admin/login', { body: { password: 'Admin123' } })).body; + const admin = { token: login.token }; + assert.ok(login.token); + + const list = (await call('GET', '/admin/matches', admin)).body.matches; + const mine = list.find((m) => m.id === id); + assert.ok(mine, 'alle Matches sichtbar, nicht nur die eigenen'); + assert.ok(mine.organizer.codes.A, 'Codes sichtbar'); + + // Ein Match abbrechen wie ein Admin + assert.equal((await call('POST', `/admin/matches/${id}/cancel`, { ...admin, body: {} })).status, 200); + assert.equal(app.service.getMatch(id).status, 'cancelled'); + + // Endgültig löschen + assert.equal((await call('DELETE', `/admin/matches/${id}`, admin)).status, 200); + assert.equal((await call('GET', `/matches/${id}`)).status, 404); + + // Abmelden: Token ist danach ungültig + await call('POST', '/admin/logout', admin); + assert.equal((await call('GET', '/admin/session', admin)).status, 401); + } finally { + await close(); + } +}); + +test('Admin-Modus ohne ADMIN_PASSWORD ist komplett aus', async () => { + const { call, close } = await setup({ adminPassword: '' }); + try { + const res = await call('POST', '/admin/login', { body: { password: 'x' } }); + assert.equal(res.status, 404); + assert.equal(res.body.code, 'admin_disabled'); + } finally { + await close(); + } +}); + test('Spieler verlassen die Lobby, Captain geht über', async () => { const { call, close } = await setup(); try {