Admin-Modus: Verwaltung aller Matches unter /admin

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.
This commit is contained in:
mregosar committed 2026-10-25 23:16:44 +01:00
1 parent 30ec5359b1
commit 46d1954a6d
8 files changed
+446

No files matched your search

+248
View File
@@ -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 <AdminLogin onLogin={(t) => { 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 (
<div className="admin">
<header className="admin-head">
<div>
<h1>Admin</h1>
<p className="muted small">Alle Matches, Server und Teams. Aktualisiert sich alle 3 Sekunden.</p>
</div>
<button className="btn btn-sm" onClick={logout}>Abmelden</button>
</header>
{notice && <div className={`toast toast-${notice.tone}`} role="status">{notice.text}</div>}
{error && <div className="alert alert-error">{error}</div>}
{matches === null && !error && <p className="muted">Lade Matches …</p>}
{matches && matches.length === 0 && <p className="muted">Noch keine Matches.</p>}
<div className="admin-list">
{matches?.map((m) => (
<MatchAdminCard key={m.id} m={m} run={run} busy={busy} />
))}
</div>
</div>
);
}
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 (
<form className="create admin-login" onSubmit={submit}>
<h1>Admin-Anmeldung</h1>
<p className="lead">Das Passwort ist in der Konfiguration unter <code>ADMIN_PASSWORD</code> hinterlegt.</p>
<label className="field">
<span>Passwort</span>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoFocus autoComplete="current-password" required />
</label>
{error && <div className="alert alert-error">{error}</div>}
<button className="btn btn-primary btn-lg" disabled={busy}>{busy ? 'Melde an …' : 'Anmelden'}</button>
<p className="small muted"><Link to="/">Zur Übersicht</Link></p>
</form>
);
}
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 = '') => (
<button className={`btn btn-sm ${cls}`} disabled={!!busy} onClick={() => run({ ...cfg, path: `/matches/${id}${cfg.path}` })}>
{label}
</button>
);
const hasNext = m.series.some((s) => s.number === m.currentMapNumber + 1);
return (
<section className={`panel admin-card status-${m.status}`}>
<div className="admin-card-head">
<div>
<strong className="team-a">{m.teams.A.name}</strong>
<span className="vs"> vs. </span>
<strong className="team-b">{m.teams.B.name}</strong>
<div className="small muted">
{m.modeLabel}, {FORMAT_LABEL[m.format]} · {m.statusLabel} · <span className={`tone-${server.tone}`}>{server.text}</span>
{m.format !== 'bo1' && <> · Serie {m.seriesScore.A}:{m.seriesScore.B}</>}
</div>
</div>
<button className="link-btn" onClick={() => setOpen(!open)}>{open ? 'Weniger' : 'Details'}</button>
</div>
<div className="admin-actions">
<Link className="btn btn-sm" to={`/m/${id}`}>Ansehen</Link>
{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')}
</div>
{open && (
<div className="admin-details">
<div className="admin-grid">
<div>
<h3>Einladungen</h3>
<div className="small muted">Team A</div>
<CopyField value={m.organizer.links.A} />
<div className="small muted">Team B</div>
<CopyField value={m.organizer.links.B} />
<div className="small muted">Zuschauer</div>
<CopyField value={m.organizer.links.spectator} />
<div className="small muted">Stream-Ansicht</div>
<CopyField value={m.organizer.links.stream} />
<div className="row">
{['A', 'B', 'spectator'].map((t) => (
<button key={t} className="link-btn" disabled={!!busy}
onClick={() => run({ path: `/matches/${id}/codes/regenerate`, body: { target: t }, confirm: 'Neuer Code? Der alte Link wird ungültig.', success: 'Neuer Code erzeugt' })}>
Code {t === 'spectator' ? 'Zuschauer' : t} erneuern
</button>
))}
</div>
</div>
<div>
<h3>Server</h3>
{m.server?.connect && <CopyField label="Spieler-Verbindung" value={m.server.connect.command} />}
{m.server?.gotv && <CopyField label="CSTV" value={m.server.gotv.command} />}
<p className="small muted">{m.server?.message ?? 'kein Server'}</p>
<p className="small muted">Versuche: {m.server?.attempts ?? 0} · Spieler auf dem Server: {m.server?.playerCount ?? '–'}</p>
</div>
</div>
<h3>Spieler</h3>
<div className="admin-players">
{['A', 'B'].map((t) => (
<div key={t}>
<div className="small muted">{m.teams[t].name} ({m.teams[t].readiness.count}/{m.teams[t].teamSize})</div>
<ul>
{m.teams[t].players.map((p) => (
<li key={p.id}>
{p.nickname} {p.isCaptain && <em>Captain</em>} {p.ready && <span className="tone-ok">bereit</span>}
{!closed && (
<button className="link-btn danger" disabled={!!busy}
onClick={() => run({ path: `/matches/${id}/kick`, body: { playerId: p.id }, confirm: `${p.nickname} entfernen?`, success: `${p.nickname} entfernt` })}>
Entfernen
</button>
)}
</li>
))}
{m.teams[t].players.length === 0 && <li className="muted">niemand</li>}
</ul>
</div>
))}
</div>
{m.veto && !m.veto.done && m.veto.current && (
<p className="small">Veto: {m.teams[m.veto.current.team].name} ist am Zug ({m.veto.current.type}).</p>
)}
<h3>Verlauf</h3>
<ol className="admin-log">
{m.events.slice(0, 15).map((e, i) => (
<li key={i}><time>{new Date(e.at).toLocaleTimeString('de-DE')}</time> {e.message}</li>
))}
</ol>
</div>
)}
</section>
);
}