Weboberflaeche mit Spielerkonto, Import und Verwaltung
Die Spieler-Detailseite beantwortet die Kernfrage direkt: drei grosse Zaehler, rot sobald ein Topf im Minus ist, darunter ob ein neuer CC oder ein Redesign gerade moeglich ist. Inaktive CCs zeigen ihren Grund und bieten genau die passende Aktion an - gratis reaktivieren oder bezahlt. Beim Redesign steht der Kulanz-Topf an erster Stelle, damit er zuerst aufgebraucht wird. Der Import fuehrt ueber Spaltenzuordnung und Vorschau; gebucht wird erst nach Bestaetigung.
This commit is contained in:
@@ -0,0 +1,507 @@
|
||||
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 } from '../lib/types';
|
||||
|
||||
type Tab = 'users' | 'mappings' | 'audit';
|
||||
|
||||
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>
|
||||
</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} />}
|
||||
</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 fuer "${target.username}" (min. 12 Zeichen, Gross/Klein, Ziffer):`,
|
||||
);
|
||||
if (!password) return;
|
||||
void act(
|
||||
() =>
|
||||
api(`/api/admin/users/${target.id}/reset-password`, {
|
||||
method: 'POST',
|
||||
body: { newPassword: password },
|
||||
}),
|
||||
`Passwort fuer "${target.username}" gesetzt. Muss beim naechsten Login geaendert 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 oeffentliche Registrierung. Konten werden ausschliesslich 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.mustChangePassword && <span className="badge badge-paid">Erstpasswort</span>}
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
value={row.role}
|
||||
disabled={row.id === me?.id}
|
||||
onChange={(event) =>
|
||||
void act(
|
||||
() =>
|
||||
api(`/api/admin/users/${row.id}`, {
|
||||
method: 'PATCH',
|
||||
body: { role: event.target.value as Role },
|
||||
}),
|
||||
`Rolle von "${row.username}" geaendert.`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-tiny btn-ghost"
|
||||
onClick={() => resetPassword(row)}
|
||||
>
|
||||
Passwort
|
||||
</button>
|
||||
{row.id !== me?.id && (
|
||||
<>
|
||||
<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 loeschen?`)) return;
|
||||
void act(
|
||||
() => api(`/api/admin/users/${row.id}`, { method: 'DELETE' }),
|
||||
`"${row.username}" geloescht.`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
Loeschen
|
||||
</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, Gross- und Kleinbuchstaben, eine Ziffer. Muss beim ersten Login
|
||||
geaendert 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}" loeschen?`)) return;
|
||||
try {
|
||||
await api(`/api/admin/product-mappings/${row.id}`, { method: 'DELETE' });
|
||||
await load();
|
||||
onDone('Mapping geloescht.');
|
||||
} catch (err) {
|
||||
onError(err instanceof ApiError ? err.message : 'Loeschen fehlgeschlagen');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="block">
|
||||
<h2>Produkt-Mappings</h2>
|
||||
<Notice kind="info">
|
||||
Beim Import wird der Produktname gegen diese Muster geprueft (Teiltreffer, Gross- und
|
||||
Kleinschreibung egal). Das erste passende Muster mit der niedrigsten Prioritaet 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">
|
||||
Hinzufuegen
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Muster</th>
|
||||
<th>Bedeutung</th>
|
||||
<th className="num">Prioritaet</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)}
|
||||
>
|
||||
Loeschen
|
||||
</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 Eintraege.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{total > items.length && (
|
||||
<p className="muted">
|
||||
{items.length} von {total} Eintraegen angezeigt.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user