Files
CC-Manager/web/src/pages/Admin.tsx
T
Harlekin 2527f6a240 Erstzugang schuetzen, Kettenstatus richtigstellen, CCs nachtraeglich aendern
Der Zugang aus der Erstinstallation ist ab Werk unantastbar: weder Rolle noch
Zustand lassen sich aendern, sein Passwort nicht von aussen zuruecksetzen, und
loeschen laesst er sich nicht - auch nicht durch sich selbst. So bleibt ein
Weg zurueck, wenn sich jemand aus Versehen die Rechte entzieht. Sein eigenes
Passwort aendert er weiterhin unter "Konto". BOOTSTRAP_ADMIN_EDITABLE=true
hebt den Schutz auf. Welcher Zugang gemeint ist, traegt der Start nach: den
Namen kennt nur er, die Migration nicht.

Ein CC behielt nach dem Verschieben seines Redesigns den Vermerk "Redesignt",
obwohl ueber ihm nichts mehr stand. Der Vermerk stammte von einem Nachfolger,
der inzwischen woanders lag. Jetzt folgt er der Kette: wer einen Nachfolger
hat, wurde ersetzt - wer ans Ende rueckt, nicht mehr. Ein Platz entsteht
dabei nicht aus dem Nichts, die Deckung wird geprueft wie sonst auch.

Neu bearbeitbar: Name, Beschreibung und der verknuepfte Forums-Antrag eines
CCs. Ein Antrag, den der Abgleich noch nicht kennt, wird dabei vorgemerkt -
sonst staende er beim naechsten Lauf als offener Antrag da, obwohl er laengst
eingetragen ist. Ebenso laesst sich nachtraeglich umstellen, ob ein Redesign
bezahlt oder Kulanz war; umgebucht wird ueber eine Gegenbuchung, ueberschrieben
wird nichts.

Zuletzt: Eintraege eines anderen Editors kamen nicht an, solange die Seite
offen stand. Jetzt wird der Stand geholt, sobald der Tab wieder in den
Vordergrund kommt - das trifft den Moment, in dem jemand wieder hinsieht,
ohne im Hintergrund dauernd zu fragen.
2026-09-21 14:36:26 +02:00

805 lines
25 KiB
TypeScript

