diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..0ced32a --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,97 @@ +import { Navigate, Route, Routes } from 'react-router-dom'; +import Layout from './components/Layout'; +import { useSession } from './lib/session'; +import type { Role } from './lib/types'; +import Account from './pages/Account'; +import Admin from './pages/Admin'; +import Dashboard from './pages/Dashboard'; +import Import from './pages/Import'; +import Login from './pages/Login'; +import PlayerDetail from './pages/PlayerDetail'; + +function Protected({ children, minimum }: { children: React.ReactNode; minimum?: Role }) { + const { user, loading, can } = useSession(); + + if (loading) return
Lade …
; + if (!user) return ; + + // Ein frisch angelegtes Konto muss zuerst sein Passwort wechseln. + if (user.mustChangePassword) return ; + if (minimum && !can(minimum)) return ; + + return <>{children}; +} + +export default function App() { + const { user, loading } = useSession(); + + return ( + + Lade … : user ? : } + /> + + Lade … + ) : !user ? ( + + ) : ( + + + + ) + } + /> + + + + + + + } + /> + + + + + + + } + /> + + + + + + + } + /> + + + + + + + } + /> + + } /> + + ); +} diff --git a/web/src/components/BalanceCards.tsx b/web/src/components/BalanceCards.tsx new file mode 100644 index 0000000..38beed9 --- /dev/null +++ b/web/src/components/BalanceCards.tsx @@ -0,0 +1,43 @@ +import type { Balances } from '../lib/types'; + +function Card({ label, value, hint }: { label: string; value: number; hint?: string }) { + return ( +
+ {label} + {value} + {hint && {hint}} +
+ ); +} + +export default function BalanceCards({ balances }: { balances: Balances }) { + const canCreate = balances.CC >= 1; + const canRedesign = balances.redesignTotal >= 1; + + return ( +
+
+ + + +
+ +
+ + {canCreate ? '✓' : '✕'} Neuer CC {canCreate ? 'moeglich' : 'nicht moeglich'} + + + {canRedesign ? '✓' : '✕'} Redesign {canRedesign ? 'moeglich' : 'nicht moeglich'} + {canRedesign && ` (${balances.redesignTotal} verfuegbar)`} + +
+ + {(balances.CC < 0 || balances.REDESIGN_PAID < 0 || balances.REDESIGN_KULANZ < 0) && ( +

+ Mindestens ein Topf ist im Minus. Das ist erlaubt, deutet aber meist auf ein noch nicht + erfasstes Paket hin. +

+ )} +
+ ); +} diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx new file mode 100644 index 0000000..da14ae8 --- /dev/null +++ b/web/src/components/Layout.tsx @@ -0,0 +1,45 @@ +import type { ReactNode } from 'react'; +import { NavLink, useNavigate } from 'react-router-dom'; +import { useSession } from '../lib/session'; + +export default function Layout({ children }: { children: ReactNode }) { + const { user, logout, can } = useSession(); + const navigate = useNavigate(); + + const handleLogout = async () => { + await logout(); + navigate('/login', { replace: true }); + }; + + return ( +
+
+
+ + CC-Manager + + + + +
+ + {user?.username} + {user?.role} + + +
+
+
+ +
{children}
+
+ ); +} diff --git a/web/src/components/Modal.tsx b/web/src/components/Modal.tsx new file mode 100644 index 0000000..01a8dd7 --- /dev/null +++ b/web/src/components/Modal.tsx @@ -0,0 +1,40 @@ +import { useEffect } from 'react'; +import type { ReactNode } from 'react'; + +interface ModalProps { + title: string; + onClose: () => void; + children: ReactNode; + footer?: ReactNode; +} + +export default function Modal({ title, onClose, children, footer }: ModalProps) { + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + return ( +
+
event.stopPropagation()} + > +
+

{title}

+ +
+
{children}
+ {footer &&
{footer}
} +
+
+ ); +} diff --git a/web/src/components/Notice.tsx b/web/src/components/Notice.tsx new file mode 100644 index 0000000..dde5399 --- /dev/null +++ b/web/src/components/Notice.tsx @@ -0,0 +1,18 @@ +interface NoticeProps { + kind: 'error' | 'success' | 'info' | 'warn'; + children: React.ReactNode; + onDismiss?: () => void; +} + +export default function Notice({ kind, children, onDismiss }: NoticeProps) { + return ( +
+ {children} + {onDismiss && ( + + )} +
+ ); +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts new file mode 100644 index 0000000..35c4d36 --- /dev/null +++ b/web/src/lib/api.ts @@ -0,0 +1,102 @@ +const CSRF_COOKIE = 'ccm_csrf'; +const CSRF_HEADER = 'x-csrf-token'; + +export class ApiError extends Error { + constructor( + readonly status: number, + readonly code: string, + message: string, + ) { + super(message); + this.name = 'ApiError'; + } +} + +function readCookie(name: string): string | undefined { + const match = document.cookie + .split('; ') + .find((entry) => entry.startsWith(`${name}=`)); + return match ? decodeURIComponent(match.slice(name.length + 1)) : undefined; +} + +interface RequestOptions { + method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'; + body?: unknown; + signal?: AbortSignal; +} + +export async function api(path: string, options: RequestOptions = {}): Promise { + const method = options.method ?? 'GET'; + const headers: Record = {}; + + if (options.body !== undefined) { + headers['content-type'] = 'application/json'; + } + if (method !== 'GET') { + const token = readCookie(CSRF_COOKIE); + if (token) headers[CSRF_HEADER] = token; + } + + const response = await fetch(path, { + method, + headers, + credentials: 'same-origin', + body: options.body === undefined ? undefined : JSON.stringify(options.body), + ...(options.signal ? { signal: options.signal } : {}), + }); + + if (response.status === 204) return undefined as T; + + const text = await response.text(); + const payload = text ? safeParse(text) : null; + + if (!response.ok) { + const error = payload as { error?: string; message?: string } | null; + throw new ApiError( + response.status, + error?.error ?? 'UNKNOWN', + error?.message ?? `Fehler ${response.status}`, + ); + } + + return payload as T; +} + +/** Datei-Upload laeuft ohne JSON-Header, aber mit demselben CSRF-Schutz. */ +export async function uploadFile(path: string, file: File): Promise { + const form = new FormData(); + form.append('file', file); + + const headers: Record = {}; + const token = readCookie(CSRF_COOKIE); + if (token) headers[CSRF_HEADER] = token; + + const response = await fetch(path, { + method: 'POST', + headers, + credentials: 'same-origin', + body: form, + }); + + const text = await response.text(); + const payload = text ? safeParse(text) : null; + + if (!response.ok) { + const error = payload as { error?: string; message?: string } | null; + throw new ApiError( + response.status, + error?.error ?? 'UNKNOWN', + error?.message ?? `Fehler ${response.status}`, + ); + } + + return payload as T; +} + +function safeParse(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return { message: text }; + } +} diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts new file mode 100644 index 0000000..d6654de --- /dev/null +++ b/web/src/lib/format.ts @@ -0,0 +1,49 @@ +import type { CreditKind, CreditSource, InactiveReason, ImportPlan } from './types'; + +export const KIND_LABEL: Record = { + CC: 'CC-Guthaben', + REDESIGN_PAID: 'Redesign (bezahlt)', + REDESIGN_KULANZ: 'Redesign (Kulanz)', +}; + +export const SOURCE_LABEL: Record = { + CC_PACKAGE: 'CC-Paket', + REDESIGN_PACKAGE: 'Redesign-Paket', + KULANZ: 'Kulanz', + ADJUSTMENT: 'Korrektur', + CONSUME: 'Verbrauch', + REVERSAL: 'Storno', +}; + +export const REASON_LABEL: Record = { + PLAYER_INACTIVITY: 'Spieler-Inaktivitaet', + REWORKED: 'Redesignt', + OTHER: 'Sonstiger Grund', +}; + +export const PLAN_LABEL: Record = { + BOOK: 'Wird gebucht', + CREATE_PLAYER_AND_BOOK: 'Spieler anlegen + buchen', + SKIP_DUPLICATE: 'Uebersprungen: schon gebucht', + SKIP_IGNORED: 'Uebersprungen: ignoriertes Produkt', + SKIP_UNMAPPED: 'Uebersprungen: kein Mapping', + SKIP_NO_PLAYER: 'Uebersprungen: Spieler unbekannt', +}; + +const dateFormat = new Intl.DateTimeFormat('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', +}); + +export function formatDate(value: string | null | undefined): string { + if (!value) return '—'; + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? '—' : dateFormat.format(parsed); +} + +export function formatAmount(amount: number): string { + return amount > 0 ? `+${amount}` : String(amount); +} diff --git a/web/src/lib/session.tsx b/web/src/lib/session.tsx new file mode 100644 index 0000000..b45ffca --- /dev/null +++ b/web/src/lib/session.tsx @@ -0,0 +1,63 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import type { ReactNode } from 'react'; +import { api } from './api'; +import type { Role, SessionUser } from './types'; + +interface SessionState { + user: SessionUser | null; + loading: boolean; + setUser: (user: SessionUser | null) => void; + refresh: () => Promise; + logout: () => Promise; + can: (minimum: Role) => boolean; +} + +const RANK: Record = { VIEWER: 1, EDITOR: 2, ADMIN: 3 }; + +const SessionContext = createContext(null); + +export function SessionProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + const refresh = useCallback(async () => { + try { + const result = await api<{ user: SessionUser | null }>('/api/auth/me'); + setUser(result.user); + } catch { + setUser(null); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const logout = useCallback(async () => { + try { + await api('/api/auth/logout', { method: 'POST' }); + } finally { + setUser(null); + } + }, []); + + const can = useCallback( + (minimum: Role) => (user ? RANK[user.role] >= RANK[minimum] : false), + [user], + ); + + const value = useMemo( + () => ({ user, loading, setUser, refresh, logout, can }), + [user, loading, refresh, logout, can], + ); + + return {children}; +} + +export function useSession(): SessionState { + const context = useContext(SessionContext); + if (!context) throw new Error('useSession benoetigt einen SessionProvider'); + return context; +} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts new file mode 100644 index 0000000..42176d2 --- /dev/null +++ b/web/src/lib/types.ts @@ -0,0 +1,156 @@ +export type Role = 'ADMIN' | 'EDITOR' | 'VIEWER'; +export type CreditKind = 'CC' | 'REDESIGN_PAID' | 'REDESIGN_KULANZ'; +export type CreditSource = + | 'CC_PACKAGE' + | 'REDESIGN_PACKAGE' + | 'KULANZ' + | 'ADJUSTMENT' + | 'CONSUME' + | 'REVERSAL'; +export type CharacterStatus = 'ACTIVE' | 'INACTIVE'; +export type InactiveReason = 'PLAYER_INACTIVITY' | 'REWORKED' | 'OTHER'; +export type ProductTarget = 'CC_PACKAGE' | 'REDESIGN_PACKAGE' | 'IGNORE'; + +export interface SessionUser { + id: number; + username: string; + role: Role; + mustChangePassword: boolean; + totpEnabled: boolean; +} + +export interface Balances { + CC: number; + REDESIGN_PAID: number; + REDESIGN_KULANZ: number; + redesignTotal: number; +} + +export interface PlayerSummary { + id: number; + displayName: string; + steamId: string | null; + forumName: string | null; + characterCount: number; + activeCharacterCount: number; + balances: Balances; +} + +export interface Player { + id: number; + displayName: string; + steamId: string | null; + forumName: string | null; + notes: string | null; + createdAt: string; +} + +export interface CharacterRef { + id: number; + name: string; +} + +export interface Character { + id: number; + playerId: number; + name: string; + loreText: string | null; + status: CharacterStatus; + inactiveReason: InactiveReason | null; + lineageId: string; + predecessorId: number | null; + predecessor: CharacterRef | null; + successors: CharacterRef[]; + createdAt: string; +} + +export interface LedgerEntry { + id: number; + playerId: number; + kind: CreditKind; + amount: number; + source: CreditSource; + groupId: string; + note: string | null; + characterId: number | null; + character: CharacterRef | null; + createdAt: string; + createdBy: { username: string } | null; + reversedBy: { id: number } | null; + reversalOfId: number | null; +} + +export interface PlayerDetail { + player: Player; + balances: Balances; + characters: Character[]; + ledger: LedgerEntry[]; + ledgerTotal: number; +} + +export interface AdminUser { + id: number; + username: string; + role: Role; + isActive: boolean; + totpEnabled: boolean; + mustChangePassword: boolean; + lastLoginAt: string | null; + createdAt: string; +} + +export interface ProductMapping { + id: number; + matchPattern: string; + target: ProductTarget; + priority: number; + isActive: boolean; + note: string | null; +} + +export interface AuditEntry { + id: number; + actorUserId: number | null; + actorName: string | null; + action: string; + targetType: string; + targetId: string | null; + details: unknown; + ip: string | null; + createdAt: string; + actor: { username: string } | null; +} + +export type ImportPlan = + | 'BOOK' + | 'CREATE_PLAYER_AND_BOOK' + | 'SKIP_DUPLICATE' + | 'SKIP_IGNORED' + | 'SKIP_UNMAPPED' + | 'SKIP_NO_PLAYER'; + +export interface ImportRow { + id: number; + lineNo: number; + externalId: string | null; + playerKey: string | null; + playerName: string | null; + productName: string | null; + purchasedAt: string | null; + target: ProductTarget | null; + resolvedPlayerId: number | null; + plan: ImportPlan; + raw: Record; +} + +export interface ImportRecord { + id: number; + filename: string; + status: 'PENDING' | 'COMMITTED' | 'DISCARDED'; + rowCount: number; + bookedCount: number; + createdAt: string; + committedAt: string | null; + importedBy: { username: string } | null; + rows?: ImportRow[]; +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..c24edd8 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,19 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; +import App from './App'; +import { SessionProvider } from './lib/session'; +import './styles.css'; + +const container = document.getElementById('root'); +if (!container) throw new Error('Wurzelelement fehlt'); + +createRoot(container).render( + + + + + + + , +); diff --git a/web/src/pages/Account.tsx b/web/src/pages/Account.tsx new file mode 100644 index 0000000..5acfb9a --- /dev/null +++ b/web/src/pages/Account.tsx @@ -0,0 +1,203 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import Notice from '../components/Notice'; +import { ApiError, api } from '../lib/api'; +import { useSession } from '../lib/session'; + +export default function Account() { + const { user, refresh } = useSession(); + const navigate = useNavigate(); + + const [current, setCurrent] = useState(''); + const [next, setNext] = useState(''); + const [repeat, setRepeat] = useState(''); + const [error, setError] = useState(null); + const [flash, setFlash] = useState(null); + const [busy, setBusy] = useState(false); + + const [totpSetup, setTotpSetup] = useState<{ qr: string; secret: string } | null>(null); + const [totpCode, setTotpCode] = useState(''); + + const changePassword = async (event: React.FormEvent) => { + event.preventDefault(); + setError(null); + + if (next !== repeat) { + setError('Die beiden neuen Passwoerter stimmen nicht ueberein'); + return; + } + + setBusy(true); + try { + await api('/api/account/password', { + method: 'POST', + body: { currentPassword: current, newPassword: next }, + }); + setCurrent(''); + setNext(''); + setRepeat(''); + await refresh(); + setFlash('Passwort geaendert. Andere Sitzungen wurden beendet.'); + if (user?.mustChangePassword) navigate('/', { replace: true }); + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Aenderung fehlgeschlagen'); + } finally { + setBusy(false); + } + }; + + const startTotp = async () => { + setError(null); + try { + const result = await api<{ qr: string; secret: string }>('/api/account/totp/setup', { + method: 'POST', + body: {}, + }); + setTotpSetup(result); + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Einrichtung fehlgeschlagen'); + } + }; + + const enableTotp = async (event: React.FormEvent) => { + event.preventDefault(); + setError(null); + setBusy(true); + try { + await api('/api/account/totp/enable', { method: 'POST', body: { code: totpCode } }); + setTotpSetup(null); + setTotpCode(''); + await refresh(); + setFlash('Zwei-Faktor-Anmeldung ist aktiv.'); + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Code wurde nicht akzeptiert'); + } finally { + setBusy(false); + } + }; + + const disableTotp = async () => { + const password = window.prompt('Zum Deaktivieren bitte das Passwort eingeben:'); + if (!password) return; + setError(null); + try { + await api('/api/account/totp/disable', { method: 'POST', body: { password } }); + await refresh(); + setFlash('Zwei-Faktor-Anmeldung wurde deaktiviert.'); + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Deaktivieren fehlgeschlagen'); + } + }; + + return ( +
+
+

Mein Konto

+
+ + {user?.mustChangePassword && ( + + Dieses Konto verwendet noch ein vergebenes Erstpasswort. Bitte jetzt aendern — die + uebrigen Seiten sind bis dahin gesperrt. + + )} + + {error && setError(null)}>{error}} + {flash && setFlash(null)}>{flash}} + +
+

