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:
1 parent
30ec5359b1
commit
46d1954a6d
8 files changed
+446
No files matched your search
@@ -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() {
|
||||
<nav>
|
||||
<Link to="/" className="nav-link">Übersicht</Link>
|
||||
<a href="/stream" target="_blank" rel="noreferrer" className="nav-link">Stream-Übersicht</a>
|
||||
<Link to="/admin" className="nav-link">Admin</Link>
|
||||
<Link to="/neu" className="btn btn-primary btn-sm">Match erstellen</Link>
|
||||
</nav>
|
||||
</header>
|
||||
@@ -50,6 +52,7 @@ function MainLayout() {
|
||||
<Routes>
|
||||
<Route path="/" element={<Overview />} />
|
||||
<Route path="/neu" element={<CreateMatch config={config} />} />
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
<Route path="/m/:id" element={<MatchPage />} />
|
||||
<Route path="/join/:code" element={<JoinPage config={config} />} />
|
||||
<Route path="/watch/:code" element={<WatchPage />} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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; } }
|
||||
Reference in new issue
Block a user