import { useCallback, useEffect, useState } from 'react';
import Modal from '../components/Modal';
import Notice from '../components/Notice';
import { ApiError, api } from '../lib/api';
import { formatDate } from '../lib/format';
import { useSession } from '../lib/session';
import type {
AdminUser,
AuditEntry,
ProductMapping,
ProductTarget,
Role,
ScheduleSetting,
ScheduleState,
} from '../lib/types';
type Tab = 'users' | 'mappings' | 'audit' | 'schedule';
const TARGET_LABEL: Record<ProductTarget, string> = {
CC_PACKAGE: 'CC-Paket',
REDESIGN_PACKAGE: 'Redesign-Paket',
IGNORE: 'Ignorieren',
};
export default function Admin() {
const [tab, setTab] = useState<Tab>('users');
const [error, setError] = useState<string | null>(null);
const [flash, setFlash] = useState<string | null>(null);
const notify = (message: string) => {
setFlash(message);
window.setTimeout(() => setFlash(null), 4000);
};
return (
<div className="page">
<div className="page-head">
<h1>Verwaltung</h1>
</div>
<div className="tabs">
<button
type="button"
className={tab === 'users' ? 'tab is-active' : 'tab'}
onClick={() => setTab('users')}
>
Nutzer
</button>
<button
type="button"
className={tab === 'mappings' ? 'tab is-active' : 'tab'}
onClick={() => setTab('mappings')}
>
Produkt-Mappings
</button>
<button
type="button"
className={tab === 'audit' ? 'tab is-active' : 'tab'}
onClick={() => setTab('audit')}
>
Audit-Log
</button>
<button
type="button"
className={tab === 'schedule' ? 'tab is-active' : 'tab'}
onClick={() => setTab('schedule')}
>
Zeitplan
</button>
</div>
{error && <Notice kind="error" onDismiss={() => setError(null)}>{error}</Notice>}
{flash && <Notice kind="success" onDismiss={() => setFlash(null)}>{flash}</Notice>}
{tab === 'users' && <UsersTab onError={setError} onDone={notify} />}
{tab === 'mappings' && <MappingsTab onError={setError} onDone={notify} />}
{tab === 'audit' && <AuditTab onError={setError} />}
{tab === 'schedule' && <ScheduleTab onError={setError} onDone={notify} />}
</div>
);
}
interface TabProps {
onError: (message: string) => void;
onDone: (message: string) => void;
}
function UsersTab({ onError, onDone }: TabProps) {
const { user: me } = useSession();
const [users, setUsers] = useState<AdminUser[]>([]);
const [creating, setCreating] = useState(false);
const load = useCallback(async () => {
try {
const result = await api<{ items: AdminUser[] }>('/api/admin/users');
setUsers(result.items);
} catch (err) {
onError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen');
}
}, [onError]);
useEffect(() => {
void load();
}, [load]);
const act = async (action: () => Promise<unknown>, message: string) => {
try {
await action();
await load();
onDone(message);
} catch (err) {
onError(err instanceof ApiError ? err.message : 'Aktion fehlgeschlagen');
}
};
const resetPassword = (target: AdminUser) => {
const password = window.prompt(
`Neues Passwort für "${target.username}" (min. 12 Zeichen, Groß/Klein, Ziffer):`,
);
if (!password) return;
void act(
() =>
api(`/api/admin/users/${target.id}/reset-password`, {
method: 'POST',
body: { newPassword: password },
}),
`Passwort für "${target.username}" gesetzt. Muss beim nächsten Login geändert werden.`,
);
};
return (
<section className="block">
<div className="block-head">
<h2>Nutzer</h2>
<button type="button" className="btn btn-primary" onClick={() => setCreating(true)}>
Nutzer anlegen
</button>
</div>
<Notice kind="info">
Es gibt keine öffentliche Registrierung. Konten werden ausschließlich hier angelegt.
</Notice>
<div className="table-wrap">
<table className="table">
<thead>
<tr>
<th>Benutzername</th>
<th>Rolle</th>
<th>Status</th>
<th>2FA</th>
<th>Letzter Login</th>
<th />
</tr>
</thead>
<tbody>
{users.map((row) => (
<tr key={row.id} className={row.isActive ? undefined : 'is-skipped'}>
<td>
<strong>{row.username}</strong>
{row.id === me?.id && <span className="badge">du</span>}
{row.isLocked && (
<span
className="badge"
title="Zugang aus der Erstinstallation - geschützt, damit immer ein Weg zurück bleibt"
>
geschützt
</span>
)}
{row.mustChangePassword && <span className="badge badge-paid">Erstpasswort</span>}
</td>
<td>
<select
value={row.role}
disabled={row.id === me?.id || row.isLocked === true}
onChange={(event) =>
void act(
() =>
api(`/api/admin/users/${row.id}`, {
method: 'PATCH',
body: { role: event.target.value as Role },
}),
`Rolle von "${row.username}" geändert.`,
)
}
>
<option value="VIEWER">Viewer</option>
<option value="EDITOR">Editor</option>
<option value="ADMIN">Admin</option>
</select>
</td>
<td>{row.isActive ? 'aktiv' : 'gesperrt'}</td>
<td className="muted">{row.totpEnabled ? 'ja' : 'nein'}</td>
<td className="muted small">{formatDate(row.lastLoginAt)}</td>
<td className="row-actions">
{row.isLocked ? (
<span className="muted small">
{row.id === me?.id
? 'Passwort änderst du unter Konto'
: 'Erstzugang - unantastbar'}
</span>
) : (
<button
type="button"
className="btn btn-tiny btn-ghost"
onClick={() => resetPassword(row)}
>
Passwort
</button>
)}
{row.id !== me?.id && !row.isLocked && (
<>
<button
type="button"
className="btn btn-tiny btn-ghost"
onClick={() =>
void act(
() =>
api(`/api/admin/users/${row.id}`, {
method: 'PATCH',
body: { isActive: !row.isActive },
}),
row.isActive
? `"${row.username}" gesperrt.`
: `"${row.username}" entsperrt.`,
)
}
>
{row.isActive ? 'Sperren' : 'Entsperren'}
</button>
<button
type="button"
className="btn btn-tiny btn-danger"
onClick={() => {
if (!window.confirm(`Konto "${row.username}" wirklich löschen?`)) return;
void act(
() => api(`/api/admin/users/${row.id}`, { method: 'DELETE' }),
`"${row.username}" gelöscht.`,
);
}}
>
Löschen
</button>
</>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{creating && (
<CreateUserModal
onClose={() => setCreating(false)}
onCreated={(username) => {
setCreating(false);
void load();
onDone(`Konto "${username}" angelegt.`);
}}
onError={onError}
/>
)}
</section>
);
}
function CreateUserModal({
onClose,
onCreated,
onError,
}: {
onClose: () => void;
onCreated: (username: string) => void;
onError: (message: string) => void;
}) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [role, setRole] = useState<Role>('EDITOR');
const [busy, setBusy] = useState(false);
const submit = async (event: React.FormEvent) => {
event.preventDefault();
setBusy(true);
try {
await api('/api/admin/users', { method: 'POST', body: { username, password, role } });
onCreated(username);
} catch (err) {
onError(err instanceof ApiError ? err.message : 'Anlegen fehlgeschlagen');
} finally {
setBusy(false);
}
};
return (
<Modal title="Nutzer anlegen" onClose={onClose}>
<form onSubmit={submit}>
<label htmlFor="new-username">Benutzername</label>
<input
id="new-username"
value={username}
onChange={(event) => setUsername(event.target.value)}
autoFocus
required
/>
<label htmlFor="new-password">Erstpasswort</label>
<input
id="new-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
<p className="muted small">
Min. 12 Zeichen, Groß- und Kleinbuchstaben, eine Ziffer. Muss beim ersten Login
geändert werden.
</p>
<label htmlFor="new-role">Rolle</label>
<select
id="new-role"
value={role}
onChange={(event) => setRole(event.target.value as Role)}
>
<option value="VIEWER">Viewer - nur lesen</option>
<option value="EDITOR">Editor - Spieler, Guthaben, CCs</option>
<option value="ADMIN">Admin - alles inkl. Import und Nutzer</option>
</select>
<div className="form-actions">
<button type="button" className="btn btn-ghost" onClick={onClose}>
Abbrechen
</button>
<button type="submit" className="btn btn-primary" disabled={busy}>
{busy ? 'Lege an …' : 'Anlegen'}
</button>
</div>
</form>
</Modal>
);
}
function MappingsTab({ onError, onDone }: TabProps) {
const [items, setItems] = useState<ProductMapping[]>([]);
const [pattern, setPattern] = useState('');
const [target, setTarget] = useState<ProductTarget>('CC_PACKAGE');
const load = useCallback(async () => {
try {
const result = await api<{ items: ProductMapping[] }>('/api/admin/product-mappings');
setItems(result.items);
} catch (err) {
onError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen');
}
}, [onError]);
useEffect(() => {
void load();
}, [load]);
const add = async (event: React.FormEvent) => {
event.preventDefault();
try {
await api('/api/admin/product-mappings', {
method: 'POST',
body: { matchPattern: pattern, target },
});
setPattern('');
await load();
onDone('Mapping angelegt.');
} catch (err) {
onError(err instanceof ApiError ? err.message : 'Anlegen fehlgeschlagen');
}
};
const remove = async (row: ProductMapping) => {
if (!window.confirm(`Mapping "${row.matchPattern}" löschen?`)) return;
try {
await api(`/api/admin/product-mappings/${row.id}`, { method: 'DELETE' });
await load();
onDone('Mapping gelöscht.');
} catch (err) {
onError(err instanceof ApiError ? err.message : 'Löschen fehlgeschlagen');
}
};
return (
<section className="block">
<h2>Produkt-Mappings</h2>
<Notice kind="info">
Beim Import wird der Produktname gegen diese Muster geprüft (Teiltreffer, Groß- und
Kleinschreibung egal). Das erste passende Muster mit der niedrigsten Priorität gewinnt.
</Notice>
<form className="inline-form" onSubmit={add}>
<input
placeholder="Muster, z. B. cc-paket"
value={pattern}
onChange={(event) => setPattern(event.target.value)}
required
/>
<select value={target} onChange={(event) => setTarget(event.target.value as ProductTarget)}>
<option value="CC_PACKAGE">CC-Paket</option>
<option value="REDESIGN_PACKAGE">Redesign-Paket</option>
<option value="IGNORE">Ignorieren</option>
</select>
<button type="submit" className="btn btn-primary">
Hinzufügen
</button>
</form>
<div className="table-wrap">
<table className="table">
<thead>
<tr>
<th>Muster</th>
<th>Bedeutung</th>
<th className="num">Priorität</th>
<th>Aktiv</th>
<th />
</tr>
</thead>
<tbody>
{items.map((row) => (
<tr key={row.id} className={row.isActive ? undefined : 'is-skipped'}>
<td className="mono">{row.matchPattern}</td>
<td>{TARGET_LABEL[row.target]}</td>
<td className="num muted">{row.priority}</td>
<td className="muted">{row.isActive ? 'ja' : 'nein'}</td>
<td className="row-actions">
<button
type="button"
className="btn btn-tiny btn-danger"
onClick={() => void remove(row)}
>
Löschen
</button>
</td>
</tr>
))}
{items.length === 0 && (
<tr>
<td colSpan={5} className="empty">
Noch keine Mappings.
</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
);
}
function AuditTab({ onError }: { onError: (message: string) => void }) {
const [items, setItems] = useState<AuditEntry[]>([]);
const [total, setTotal] = useState(0);
const [filter, setFilter] = useState('');
useEffect(() => {
const controller = new AbortController();
const timer = setTimeout(async () => {
try {
const params = new URLSearchParams({ take: '150' });
if (filter) params.set('action', filter);
const result = await api<{ items: AuditEntry[]; total: number }>(
`/api/admin/audit?${params}`,
{ signal: controller.signal },
);
setItems(result.items);
setTotal(result.total);
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return;
onError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen');
}
}, 250);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [filter, onError]);
return (
<section className="block">
<h2>Audit-Log</h2>
<input
className="search"
placeholder="Nach Aktion filtern, z. B. credit. oder character."
value={filter}
onChange={(event) => setFilter(event.target.value)}
/>
<div className="table-wrap scroll">
<table className="table small">
<thead>
<tr>
<th>Zeitpunkt</th>
<th>Wer</th>
<th>Aktion</th>
<th>Ziel</th>
<th>Details</th>
</tr>
</thead>
<tbody>
{items.map((entry) => (
<tr key={entry.id}>
<td className="muted small">{formatDate(entry.createdAt)}</td>
<td>{entry.actor?.username ?? entry.actorName ?? '-'}</td>
<td className="mono">{entry.action}</td>
<td className="muted">
{entry.targetType}
{entry.targetId ? ` #${entry.targetId}` : ''}
</td>
<td className="muted small details">
{entry.details ? JSON.stringify(entry.details) : '-'}
</td>
</tr>
))}
{items.length === 0 && (
<tr>
<td colSpan={5} className="empty">
Keine Einträge.
</td>
</tr>
)}
</tbody>
</table>
</div>
{total > items.length && (
<p className="muted">
{items.length} von {total} Einträgen angezeigt.
</p>
)}
</section>
);
}
/**
* Zeitplan: was von allein laufen soll und wie oft.
*
* Die Abstände stehen in Minuten, weil sich damit alles ausdrücken lässt -
* daneben steht, was das in Stunden bedeutet, damit niemand im Kopf rechnen
* muss. Beide Abgleiche greifen auf fremde Server zu; ein sehr kurzer Takt
* ist deshalb keine gute Idee und unter einer Viertelstunde nicht möglich.
*/
function ScheduleTab({
onError,
onDone,
}: {
onError: (message: string) => void;
onDone: (message: string) => void;
}) {
const [stand, setStand] = useState<ScheduleState | null>(null);
const laden = useCallback(async () => {
try {
setStand(await api<ScheduleState>('/api/admin/schedule'));
} catch (err) {
onError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen');
}
}, [onError]);
useEffect(() => {
void laden();
}, [laden]);
const LABEL: Record<string, string> = {
gextension: 'Käufe bei GExtension',
forum: 'Forums-Anträge',
letzter_fehler: 'Zuletzt gescheitert',
};
if (stand === null) {
return (
<section className="block">
<h2>Zeitplan</h2>
<p className="muted">Lade …</p>
</section>
);
}
return (
<>
<section className="block">
<h2>Zeitplan</h2>
<Notice kind="info">
Gebucht wird hier nichts von allein: neue Pakete landen in der Import-Vorschau, neue
Anträge in der Liste. Beide Abgleiche greifen auf Server zu, die der Community gehören -
ein knapper Takt bringt wenig und belastet sie ohne Not.
</Notice>
{stand.settings.map((setting) => (
<ScheduleForm
key={setting.name}
setting={setting}
titel={LABEL[setting.name] ?? setting.name}
onSaved={(text) => {
onDone(text);
void laden();
}}
onError={onError}
/>
))}
</section>
<section className="block">
<h2>Letzte Läufe</h2>
{stand.runs.length === 0 ? (
<p className="muted">Noch kein Lauf verzeichnet.</p>
) : (
<div className="table-wrap">
<table className="table">
<thead>
<tr>
<th>Lauf</th>
<th>Zuletzt</th>
<th>Ergebnis</th>
</tr>
</thead>
<tbody>
{stand.runs.map((run) => (
<tr key={run.name}>
<td>{LABEL[run.name] ?? run.name}</td>
<td className="muted small">{formatDate(run.lastRunAt)}</td>
<td className="muted small">
{run.lastStatus === 'FAILED' && <span className="neg">Fehler: </span>}
{run.lastNote ?? '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</>
);
}
/** Minuten in eine Angabe, die sich lesen lässt. */
function inStunden(minuten: number): string {
if (minuten < 60) return `${minuten} Minuten`;
if (minuten % 1440 === 0) {
const tage = minuten / 1440;
return tage === 1 ? 'einmal täglich' : `alle ${tage} Tage`;
}
const stunden = minuten / 60;
const text = Number.isInteger(stunden) ? String(stunden) : stunden.toFixed(1).replace('.', ',');
return `${text} Stunden`;
}
function ScheduleForm({
setting,
titel,
onSaved,
onError,
}: {
setting: ScheduleSetting;
titel: string;
onSaved: (message: string) => void;
onError: (message: string) => void;
}) {
const [enabled, setEnabled] = useState(setting.enabled);
const [interval, setInterval] = useState(String(setting.intervalMinutes));
const [idleInterval, setIdleInterval] = useState(
setting.idleIntervalMinutes === null ? '' : String(setting.idleIntervalMinutes),
);
const [idleDays, setIdleDays] = useState(
setting.idleAfterDays === null ? '' : String(setting.idleAfterDays),
);
const [hour, setHour] = useState(
setting.onlyAfterHour === null ? '' : String(setting.onlyAfterHour),
);
const [busy, setBusy] = useState(false);
const istKaeufe = setting.name === 'gextension';
const speichern = async (event: React.FormEvent) => {
event.preventDefault();
setBusy(true);
try {
await api(`/api/admin/schedule/${setting.name}`, {
method: 'PATCH',
body: {
enabled,
intervalMinutes: Number(interval),
...(istKaeufe
? {
idleIntervalMinutes: idleInterval ? Number(idleInterval) : null,
idleAfterDays: idleDays ? Number(idleDays) : null,
}
: { onlyAfterHour: hour === '' ? null : Number(hour) }),
},
});
onSaved(`Zeitplan für „${titel}" gespeichert.`);
} catch (err) {
onError(err instanceof ApiError ? err.message : 'Speichern fehlgeschlagen');
} finally {
setBusy(false);
}
};
return (
<form onSubmit={speichern} className="schedule-form">
<div className="schedule-head">
<h3>{titel}</h3>
<label className="checkbox">
<input
type="checkbox"
checked={enabled}
onChange={(event) => setEnabled(event.target.checked)}
/>
<span>{enabled ? 'läuft automatisch' : 'nur von Hand'}</span>
</label>
</div>
<div className="schedule-fields">
<div>
<label htmlFor={`${setting.name}-interval`}>Abstand (Minuten)</label>
<input
id={`${setting.name}-interval`}
type="number"
min={15}
max={10080}
value={interval}
onChange={(event) => setInterval(event.target.value)}
disabled={!enabled}
required
/>
<span className="muted small">
{Number(interval) >= 15 ? `= ${inStunden(Number(interval))}` : 'mindestens 15 Minuten'}
</span>
</div>
{istKaeufe ? (
<>
<div>
<label htmlFor={`${setting.name}-idle`}>Ruhende Konten (Minuten)</label>
<input
id={`${setting.name}-idle`}
type="number"
min={15}
max={10080}
value={idleInterval}
onChange={(event) => setIdleInterval(event.target.value)}
disabled={!enabled}
placeholder="leer = derselbe Abstand"
/>
<span className="muted small">
{idleInterval && Number(idleInterval) >= 15
? `= ${inStunden(Number(idleInterval))}`
: 'wie oben'}
</span>
</div>
<div>
<label htmlFor={`${setting.name}-days`}>Ruhend ab (Tage ohne Kauf)</label>
<input
id={`${setting.name}-days`}
type="number"
min={1}
value={idleDays}
onChange={(event) => setIdleDays(event.target.value)}
disabled={!enabled}
placeholder="leer = alle gleich"
/>
<span className="muted small">
{idleDays ? `= rund ${Math.round(Number(idleDays) / 365)} Jahre` : 'kein Unterschied'}
</span>
</div>
</>
) : (
<div>
<label htmlFor={`${setting.name}-hour`}>Frühestens ab (Uhr)</label>
<input
id={`${setting.name}-hour`}
type="number"
min={0}
max={23}
value={hour}
onChange={(event) => setHour(event.target.value)}
disabled={!enabled}
placeholder="leer = jederzeit"
/>
<span className="muted small">
{hour === ''
? 'sobald der Abstand um ist'
: `nicht vor ${hour} Uhr - bei einem täglichen Takt also nachts`}
</span>
</div>
)}
</div>
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={busy}>
{busy ? 'Speichere …' : 'Speichern'}
</button>
</div>
</form>
);
}