Passwort aendern

+
+ + setCurrent(event.target.value)} + required + /> + + + setNext(event.target.value)} + required + /> +

+ Mindestens 12 Zeichen, Gross- und Kleinbuchstaben sowie eine Ziffer. +

+ + + setRepeat(event.target.value)} + required + /> + + +
+
+ +
+

Zwei-Faktor-Anmeldung

+ + {user?.totpEnabled ? ( + <> + Zwei-Faktor-Anmeldung ist fuer dieses Konto aktiv. + + + ) : totpSetup ? ( +
+

+ QR-Code in der Authenticator-App scannen und den angezeigten Code eintragen. +

+ QR-Code zur Einrichtung +

+ Falls Scannen nicht geht, Schluessel manuell eintragen:{' '} + {totpSetup.secret} +

+ + + setTotpCode(event.target.value)} + maxLength={10} + required + /> + +
+ + +
+
+ ) : ( + <> +

+ Schuetzt das Konto zusaetzlich mit einem Einmalcode. Fuer Administratoren empfohlen. +

+ + + )} +
+
+ ); +} diff --git a/web/src/pages/Admin.tsx b/web/src/pages/Admin.tsx new file mode 100644 index 0000000..ff284da --- /dev/null +++ b/web/src/pages/Admin.tsx @@ -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 = { + 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' && } +
+ ); +} + +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 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 ( +
+
+

Nutzer

+ +
+ + + Es gibt keine oeffentliche Registrierung. Konten werden ausschliesslich hier angelegt. + + +
+ + + + + + + + + + + + {users.map((row) => ( + + + + + + + + + ))} + +
BenutzernameRolleStatus2FALetzter Login +
+ {row.username} + {row.id === me?.id && du} + {row.mustChangePassword && Erstpasswort} + + + {row.isActive ? 'aktiv' : 'gesperrt'}{row.totpEnabled ? 'ja' : 'nein'}{formatDate(row.lastLoginAt)} + + {row.id !== me?.id && ( + <> + + + + )} +
+
+ + {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, Gross- und Kleinbuchstaben, eine Ziffer. Muss beim ersten Login + geaendert 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}" 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 ( +
+

Produkt-Mappings

+ + Beim Import wird der Produktname gegen diese Muster geprueft (Teiltreffer, Gross- und + Kleinschreibung egal). Das erste passende Muster mit der niedrigsten Prioritaet gewinnt. + + +
+ setPattern(event.target.value)} + required + /> + + +
+ +
+ + + + + + + + + + + {items.map((row) => ( + + + + + + + + ))} + {items.length === 0 && ( + + + + )} + +
MusterBedeutungPrioritaetAktiv +
{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 && ( + + + + )} + +
ZeitpunktWerAktionZielDetails
{formatDate(entry.createdAt)}{entry.actor?.username ?? entry.actorName ?? '—'}{entry.action} + {entry.targetType} + {entry.targetId ? ` #${entry.targetId}` : ''} + + {entry.details ? JSON.stringify(entry.details) : '—'} +
+ Keine Eintraege. +
+
+ + {total > items.length && ( +

+ {items.length} von {total} Eintraegen angezeigt. +

+ )} +
+ ); +} diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx new file mode 100644 index 0000000..7daf6b2 --- /dev/null +++ b/web/src/pages/Dashboard.tsx @@ -0,0 +1,208 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import Modal from '../components/Modal'; +import Notice from '../components/Notice'; +import { ApiError, api } from '../lib/api'; +import { useSession } from '../lib/session'; +import type { PlayerSummary } from '../lib/types'; + +interface SearchResult { + total: number; + items: PlayerSummary[]; +} + +function Counter({ value, title }: { value: number; title: string }) { + return ( + + {value} + + ); +} + +export default function Dashboard() { + const { can } = useSession(); + const [query, setQuery] = useState(''); + const [result, setResult] = useState({ total: 0, items: [] }); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [creating, setCreating] = useState(false); + + const load = useCallback(async (search: string, signal?: AbortSignal) => { + setLoading(true); + try { + const params = new URLSearchParams({ q: search, take: '100' }); + const data = await api(`/api/players?${params}`, signal ? { signal } : {}); + setResult(data); + setError(null); + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return; + setError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen'); + } finally { + setLoading(false); + } + }, []); + + // Tippen entprellen, damit nicht jeder Anschlag eine Anfrage ausloest. + useEffect(() => { + const controller = new AbortController(); + const timer = setTimeout(() => void load(query, controller.signal), 250); + return () => { + clearTimeout(timer); + controller.abort(); + }; + }, [query, load]); + + return ( +
+
+

Spieler

+ {can('EDITOR') && ( + + )} +
+ + setQuery(event.target.value)} + autoFocus + /> + + {error && {error}} + +
+ + + + + + + + + + + + + {result.items.map((player) => ( + + + + + + + + + ))} + + {!loading && result.items.length === 0 && ( + + + + )} + +
SpielerSteamIDCCRedesignKulanzAktive CCs
+ + {player.displayName} + + {player.forumName && · {player.forumName}} + {player.steamId ?? '—'} + + + + + + + {player.activeCharacterCount} / {player.characterCount} +
+ {query ? 'Kein Treffer.' : 'Noch keine Spieler erfasst.'} +
+
+ + {loading &&

Lade …

} + {!loading && result.total > result.items.length && ( +

+ {result.items.length} von {result.total} angezeigt — Suche eingrenzen. +

+ )} + + {creating && ( + setCreating(false)} + onCreated={() => { + setCreating(false); + void load(query); + }} + /> + )} +
+ ); +} + +function CreatePlayerModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) { + const [displayName, setDisplayName] = useState(''); + const [steamId, setSteamId] = useState(''); + const [forumName, setForumName] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const submit = async (event: React.FormEvent) => { + event.preventDefault(); + setBusy(true); + setError(null); + try { + await api('/api/players', { + method: 'POST', + body: { + displayName, + steamId: steamId.trim() || null, + forumName: forumName.trim() || null, + }, + }); + onCreated(); + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Anlegen fehlgeschlagen'); + } finally { + setBusy(false); + } + }; + + return ( + +
+ {error && {error}} + + + setDisplayName(event.target.value)} + autoFocus + required + /> + + + setSteamId(event.target.value)} /> + + + setForumName(event.target.value)} + /> + +
+ + +
+
+
+ ); +} diff --git a/web/src/pages/Import.tsx b/web/src/pages/Import.tsx new file mode 100644 index 0000000..75eadc6 --- /dev/null +++ b/web/src/pages/Import.tsx @@ -0,0 +1,388 @@ +import { useState } from 'react'; +import Notice from '../components/Notice'; +import { ApiError, api, uploadFile } from '../lib/api'; +import { PLAN_LABEL, formatDate } from '../lib/format'; +import type { ImportPlan, ImportRecord } from '../lib/types'; + +interface UploadResult { + importId: number; + columns: string[]; + rowCount: number; + sample: Record[]; +} + +interface MappingForm { + playerKey: string; + playerName: string; + product: string; + orderId: string; + purchasedAt: string; + createMissingPlayers: boolean; +} + +const EMPTY_MAPPING: MappingForm = { + playerKey: '', + playerName: '', + product: '', + orderId: '', + purchasedAt: '', + createMissingPlayers: false, +}; + +// Trifft die ueblichen Spaltennamen, damit das Formular meist schon passt. +const GUESSES: Record, string[]> = { + playerKey: ['steamid', 'steam_id', 'steam', 'identifier', 'user', 'kunde'], + playerName: ['name', 'username', 'displayname', 'spieler'], + product: ['product', 'produkt', 'item', 'artikel', 'package', 'paket'], + orderId: ['order', 'bestell', 'transaction', 'id', 'purchase'], + purchasedAt: ['date', 'datum', 'created', 'time', 'kauf'], +}; + +function guessMapping(columns: string[]): MappingForm { + const result = { ...EMPTY_MAPPING }; + for (const [field, needles] of Object.entries(GUESSES) as [ + keyof typeof GUESSES, + string[], + ][]) { + const hit = columns.find((column) => + needles.some((needle) => column.toLowerCase().includes(needle)), + ); + if (hit) result[field] = hit; + } + return result; +} + +export default function Import() { + const [upload, setUpload] = useState(null); + const [mapping, setMapping] = useState(EMPTY_MAPPING); + const [preview, setPreview] = useState<{ + summary: Record; + import: ImportRecord; + } | null>(null); + const [error, setError] = useState(null); + const [flash, setFlash] = useState(null); + const [busy, setBusy] = useState(false); + + const reset = () => { + setUpload(null); + setPreview(null); + setMapping(EMPTY_MAPPING); + setError(null); + }; + + const handleUpload = async (file: File) => { + setBusy(true); + setError(null); + setPreview(null); + try { + const result = await uploadFile('/api/imports', file); + setUpload(result); + setMapping(guessMapping(result.columns)); + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Upload fehlgeschlagen'); + } finally { + setBusy(false); + } + }; + + const handlePreview = async (event: React.FormEvent) => { + event.preventDefault(); + if (!upload) return; + setBusy(true); + setError(null); + try { + const body = { + playerKey: mapping.playerKey, + product: mapping.product, + createMissingPlayers: mapping.createMissingPlayers, + ...(mapping.playerName ? { playerName: mapping.playerName } : {}), + ...(mapping.orderId ? { orderId: mapping.orderId } : {}), + ...(mapping.purchasedAt ? { purchasedAt: mapping.purchasedAt } : {}), + }; + const result = await api<{ summary: Record; import: ImportRecord }>( + `/api/imports/${upload.importId}/mapping`, + { method: 'POST', body }, + ); + setPreview(result); + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Vorschau fehlgeschlagen'); + } finally { + setBusy(false); + } + }; + + const handleCommit = async () => { + if (!upload) return; + setBusy(true); + setError(null); + try { + const result = await api<{ booked: number; createdPlayers: number }>( + `/api/imports/${upload.importId}/commit`, + { method: 'POST', body: {} }, + ); + setFlash( + `${result.booked} Buchungen angelegt` + + (result.createdPlayers > 0 ? `, ${result.createdPlayers} Spieler neu erfasst.` : '.'), + ); + reset(); + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Buchen fehlgeschlagen'); + } finally { + setBusy(false); + } + }; + + const handleDiscard = async () => { + if (!upload) return; + try { + await api(`/api/imports/${upload.importId}/discard`, { method: 'POST', body: {} }); + } finally { + reset(); + } + }; + + const bookable = preview + ? preview.summary.BOOK + preview.summary.CREATE_PLAYER_AND_BOOK + : 0; + + return ( +
+
+

