+
+
+ {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 (
+
+
+
+
{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) => (
+ - {e.message}
+ ))}
+
+
+ )}
+
+ );
+}
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 {