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 = { CC_PACKAGE: 'CC-Paket', REDESIGN_PACKAGE: 'Redesign-Paket', IGNORE: 'Ignorieren', }; export default function Admin() { const [tab, setTab] = useState('users'); const [error, setError] = useState(null); const [flash, setFlash] = useState(null); const notify = (message: string) => { setFlash(message); window.setTimeout(() => setFlash(null), 4000); }; return (

Verwaltung

{error && setError(null)}>{error}} {flash && setFlash(null)}>{flash}} {tab === 'users' && } {tab === 'mappings' && } {tab === 'audit' && } {tab === 'schedule' && }
); } interface TabProps { onError: (message: string) => void; onDone: (message: string) => void; } function UsersTab({ onError, onDone }: TabProps) { const { user: me } = useSession(); const [users, setUsers] = useState([]); 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, 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 (

Nutzer

Es gibt keine öffentliche Registrierung. Konten werden ausschließlich hier angelegt.
{users.map((row) => ( ))}
Benutzername Rolle Status 2FA Letzter Login
{row.username} {row.id === me?.id && du} {row.isLocked && ( geschützt )} {row.mustChangePassword && Erstpasswort} {row.isActive ? 'aktiv' : 'gesperrt'} {row.totpEnabled ? 'ja' : 'nein'} {formatDate(row.lastLoginAt)} {row.isLocked ? ( {row.id === me?.id ? 'Passwort änderst du unter Konto' : 'Erstzugang - unantastbar'} ) : ( )} {row.id !== me?.id && !row.isLocked && ( <> )}
{creating && ( setCreating(false)} onCreated={(username) => { setCreating(false); void load(); onDone(`Konto "${username}" angelegt.`); }} onError={onError} /> )}
); } 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('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 (
setUsername(event.target.value)} autoFocus required /> setPassword(event.target.value)} required />

Min. 12 Zeichen, Groß- und Kleinbuchstaben, eine Ziffer. Muss beim ersten Login geändert werden.

); } function MappingsTab({ onError, onDone }: TabProps) { const [items, setItems] = useState([]); const [pattern, setPattern] = useState(''); const [target, setTarget] = useState('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 (

Produkt-Mappings

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.
setPattern(event.target.value)} required />
{items.map((row) => ( ))} {items.length === 0 && ( )}
Muster Bedeutung Priorität Aktiv
{row.matchPattern} {TARGET_LABEL[row.target]} {row.priority} {row.isActive ? 'ja' : 'nein'}
Noch keine Mappings.
); } function AuditTab({ onError }: { onError: (message: string) => void }) { const [items, setItems] = useState([]); 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 (

Audit-Log

setFilter(event.target.value)} />
{items.map((entry) => ( ))} {items.length === 0 && ( )}
Zeitpunkt Wer Aktion Ziel Details
{formatDate(entry.createdAt)} {entry.actor?.username ?? entry.actorName ?? '-'} {entry.action} {entry.targetType} {entry.targetId ? ` #${entry.targetId}` : ''} {entry.details ? JSON.stringify(entry.details) : '-'}
Keine Einträge.
{total > items.length && (

{items.length} von {total} Einträgen angezeigt.

)}
); } /** * 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(null); const laden = useCallback(async () => { try { setStand(await api('/api/admin/schedule')); } catch (err) { onError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen'); } }, [onError]); useEffect(() => { void laden(); }, [laden]); const LABEL: Record = { gextension: 'Käufe bei GExtension', forum: 'Forums-Anträge', letzter_fehler: 'Zuletzt gescheitert', }; if (stand === null) { return (

Zeitplan

Lade …

); } return ( <>

Zeitplan

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. {stand.settings.map((setting) => ( { onDone(text); void laden(); }} onError={onError} /> ))}

Letzte Läufe

{stand.runs.length === 0 ? (

Noch kein Lauf verzeichnet.

) : (
{stand.runs.map((run) => ( ))}
Lauf Zuletzt Ergebnis
{LABEL[run.name] ?? run.name} {formatDate(run.lastRunAt)} {run.lastStatus === 'FAILED' && Fehler: } {run.lastNote ?? '-'}
)}
); } /** 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 (

{titel}

setInterval(event.target.value)} disabled={!enabled} required /> {Number(interval) >= 15 ? `= ${inStunden(Number(interval))}` : 'mindestens 15 Minuten'}
{istKaeufe ? ( <>
setIdleInterval(event.target.value)} disabled={!enabled} placeholder="leer = derselbe Abstand" /> {idleInterval && Number(idleInterval) >= 15 ? `= ${inStunden(Number(idleInterval))}` : 'wie oben'}
setIdleDays(event.target.value)} disabled={!enabled} placeholder="leer = alle gleich" /> {idleDays ? `= rund ${Math.round(Number(idleDays) / 365)} Jahre` : 'kein Unterschied'}
) : (
setHour(event.target.value)} disabled={!enabled} placeholder="leer = jederzeit" /> {hour === '' ? 'sobald der Abstand um ist' : `nicht vor ${hour} Uhr - bei einem täglichen Takt also nachts`}
)}
); }