Import

+
+ + {error && setError(null)}>{error}} + {flash && setFlash(null)}>{flash}} + + {!upload && ( +
+ + CSV oder JSON hochladen. Danach werden die Spalten zugeordnet und eine Vorschau + gezeigt — gebucht wird erst nach Bestaetigung. Bereits importierte Bestell-IDs + werden automatisch uebersprungen. + + + +
+ )} + + {upload && !preview && ( +
+

Spalten zuordnen

+

+ {upload.rowCount} Zeilen aus {upload.columns.length} Spalten gelesen. +

+ +
+
+ setMapping({ ...mapping, playerKey: value })} + /> + setMapping({ ...mapping, product: value })} + /> + setMapping({ ...mapping, playerName: value })} + /> + setMapping({ ...mapping, orderId: value })} + /> + setMapping({ ...mapping, purchasedAt: value })} + /> +
+ + + + {!mapping.orderId && ( + + Ohne Bestell-ID kann ein zweiter Import dieselben Kaeufe erneut buchen. + + )} + +
+ + +
+
+ +

Beispielzeilen

+
+ + + + {upload.columns.map((column) => ( + + ))} + + + + {upload.sample.map((row, index) => ( + + {upload.columns.map((column) => ( + + ))} + + ))} + +
{column}
+ {row[column] ?? ''} +
+
+
+ )} + + {preview && ( +
+

Vorschau

+ +
+ {(Object.keys(PLAN_LABEL) as ImportPlan[]).map((plan) => ( +
+ {preview.summary[plan] ?? 0} + {PLAN_LABEL[plan]} +
+ ))} +
+ + {bookable === 0 ? ( + + Keine Zeile wuerde gebucht. Pruefe die Spaltenzuordnung oder die Produkt-Mappings + in der Verwaltung. + + ) : ( + + {bookable} Zeilen werden gebucht. Ein CC-Paket erzeugt dabei automatisch ein + CC-Guthaben und ein bezahltes Redesign-Guthaben. + + )} + +
+ + + + + + + + + + + + + {(preview.import.rows ?? []).map((row) => ( + + + + + + + + + ))} + +
#SpielerProduktBestell-IDDatumErgebnis
{row.lineNo} + {row.playerName || row.playerKey || '—'} + {row.resolvedPlayerId === null && row.plan === 'CREATE_PLAYER_AND_BOOK' && ( + neu + )} + {row.productName ?? '—'}{row.externalId ?? '—'}{formatDate(row.purchasedAt)} + {PLAN_LABEL[row.plan]} +
+
+ +
+ + + +
+
+ )} +
+ ); +} + +function MappingSelect({ + label, + value, + columns, + required, + onChange, +}: { + label: string; + value: string; + columns: string[]; + required?: boolean; + onChange: (value: string) => void; +}) { + return ( +
+ + +
+ ); +} diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx new file mode 100644 index 0000000..2973f3f --- /dev/null +++ b/web/src/pages/Login.tsx @@ -0,0 +1,141 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import Notice from '../components/Notice'; +import { ApiError, api } from '../lib/api'; +import { useSession } from '../lib/session'; +import type { SessionUser } from '../lib/types'; + +interface LoginResponse { + totpRequired: boolean; + challenge?: string; + user?: SessionUser; +} + +export default function Login() { + const { setUser } = useSession(); + const navigate = useNavigate(); + + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [code, setCode] = useState(''); + const [challenge, setChallenge] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const finish = (user: SessionUser) => { + setUser(user); + navigate(user.mustChangePassword ? '/account' : '/', { replace: true }); + }; + + const submitPassword = async (event: React.FormEvent) => { + event.preventDefault(); + setError(null); + setBusy(true); + try { + const result = await api('/api/auth/login', { + method: 'POST', + body: { username, password }, + }); + + if (result.totpRequired && result.challenge) { + setChallenge(result.challenge); + } else if (result.user) { + finish(result.user); + } + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Anmeldung fehlgeschlagen'); + } finally { + setBusy(false); + } + }; + + const submitTotp = async (event: React.FormEvent) => { + event.preventDefault(); + setError(null); + setBusy(true); + try { + const result = await api<{ user: SessionUser }>('/api/auth/login/totp', { + method: 'POST', + body: { challenge, code }, + }); + finish(result.user); + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Anmeldung fehlgeschlagen'); + if (err instanceof ApiError && err.code === 'UNAUTHORIZED' && err.message.includes('abgelaufen')) { + setChallenge(null); + setCode(''); + } + } finally { + setBusy(false); + } + }; + + return ( +
+
+

CC-Manager

+

+ {challenge ? 'Bestaetigungscode eingeben' : 'Bitte anmelden'} +

+ + {error && {error}} + + {challenge ? ( +
+ + setCode(event.target.value)} + maxLength={10} + autoFocus + required + /> + + +
+ ) : ( +
+ + setUsername(event.target.value)} + autoFocus + required + /> + + + setPassword(event.target.value)} + required + /> + + +
+ )} +
+
+ ); +} diff --git a/web/src/pages/PlayerDetail.tsx b/web/src/pages/PlayerDetail.tsx new file mode 100644 index 0000000..6cf765e --- /dev/null +++ b/web/src/pages/PlayerDetail.tsx @@ -0,0 +1,730 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Link, useParams } from 'react-router-dom'; +import BalanceCards from '../components/BalanceCards'; +import Modal from '../components/Modal'; +import Notice from '../components/Notice'; +import { ApiError, api } from '../lib/api'; +import { KIND_LABEL, REASON_LABEL, SOURCE_LABEL, formatAmount, formatDate } from '../lib/format'; +import { useSession } from '../lib/session'; +import type { Character, CreditKind, LedgerEntry, PlayerDetail as Detail } from '../lib/types'; + +type DialogState = + | { type: 'none' } + | { type: 'newCharacter' } + | { type: 'redesign'; character: Character } + | { type: 'deactivate'; character: Character } + | { type: 'reactivatePaid'; character: Character } + | { type: 'revert'; character: Character } + | { type: 'reverse'; entry: LedgerEntry }; + +export default function PlayerDetail() { + const { id } = useParams<{ id: string }>(); + const playerId = Number(id); + const { can } = useSession(); + + const [detail, setDetail] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [flash, setFlash] = useState(null); + const [dialog, setDialog] = useState({ type: 'none' }); + const [busy, setBusy] = useState(false); + + const load = useCallback(async () => { + try { + setDetail(await api(`/api/players/${playerId}`)); + setError(null); + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen'); + } finally { + setLoading(false); + } + }, [playerId]); + + useEffect(() => { + void load(); + }, [load]); + + const run = async (action: () => Promise, message: string) => { + setBusy(true); + setError(null); + try { + await action(); + await load(); + setDialog({ type: 'none' }); + setFlash(message); + window.setTimeout(() => setFlash(null), 4000); + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Aktion fehlgeschlagen'); + } finally { + setBusy(false); + } + }; + + if (loading) return

Lade …

; + if (!detail) return {error ?? 'Spieler nicht gefunden'}; + + const editor = can('EDITOR'); + const active = detail.characters.filter((c) => c.status === 'ACTIVE'); + const inactive = detail.characters.filter((c) => c.status === 'INACTIVE'); + + return ( +
+
+
+ + ← Alle Spieler + +

{detail.player.displayName}

+

+ {detail.player.steamId ?? 'keine SteamID'} + {detail.player.forumName && ` · Forum: ${detail.player.forumName}`} +

+
+
+ + {error && setError(null)}>{error}} + {flash && setFlash(null)}>{flash}} + + + + {editor && ( +
+ + + + +
+ )} + +
+

Aktive CCs ({active.length})

+ {active.length === 0 ? ( +

Keine aktiven CCs.

+ ) : ( +
    + {active.map((character) => ( +
  • +
    + {character.name} + {character.predecessor && ( + · Nachfolger von {character.predecessor.name} + )} + {character.loreText &&

    {character.loreText}

    } + Seit {formatDate(character.createdAt)} +
    + {editor && ( +
    + + + +
    + )} +
  • + ))} +
+ )} +
+ +
+

Inaktive CCs ({inactive.length})

+ {inactive.length === 0 ? ( +

Keine inaktiven CCs.

+ ) : ( +
    + {inactive.map((character) => { + const free = character.inactiveReason === 'PLAYER_INACTIVITY'; + return ( +
  • +
    + {character.name} + + {character.inactiveReason + ? REASON_LABEL[character.inactiveReason] + : 'Unbekannt'} + + {character.successors.length > 0 && ( + + {' '} + · Ersetzt durch {character.successors.map((s) => s.name).join(', ')} + + )} +

    + {free + ? 'Kommt gratis zurueck — kostet kein Guthaben.' + : 'Reaktivierung kostet ein Redesign- oder CC-Guthaben.'} +

    +
    + {editor && ( +
    + {free ? ( + + ) : ( + + )} +
    + )} +
  • + ); + })} +
+ )} +
+ +
+

Guthaben-Historie

+
+ + + + + + + + + + + + + {detail.ledger.map((entry) => ( + + + + + + + + + + ))} + {detail.ledger.length === 0 && ( + + + + )} + +
ZeitpunktTopfMengeAnlassNotizVon +
{formatDate(entry.createdAt)}{KIND_LABEL[entry.kind]} + {formatAmount(entry.amount)} + {SOURCE_LABEL[entry.source]} + {entry.note ?? '—'} + {entry.character && ` (${entry.character.name})`} + {entry.createdBy?.username ?? 'System'} + {editor && + !entry.reversedBy && + entry.source !== 'CONSUME' && + entry.source !== 'REVERSAL' && ( + + )} + {entry.reversedBy && storniert} +
+ Noch keine Buchungen. +
+
+ {detail.ledgerTotal > detail.ledger.length && ( +

+ {detail.ledger.length} von {detail.ledgerTotal} Buchungen angezeigt. +

+ )} +
+ + {dialog.type === 'newCharacter' && ( + setDialog({ type: 'none' })} + onSubmit={(values) => + void run( + () => + api(`/api/players/${playerId}/characters`, { + method: 'POST', + body: values, + }), + `CC "${values.name}" angelegt — 1 CC-Guthaben verbraucht.`, + ) + } + /> + )} + + {dialog.type === 'redesign' && ( + setDialog({ type: 'none' })} + onSubmit={(values) => + void run( + () => + api(`/api/characters/${dialog.character.id}/redesign`, { + method: 'POST', + body: values, + }), + `Redesign gebucht: "${dialog.character.name}" → "${values.name}".`, + ) + } + /> + )} + + {dialog.type === 'deactivate' && ( + setDialog({ type: 'none' })} + onSubmit={(reason) => + void run( + () => + api(`/api/characters/${dialog.character.id}/deactivate`, { + method: 'POST', + body: { reason }, + }), + `"${dialog.character.name}" ist jetzt inaktiv.`, + ) + } + /> + )} + + {dialog.type === 'reactivatePaid' && ( + setDialog({ type: 'none' })} + onSubmit={(useKind) => + void run( + () => + api(`/api/characters/${dialog.character.id}/reactivate-paid`, { + method: 'POST', + body: { useKind }, + }), + `"${dialog.character.name}" ist wieder aktiv.`, + ) + } + /> + )} + + {dialog.type === 'revert' && ( + setDialog({ type: 'none' })} + onSubmit={(reason) => + void run( + () => + api(`/api/characters/${dialog.character.id}/revert`, { + method: 'POST', + body: { reason }, + }), + `"${dialog.character.name}" wurde zurueckgenommen.`, + ) + } + /> + )} + + {dialog.type === 'reverse' && ( + setDialog({ type: 'none' })} + onSubmit={(reason) => + void run( + () => + api(`/api/credits/${dialog.entry.id}/reverse`, { + method: 'POST', + body: { reason }, + }), + 'Buchung storniert.', + ) + } + /> + )} +
+ ); +} + +interface CharacterValues { + name: string; + loreText?: string; + useKind?: CreditKind; +} + +function CharacterFormModal({ + title, + submitLabel, + hint, + busy, + withKind, + balances, + onClose, + onSubmit, +}: { + title: string; + submitLabel: string; + hint: string; + busy: boolean; + withKind?: CreditKind[]; + balances?: { CC: number; REDESIGN_PAID: number; REDESIGN_KULANZ: number }; + onClose: () => void; + onSubmit: (values: CharacterValues) => void; +}) { + const [name, setName] = useState(''); + const [loreText, setLoreText] = useState(''); + // Kulanz steht bewusst vorne: sie soll zuerst aufgebraucht werden. + const [useKind, setUseKind] = useState(withKind?.[0] ?? 'CC'); + + return ( + +
{ + event.preventDefault(); + onSubmit({ + name, + ...(loreText.trim() ? { loreText: loreText.trim() } : {}), + ...(withKind ? { useKind } : {}), + }); + }} + > + {hint} + + + setName(event.target.value)} + autoFocus + required + /> + + +