Compare commits

..
8 Commits
Author SHA1 Message Date
Harlekin 3ec0ff4df1 Container-Setup, Backups und Dokumentation
Mehrstufiges Image: gebaut wird mit allen Abhaengigkeiten, ausgeliefert wird
nur die Laufzeit. Migrationen laufen beim Containerstart. Der App-Port wird
bewusst nicht nach aussen gemappt - davor gehoert ein Reverse Proxy, der HTTPS
terminiert.

Der Backup-Service schreibt taeglich einen pg_dump und raeumt Dumps aelter als
14 Tage weg.
2026-09-18 11:55:06 +02:00
Harlekin b988a82765 Integrationstests gegen eine Postgres-Instanz im Speicher
Die Tests starten Postgres per PGlite und sprechen es ueber einen lokalen Port
an, laufen also ohne Docker und ohne installierte Datenbank.

Abgedeckt sind die Guthaben-Regeln, der CC-Lebenszyklus samt Reaktivierung,
Stornos und Ruecknahmen, Anmeldung mit CSRF- und Rollenpruefung sowie der
Import mit Dedup.
2026-09-18 11:37:43 +02:00
Harlekin 245666d363 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.
2026-09-18 11:37:43 +02:00
Harlekin a7228e63d1 HTTP-Endpunkte und Anwendungsaufbau
Eingaben werden mit zod validiert, jede aendernde Aktion schreibt ins
Audit-Log. Beim ersten Start entsteht ein Administrator samt Startwerten fuer
die Produkterkennung; solange ein Erstpasswort gilt, bleiben schreibende Wege
bis auf den Passwortwechsel gesperrt.

Nicht-API-Pfade fallen auf die SPA zurueck, damit Deep-Links funktionieren.
2026-09-18 11:37:31 +02:00
Harlekin f3e31cdd17 Fachlogik fuer Guthaben, CC-Lebenszyklus und Import
Die beiden zentralen Regeln stehen jeweils an genau einer Stelle:

Ein CC-Paket erzeugt immer CC +1 und REDESIGN_PAID +1. Die Zeilen dafuer baut
buildPackageRows, das sowohl die manuelle Eingabe als auch der Import nutzen -
so koennen beide Wege nicht auseinanderlaufen.

Ein CC kommt nur dann gratis zurueck, wenn er wegen Spieler-Inaktivitaet
entfernt wurde. Ein reworkter CC existiert im Original nicht mehr und kostet
deshalb ein Redesign- oder CC-Guthaben.

Ueberzogene Toepfe werden zugelassen statt blockiert; die Aktion meldet das
ueber ein overdrawn-Flag zurueck. Storniert wird per Gegenbuchung, nie durch
Loeschen. Der Import berechnet den Plan vorab und dedupliziert ueber die
Bestell-ID, sowohl gegen bereits gebuchte als auch innerhalb einer Datei.
2026-09-18 11:37:31 +02:00
Harlekin 3d2b87297e Authentifizierung mit serverseitigen Sitzungen, CSRF und Rollen
Passwoerter mit Argon2id, im Cookie steht nur ein Token und in der Datenbank
dessen Hash. Das CSRF-Token wird per HMAC aus dem Sitzungstoken abgeleitet
statt separat gespeichert - ohne das httpOnly-Cookie ist es nicht herstellbar.

TOTP ist optional und wird erst nach einem bestaetigten Code scharf geschaltet,
damit sich niemand bei der Einrichtung aussperrt.
2026-09-18 11:33:25 +02:00
Harlekin a1b764155a Datenmodell fuer Spieler, Guthaben-Ledger und CCs
Guthaben werden als Kontobuch gefuehrt: jede Bewegung ist eine Zeile, der
Stand eines Topfes ist deren Summe. Zusammengehoerige Zeilen teilen sich eine
groupId, damit ein CC-Paket als Ganzes storniert werden kann; ein unique
Constraint auf reversalOfId verhindert doppelte Stornos.

CCs bilden ueber lineageId und predecessorId eine Redesign-Kette. Der Grund
einer Deaktivierung entscheidet spaeter ueber die Reaktivierungskosten.
2026-09-18 11:33:25 +02:00
Harlekin d7e7a3b186 Projektgeruest mit Workspaces fuer Server und Weboberflaeche
Fastify mit Prisma im Server-Workspace, React mit Vite im Web-Workspace.
Das Frontend baut direkt nach server/public, damit am Ende ein einzelnes
Image API und Oberflaeche ausliefert.
2026-09-18 11:31:13 +02:00
61 changed files with 13528 additions and 1 deletions
+14
View File
@@ -0,0 +1,14 @@
node_modules
**/node_modules
**/dist
server/public
.git
.gitignore
*.md
.env
.env.*
!.env.example
**/*.log
coverage
.idea
.vscode
+24
View File
@@ -0,0 +1,24 @@
# Kopie als .env neben die compose.yaml legen und Werte ersetzen.
# Passwort des Datenbank-Nutzers. Lang und zufaellig waehlen.
DB_PASSWORD=hier-ein-langes-zufaelliges-passwort
# Schluessel fuer Sitzungen und CSRF-Token, mindestens 32 Zeichen.
# Erzeugen z. B. mit: openssl rand -base64 48
SESSION_SECRET=hier-einen-langen-zufallsstring-einsetzen
# Erster Administrator, wird nur angelegt solange noch kein Konto existiert.
# Bleibt das Passwort leer, erzeugt die App eines und schreibt es einmalig
# ins Container-Log. In beiden Faellen ist ein Wechsel beim ersten Login Pflicht.
BOOTSTRAP_ADMIN_USER=admin
BOOTSTRAP_ADMIN_PASSWORD=
# --- Optional ---
# Gueltigkeit einer Sitzung in Stunden (Standard: 12).
# SESSION_TTL_HOURS=12
# Nur abschalten, wenn ohne HTTPS getestet wird.
# COOKIE_SECURE=true
# LOG_LEVEL=info
+40
View File
@@ -0,0 +1,40 @@
# Dependencies
node_modules/
.pnp
.pnp.js
# Build output
dist/
build/
*.tsbuildinfo
.vite/
# Frontend-Bundle, wird beim Build erzeugt
server/public/
# Environment
.env
.env.local
.env.*.local
!.env.example
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Runtime data
uploads/
tmp/
coverage/
# Editor / OS
.DS_Store
Thumbs.db
.idea/
*.swp
*~
# Local notes
notes/
+54
View File
@@ -0,0 +1,54 @@
# --- Build ---
FROM node:22-slim AS build
# Prisma benoetigt OpenSSL fuer seine Query-Engine.
RUN apt-get update && apt-get install -y --no-install-recommends openssl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json package-lock.json ./
COPY server/package.json ./server/
COPY web/package.json ./web/
RUN npm ci
COPY server/prisma ./server/prisma
RUN npx prisma generate --schema server/prisma/schema.prisma
COPY server ./server
COPY web ./web
# Das Frontend landet laut vite.config.ts direkt in server/public.
RUN npm run build --workspace=web \
&& npx tsc -p server/tsconfig.json
# --- Laufzeit ---
FROM node:22-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends openssl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
COPY server/package.json ./server/
COPY web/package.json ./web/
RUN npm ci --omit=dev && npm cache clean --force
COPY server/prisma ./server/prisma
RUN npx prisma generate --schema server/prisma/schema.prisma
COPY --from=build /app/server/dist ./server/dist
COPY --from=build /app/server/public ./server/public
COPY docker-entrypoint.sh ./
RUN chmod +x docker-entrypoint.sh && chown -R node:node /app
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
ENTRYPOINT ["./docker-entrypoint.sh"]
CMD ["node", "server/dist/index.js"]
+176 -1
View File
@@ -1,3 +1,178 @@
# CC-Manager # CC-Manager
For managing Custom Characters on a Server Selbst gehostete Web-App zur Verwaltung von Custom Characters (CCs) auf einem SCP-RP-Server.
Sie führt pro Spieler ein Konto über gekaufte Ansprüche und deren Verbrauch, damit die Frage
„darf der gerade einen neuen CC?" nicht mehr von Hand abgeglichen werden muss.
## Das Prinzip
Jeder Spieler hat drei Guthaben-Töpfe:
| Topf | Wird gefüllt durch | Wird verbraucht durch |
|------|--------------------|-----------------------|
| CC-Guthaben | jedes CC-Paket (+1) | ein neuer CC |
| Redesign-Guthaben (bezahlt) | jedes CC-Paket (+1, immer inklusive) und jedes Redesign-Paket (+1) | ein Redesign |
| Kulanz-Redesign | manuell eingetragene Kulanz (+1) | ein Redesign |
Zwei Regeln sind fest verdrahtet:
1. **Ein CC-Paket erzeugt beide Guthaben.** Der inklusive Redesign wird automatisch mitgebucht,
in derselben Transaktion und mit gemeinsamer Gruppen-ID — ein Storno nimmt beide zurück.
2. **Inaktive CCs kosten unterschiedlich.** Wurde ein CC nur wegen Spieler-Inaktivität vom Server
entfernt, kommt er gratis zurück. Wurde er redesignt und existiert im Original nicht mehr,
kostet die Reaktivierung ein Redesign- oder CC-Guthaben.
Guthaben werden als **Kontobuch** geführt: jede Bewegung ist eine eigene Zeile, der Stand eines
Topfes ist deren Summe. Das macht jeden Stand nachvollziehbar und Korrekturen sauber — storniert
wird per Gegenbuchung, nie durch Löschen. Ein überzogenes Konto ist erlaubt (kommt vor, wenn ein
Redesign vor dem zugehörigen Paket erfasst wird), wird aber rot markiert.
Ein Redesign legt einen **neuen** CC-Datensatz an und verkettet ihn mit dem alten. So bleibt die
Kette „A → B → C" sichtbar und die Reaktivierungsregel greift automatisch richtig.
## Schnellstart
Vorausgesetzt wird Docker mit Compose-Plugin (oder DockGe).
```bash
git clone https://git.harlelux.de/Harlekin/CC-Manager.git
cd CC-Manager
cp .env.example .env
```
In der `.env` mindestens `DB_PASSWORD` und `SESSION_SECRET` setzen:
```bash
openssl rand -base64 48 # für SESSION_SECRET
```
Dann starten:
```bash
docker compose up -d --build
docker compose logs -f app
```
Beim ersten Start legt die App einen Administrator an. Ist `BOOTSTRAP_ADMIN_PASSWORD` leer,
erzeugt sie ein Einmal-Passwort und schreibt es **einmalig** ins Log — dort steht es in der
Ausgabe oben. Beim ersten Login ist ein Passwortwechsel Pflicht; bis dahin sind alle anderen
Seiten gesperrt.
Für DockGe: Repo-Inhalt als Stack anlegen, `.env` daneben, fertig. Datenbank-Migrationen laufen
bei jedem Containerstart automatisch mit.
## HTTPS
Der App-Port wird bewusst **nicht** nach außen gemappt. Davor gehört ein Reverse Proxy
(Nginx Proxy Manager, Traefik oder Caddy), der HTTPS terminiert und intern auf `app:3000`
weiterleitet. So ist nur Port 443 offen und die Zertifikate laufen über Let's Encrypt.
Läuft die App hinter einem Proxy, muss `TRUST_PROXY=true` gesetzt bleiben — sonst landen im
Audit-Log die Proxy-IPs statt der echten. In Produktion werden Cookies automatisch auf `Secure`
gesetzt; nur zum Testen ohne TLS lässt sich das über `COOKIE_SECURE=false` abschalten.
## Backups
Der `backup`-Service im Stack schreibt beim Start und danach alle 24 Stunden einen
`pg_dump` nach `./backups/` und räumt Dumps älter als 14 Tage weg. Die Daten bilden bezahlte
Ansprüche ab — das Verzeichnis gehört auf eine Sicherung außerhalb des Hosts.
Wiederherstellen:
```bash
gunzip -c backups/ccmanager-2025-01-15-0300.sql.gz | \
docker compose exec -T db psql -U ccuser -d ccmanager
```
## Import
Kaufdaten lassen sich als CSV oder JSON hochladen; das Format ist nicht festgelegt. Der Ablauf:
1. Datei hochladen — Spalten und Beispielzeilen werden angezeigt.
2. Spalten zuordnen (Spieler-Kennung, Produktname, optional Bestell-ID und Datum). Die üblichen
Spaltennamen werden vorausgewählt.
3. **Vorschau** prüfen: für jede Zeile steht da, was passieren würde.
4. Buchen.
Welcher Produktname welches Guthaben ergibt, steht in der Mapping-Tabelle unter *Verwaltung →
Produkt-Mappings* (Teiltreffer, Groß-/Kleinschreibung egal). Neue Produkte lassen sich dort
jederzeit ergänzen, ohne den Code anzufassen.
Wird eine Bestell-ID zugeordnet, bucht ein zweiter Import dieselben Käufe **nicht** erneut —
Dubletten werden sowohl gegen bereits gebuchte Bestellungen als auch innerhalb der Datei erkannt.
Ohne Bestell-ID entfällt dieser Schutz; darauf weist das Formular hin.
Kulanz-Redesigns tauchen in keinem Shop-Export auf und werden immer von Hand eingetragen.
## Rollen
Es gibt **keine öffentliche Registrierung**. Konten legt ausschließlich ein Administrator an.
- **Viewer** — darf nur lesen.
- **Editor** — Spieler anlegen, Guthaben eintragen, CCs erstellen, redesignen, reaktivieren.
- **Admin** — zusätzlich Import, Nutzerverwaltung, Produkt-Mappings, Audit-Log und Korrekturbuchungen.
Jede ändernde Aktion landet im Audit-Log. Für Administratoren lässt sich unter *Mein Konto*
eine Zwei-Faktor-Anmeldung per TOTP einrichten.
## Entwicklung
Gebraucht werden Node 20+ und eine erreichbare PostgreSQL-Instanz.
```bash
npm install
cp server/.env.example server/.env # DATABASE_URL auf die lokale DB zeigen lassen
npm run migrate:dev
npm run dev # API auf :3000, Frontend auf :5173
```
Das Frontend leitet `/api` im Entwicklungsbetrieb an den API-Port weiter, Anmeldung und Cookies
funktionieren also wie in Produktion.
Die Tests brauchen **keine** laufende Datenbank — sie starten eine Postgres-Instanz im Speicher:
```bash
npm test
```
Abgedeckt sind die Guthaben-Regeln, der CC-Lebenszyklus samt Reaktivierungslogik, Korrekturen
und Stornos, Anmeldung mit CSRF- und Rollenprüfung sowie der Import mit Dedup.
### Aufbau
```
server/
prisma/schema.prisma Datenmodell und Migrationen
src/services/ Fachlogik: Ledger, CC-Lebenszyklus, Import
src/routes/ HTTP-Endpunkte
src/auth/ Passwörter, Sitzungen, CSRF, Rollen, TOTP
test/ Integrationstests gegen eine echte Postgres-Instanz
web/
src/pages/ Login, Übersicht, Spieler-Detail, Import, Verwaltung
src/components/ Wiederverwendete Bausteine
```
Backend ist Fastify mit Prisma, Frontend eine React-SPA. Gebaut wird beides in ein einziges
Image: Fastify liefert die API und die fertigen Frontend-Dateien aus.
Nach Änderungen am Datenmodell:
```bash
npm run migrate:dev -- --name beschreibung-der-aenderung
```
## Sicherheit
- Passwörter mit Argon2id gehasht, Mindestlänge 12 Zeichen.
- Sitzungen serverseitig; im Cookie steht nur ein Token, in der Datenbank dessen Hash.
Cookie ist `httpOnly`, `SameSite=Lax` und in Produktion `Secure`.
- CSRF-Schutz für alle ändernden Anfragen über ein aus der Sitzung abgeleitetes Token.
- Alle Eingaben werden validiert, alle Datenbankzugriffe laufen parametrisiert über das ORM.
- Rate-Limit auf dem Login, generische Fehlermeldung ohne Hinweis auf existierende Konten.
- Sicherheits-Header inklusive Content-Security-Policy.
- Ein gesperrtes Konto verliert seine offenen Sitzungen sofort; der letzte aktive Administrator
kann sich nicht selbst entfernen.
## Lizenz
GPL-3.0-or-later, siehe [LICENSE](LICENSE).
+57
View File
@@ -0,0 +1,57 @@
services:
app:
build: .
# Alternativ ein vorgebautes Image verwenden:
# image: ghcr.io/harlekin/cc-manager:latest
restart: unless-stopped
environment:
DATABASE_URL: postgres://ccuser:${DB_PASSWORD}@db:5432/ccmanager
SESSION_SECRET: ${SESSION_SECRET}
NODE_ENV: production
# Erstzugang; nach der ersten Anmeldung wird ein Wechsel erzwungen.
BOOTSTRAP_ADMIN_USER: ${BOOTSTRAP_ADMIN_USER:-admin}
BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-}
TRUST_PROXY: "true"
expose:
# Bewusst nicht nach aussen gemappt: der Reverse Proxy terminiert HTTPS.
- "3000"
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ccuser
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ccmanager
volumes:
- cc_db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ccuser -d ccmanager"]
interval: 10s
timeout: 5s
retries: 5
backup:
image: postgres:16-alpine
restart: unless-stopped
environment:
PGPASSWORD: ${DB_PASSWORD}
volumes:
- ./backups:/backups
depends_on:
db:
condition: service_healthy
# Dump beim Start und danach alle 24 Stunden, Aufbewahrung 14 Tage.
entrypoint: >
sh -c 'while true; do
pg_dump -h db -U ccuser -d ccmanager | gzip > /backups/ccmanager-$$(date +%F-%H%M).sql.gz &&
echo "Backup geschrieben: $$(date +%F-%H%M)";
find /backups -name "ccmanager-*.sql.gz" -mtime +14 -delete;
sleep 86400;
done'
volumes:
cc_db:
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
set -e
# Schema aktuell halten, bevor die App startet. Bereits angewandte
# Migrationen werden dabei uebersprungen.
echo "Wende Datenbank-Migrationen an ..."
npx prisma migrate deploy --schema server/prisma/schema.prisma
exec "$@"
+5059
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "cc-manager",
"version": "1.0.0",
"private": true,
"description": "Tracking von Custom Characters, Paketen und Redesigns",
"license": "GPL-3.0-or-later",
"workspaces": [
"server",
"web"
],
"engines": {
"node": ">=20"
},
"scripts": {
"dev": "concurrently -n api,web -c blue,magenta \"npm:dev:api\" \"npm:dev:web\"",
"dev:api": "npm run dev --workspace=server",
"dev:web": "npm run dev --workspace=web",
"build": "npm run build --workspace=web && npm run build --workspace=server",
"start": "npm run start --workspace=server",
"migrate": "npm run migrate --workspace=server",
"migrate:dev": "npm run migrate:dev --workspace=server",
"generate": "npm run generate --workspace=server",
"typecheck": "npm run typecheck --workspace=server && npm run typecheck --workspace=web",
"test": "npm run test --workspace=server"
},
"devDependencies": {
"concurrently": "^9.1.2"
}
}
+15
View File
@@ -0,0 +1,15 @@
# Nur fuer die lokale Entwicklung. Als server/.env kopieren.
# Im Container kommen diese Werte aus der compose.yaml.
DATABASE_URL=postgresql://ccuser:passwort@localhost:5432/ccmanager
SESSION_SECRET=lokaler-entwicklungsschluessel-mindestens-32-zeichen
NODE_ENV=development
PORT=3000
# Ohne HTTPS muessen die Cookies unsicher ausgeliefert werden duerfen.
COOKIE_SECURE=false
# Erstadministrator beim ersten Start.
BOOTSTRAP_ADMIN_USER=admin
BOOTSTRAP_ADMIN_PASSWORD=EntwicklungPasswort1
+41
View File
@@ -0,0 +1,41 @@
{
"name": "@cc-manager/server",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch --env-file-if-exists=.env src/index.ts",
"build": "prisma generate && tsc -p tsconfig.json",
"start": "node dist/index.js",
"generate": "prisma generate",
"migrate": "prisma migrate deploy",
"migrate:dev": "prisma migrate dev",
"studio": "prisma studio",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "node --import tsx --test test/*.test.ts"
},
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/helmet": "^13.0.1",
"@fastify/multipart": "^9.0.3",
"@fastify/rate-limit": "^10.2.2",
"@fastify/static": "^8.1.1",
"@node-rs/argon2": "^2.0.2",
"@prisma/client": "^6.5.0",
"csv-parse": "^5.6.0",
"fastify": "^5.2.1",
"otplib": "^13.5.0",
"prisma": "^6.5.0",
"qrcode": "^1.5.4",
"zod": "^3.24.2"
},
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"@electric-sql/pglite-socket": "^0.2.11",
"@types/node": "^22.13.10",
"@types/qrcode": "^1.5.5",
"tsx": "^4.19.3",
"typescript": "^5.8.2"
}
}
@@ -0,0 +1,286 @@
-- CreateSchema
CREATE SCHEMA IF NOT EXISTS "public";
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('ADMIN', 'EDITOR', 'VIEWER');
-- CreateEnum
CREATE TYPE "CreditKind" AS ENUM ('CC', 'REDESIGN_PAID', 'REDESIGN_KULANZ');
-- CreateEnum
CREATE TYPE "CreditSource" AS ENUM ('CC_PACKAGE', 'REDESIGN_PACKAGE', 'KULANZ', 'ADJUSTMENT', 'CONSUME', 'REVERSAL');
-- CreateEnum
CREATE TYPE "CharacterStatus" AS ENUM ('ACTIVE', 'INACTIVE');
-- CreateEnum
CREATE TYPE "InactiveReason" AS ENUM ('PLAYER_INACTIVITY', 'REWORKED', 'OTHER');
-- CreateEnum
CREATE TYPE "ProductTarget" AS ENUM ('CC_PACKAGE', 'REDESIGN_PACKAGE', 'IGNORE');
-- CreateEnum
CREATE TYPE "ImportStatus" AS ENUM ('PENDING', 'COMMITTED', 'DISCARDED');
-- CreateTable
CREATE TABLE "User" (
"id" SERIAL NOT NULL,
"username" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"role" "Role" NOT NULL DEFAULT 'EDITOR',
"totpSecret" TEXT,
"totpEnabled" BOOLEAN NOT NULL DEFAULT false,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"mustChangePassword" BOOLEAN NOT NULL DEFAULT false,
"lastLoginAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"ip" TEXT,
"userAgent" TEXT,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Player" (
"id" SERIAL NOT NULL,
"displayName" TEXT NOT NULL,
"steamId" TEXT,
"forumName" TEXT,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Player_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CreditLedger" (
"id" SERIAL NOT NULL,
"playerId" INTEGER NOT NULL,
"kind" "CreditKind" NOT NULL,
"amount" INTEGER NOT NULL,
"source" "CreditSource" NOT NULL,
"groupId" TEXT NOT NULL,
"characterId" INTEGER,
"importId" INTEGER,
"note" TEXT,
"reversalOfId" INTEGER,
"createdById" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CreditLedger_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Character" (
"id" SERIAL NOT NULL,
"playerId" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"loreText" TEXT,
"status" "CharacterStatus" NOT NULL DEFAULT 'ACTIVE',
"inactiveReason" "InactiveReason",
"lineageId" TEXT NOT NULL,
"predecessorId" INTEGER,
"createdById" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Character_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Import" (
"id" SERIAL NOT NULL,
"filename" TEXT NOT NULL,
"status" "ImportStatus" NOT NULL DEFAULT 'PENDING',
"rowCount" INTEGER NOT NULL DEFAULT 0,
"bookedCount" INTEGER NOT NULL DEFAULT 0,
"mapping" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"committedAt" TIMESTAMP(3),
"importedById" INTEGER,
CONSTRAINT "Import_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ImportRow" (
"id" SERIAL NOT NULL,
"importId" INTEGER NOT NULL,
"lineNo" INTEGER NOT NULL,
"externalId" TEXT,
"playerKey" TEXT,
"playerName" TEXT,
"productName" TEXT,
"purchasedAt" TIMESTAMP(3),
"target" "ProductTarget",
"resolvedPlayerId" INTEGER,
"plan" TEXT NOT NULL,
"raw" JSONB NOT NULL,
CONSTRAINT "ImportRow_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ImportedOrder" (
"id" SERIAL NOT NULL,
"externalId" TEXT NOT NULL,
"importId" INTEGER NOT NULL,
"playerId" INTEGER NOT NULL,
"target" "ProductTarget" NOT NULL,
"groupId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ImportedOrder_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProductMapping" (
"id" SERIAL NOT NULL,
"matchPattern" TEXT NOT NULL,
"target" "ProductTarget" NOT NULL,
"priority" INTEGER NOT NULL DEFAULT 100,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"note" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProductMapping_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" SERIAL NOT NULL,
"actorUserId" INTEGER,
"actorName" TEXT,
"action" TEXT NOT NULL,
"targetType" TEXT NOT NULL,
"targetId" TEXT,
"details" JSONB,
"ip" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- CreateIndex
CREATE UNIQUE INDEX "Session_tokenHash_key" ON "Session"("tokenHash");
-- CreateIndex
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
-- CreateIndex
CREATE INDEX "Session_expiresAt_idx" ON "Session"("expiresAt");
-- CreateIndex
CREATE UNIQUE INDEX "Player_steamId_key" ON "Player"("steamId");
-- CreateIndex
CREATE INDEX "Player_displayName_idx" ON "Player"("displayName");
-- CreateIndex
CREATE UNIQUE INDEX "CreditLedger_reversalOfId_key" ON "CreditLedger"("reversalOfId");
-- CreateIndex
CREATE INDEX "CreditLedger_playerId_idx" ON "CreditLedger"("playerId");
-- CreateIndex
CREATE INDEX "CreditLedger_playerId_kind_idx" ON "CreditLedger"("playerId", "kind");
-- CreateIndex
CREATE INDEX "CreditLedger_groupId_idx" ON "CreditLedger"("groupId");
-- CreateIndex
CREATE INDEX "CreditLedger_createdAt_idx" ON "CreditLedger"("createdAt");
-- CreateIndex
CREATE INDEX "Character_playerId_idx" ON "Character"("playerId");
-- CreateIndex
CREATE INDEX "Character_playerId_status_idx" ON "Character"("playerId", "status");
-- CreateIndex
CREATE INDEX "Character_lineageId_idx" ON "Character"("lineageId");
-- CreateIndex
CREATE INDEX "Import_status_idx" ON "Import"("status");
-- CreateIndex
CREATE INDEX "ImportRow_importId_idx" ON "ImportRow"("importId");
-- CreateIndex
CREATE UNIQUE INDEX "ImportedOrder_externalId_key" ON "ImportedOrder"("externalId");
-- CreateIndex
CREATE INDEX "ImportedOrder_playerId_idx" ON "ImportedOrder"("playerId");
-- CreateIndex
CREATE INDEX "ProductMapping_priority_idx" ON "ProductMapping"("priority");
-- CreateIndex
CREATE INDEX "AuditLog_createdAt_idx" ON "AuditLog"("createdAt");
-- CreateIndex
CREATE INDEX "AuditLog_targetType_targetId_idx" ON "AuditLog"("targetType", "targetId");
-- CreateIndex
CREATE INDEX "AuditLog_actorUserId_idx" ON "AuditLog"("actorUserId");
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CreditLedger" ADD CONSTRAINT "CreditLedger_playerId_fkey" FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CreditLedger" ADD CONSTRAINT "CreditLedger_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CreditLedger" ADD CONSTRAINT "CreditLedger_importId_fkey" FOREIGN KEY ("importId") REFERENCES "Import"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CreditLedger" ADD CONSTRAINT "CreditLedger_reversalOfId_fkey" FOREIGN KEY ("reversalOfId") REFERENCES "CreditLedger"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "CreditLedger" ADD CONSTRAINT "CreditLedger_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Character" ADD CONSTRAINT "Character_playerId_fkey" FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Character" ADD CONSTRAINT "Character_predecessorId_fkey" FOREIGN KEY ("predecessorId") REFERENCES "Character"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "Character" ADD CONSTRAINT "Character_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Import" ADD CONSTRAINT "Import_importedById_fkey" FOREIGN KEY ("importedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ImportRow" ADD CONSTRAINT "ImportRow_importId_fkey" FOREIGN KEY ("importId") REFERENCES "Import"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ImportedOrder" ADD CONSTRAINT "ImportedOrder_importId_fkey" FOREIGN KEY ("importId") REFERENCES "Import"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ImportedOrder" ADD CONSTRAINT "ImportedOrder_playerId_fkey" FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_actorUserId_fkey" FOREIGN KEY ("actorUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+259
View File
@@ -0,0 +1,259 @@
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-3.0.x"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum Role {
ADMIN
EDITOR
VIEWER
}
enum CreditKind {
CC
REDESIGN_PAID
REDESIGN_KULANZ
}
enum CreditSource {
CC_PACKAGE
REDESIGN_PACKAGE
KULANZ
ADJUSTMENT
CONSUME
REVERSAL
}
enum CharacterStatus {
ACTIVE
INACTIVE
}
enum InactiveReason {
PLAYER_INACTIVITY
REWORKED
OTHER
}
enum ProductTarget {
CC_PACKAGE
REDESIGN_PACKAGE
IGNORE
}
enum ImportStatus {
PENDING
COMMITTED
DISCARDED
}
model User {
id Int @id @default(autoincrement())
username String @unique
passwordHash String
role Role @default(EDITOR)
totpSecret String?
totpEnabled Boolean @default(false)
isActive Boolean @default(true)
mustChangePassword Boolean @default(false)
lastLoginAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions Session[]
auditEntries AuditLog[]
ledgerEntries CreditLedger[] @relation("LedgerCreatedBy")
charactersCreated Character[] @relation("CharacterCreatedBy")
imports Import[]
}
model Session {
id String @id @default(uuid())
tokenHash String @unique
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
expiresAt DateTime
createdAt DateTime @default(now())
lastSeenAt DateTime @default(now())
ip String?
userAgent String?
@@index([userId])
@@index([expiresAt])
}
model Player {
id Int @id @default(autoincrement())
displayName String
steamId String? @unique
forumName String?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ledger CreditLedger[]
characters Character[]
orders ImportedOrder[]
@@index([displayName])
}
/// Kontobuch: jede Guthaben-Bewegung ist eine Zeile (+1 Zugang, -1 Verbrauch).
/// Der offene Stand eines Topfes ist die Summe der amounts je kind.
model CreditLedger {
id Int @id @default(autoincrement())
player Player @relation(fields: [playerId], references: [id], onDelete: Cascade)
playerId Int
kind CreditKind
amount Int
source CreditSource
/// Klammert zusammengehoerige Zeilen: ein CC-Paket schreibt zwei Zeilen
/// mit derselben groupId, damit ein Storno beide zurueckholt.
groupId String @default(uuid())
characterId Int?
character Character? @relation(fields: [characterId], references: [id], onDelete: SetNull)
importId Int?
import Import? @relation(fields: [importId], references: [id], onDelete: SetNull)
note String?
/// Storno erfolgt als Gegenbuchung, nicht als Loeschung. Das @unique
/// verhindert, dass eine Zeile zweimal storniert wird.
reversalOfId Int? @unique
reversalOf CreditLedger? @relation("LedgerReversal", fields: [reversalOfId], references: [id], onDelete: NoAction, onUpdate: NoAction)
reversedBy CreditLedger? @relation("LedgerReversal")
createdById Int?
createdBy User? @relation("LedgerCreatedBy", fields: [createdById], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
@@index([playerId])
@@index([playerId, kind])
@@index([groupId])
@@index([createdAt])
}
model Character {
id Int @id @default(autoincrement())
playerId Int
player Player @relation(fields: [playerId], references: [id], onDelete: Cascade)
name String
loreText String?
status CharacterStatus @default(ACTIVE)
inactiveReason InactiveReason?
/// Alle CCs einer Redesign-Kette teilen sich die lineageId.
lineageId String @default(uuid())
predecessorId Int?
predecessor Character? @relation("CharacterLineage", fields: [predecessorId], references: [id], onDelete: NoAction, onUpdate: NoAction)
successors Character[] @relation("CharacterLineage")
ledgerEntries CreditLedger[]
createdById Int?
createdBy User? @relation("CharacterCreatedBy", fields: [createdById], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([playerId])
@@index([playerId, status])
@@index([lineageId])
}
model Import {
id Int @id @default(autoincrement())
filename String
status ImportStatus @default(PENDING)
rowCount Int @default(0)
bookedCount Int @default(0)
mapping Json?
createdAt DateTime @default(now())
committedAt DateTime?
importedById Int?
importedBy User? @relation(fields: [importedById], references: [id], onDelete: SetNull)
rows ImportRow[]
orders ImportedOrder[]
ledgerEntries CreditLedger[]
@@index([status])
}
model ImportRow {
id Int @id @default(autoincrement())
importId Int
import Import @relation(fields: [importId], references: [id], onDelete: Cascade)
lineNo Int
externalId String?
playerKey String?
playerName String?
productName String?
purchasedAt DateTime?
target ProductTarget?
resolvedPlayerId Int?
/// Was beim Buchen passieren wuerde: BOOK, CREATE_PLAYER_AND_BOOK,
/// SKIP_DUPLICATE, SKIP_IGNORED, SKIP_UNMAPPED, SKIP_NO_PLAYER
plan String
raw Json
@@index([importId])
}
/// Dedup-Anker: eine bereits gebuchte Bestell-ID wird nie ein zweites Mal gebucht.
model ImportedOrder {
id Int @id @default(autoincrement())
externalId String @unique
importId Int
import Import @relation(fields: [importId], references: [id], onDelete: Cascade)
playerId Int
player Player @relation(fields: [playerId], references: [id], onDelete: Cascade)
target ProductTarget
groupId String
createdAt DateTime @default(now())
@@index([playerId])
}
model ProductMapping {
id Int @id @default(autoincrement())
matchPattern String
target ProductTarget
priority Int @default(100)
isActive Boolean @default(true)
note String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([priority])
}
model AuditLog {
id Int @id @default(autoincrement())
actorUserId Int?
actor User? @relation(fields: [actorUserId], references: [id], onDelete: SetNull)
/// Name wird mitgeschrieben, damit der Eintrag lesbar bleibt, wenn der Account weg ist.
actorName String?
action String
targetType String
targetId String?
details Json?
ip String?
createdAt DateTime @default(now())
@@index([createdAt])
@@index([targetType, targetId])
@@index([actorUserId])
}
+153
View File
@@ -0,0 +1,153 @@
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import Fastify, { type FastifyInstance } from 'fastify';
import cookie from '@fastify/cookie';
import helmet from '@fastify/helmet';
import multipart from '@fastify/multipart';
import rateLimit from '@fastify/rate-limit';
import fastifyStatic from '@fastify/static';
import { Prisma } from '@prisma/client';
import { env } from './env.js';
import { AppError } from './errors.js';
import { attachUser, blockUntilPasswordChanged, enforceCsrf } from './auth/guard.js';
import { authRoutes } from './routes/auth.routes.js';
import { playerRoutes } from './routes/players.routes.js';
import { creditRoutes } from './routes/credits.routes.js';
import { characterRoutes } from './routes/characters.routes.js';
import { importRoutes } from './routes/imports.routes.js';
import { adminRoutes } from './routes/admin.routes.js';
const here = dirname(fileURLToPath(import.meta.url));
// Im Container liegt das gebaute Frontend neben dem kompilierten Server.
const webRoot = join(here, '..', 'public');
function readStatusCode(error: unknown): number | undefined {
const value = (error as { statusCode?: unknown }).statusCode;
return typeof value === 'number' ? value : undefined;
}
function readMessage(error: unknown): string | undefined {
const value = (error as { message?: unknown }).message;
return typeof value === 'string' ? value : undefined;
}
export async function buildApp(): Promise<FastifyInstance> {
const app = Fastify({
logger: {
level: env.LOG_LEVEL,
redact: ['req.headers.cookie', 'req.headers.authorization'],
},
trustProxy: env.TRUST_PROXY,
bodyLimit: 2 * 1024 * 1024,
});
await app.register(helmet, {
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
// Der QR-Code der 2FA-Einrichtung kommt als data-URL.
imgSrc: ["'self'", 'data:'],
styleSrc: ["'self'", "'unsafe-inline'"],
connectSrc: ["'self'"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
frameAncestors: ["'none'"],
formAction: ["'self'"],
},
},
crossOriginEmbedderPolicy: false,
hsts: env.isProduction ? { maxAge: 31536000, includeSubDomains: true } : false,
});
await app.register(cookie, { secret: env.SESSION_SECRET });
await app.register(rateLimit, {
global: true,
max: 300,
timeWindow: '1 minute',
allowList: () => !env.isProduction,
});
await app.register(multipart, {
limits: { fileSize: 10 * 1024 * 1024, files: 1 },
});
app.addHook('onRequest', attachUser);
app.addHook('onRequest', enforceCsrf);
app.addHook('preHandler', blockUntilPasswordChanged);
app.setErrorHandler((error, request, reply) => {
if (error instanceof AppError) {
reply.code(error.statusCode).send({ error: error.code, message: error.message });
return;
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === 'P2002') {
reply.code(409).send({ error: 'UNIQUE_VIOLATION', message: 'Dieser Wert ist bereits vergeben' });
return;
}
if (error.code === 'P2025') {
reply.code(404).send({ error: 'NOT_FOUND', message: 'Datensatz nicht gefunden' });
return;
}
}
const statusCode = readStatusCode(error);
if (statusCode === 429) {
reply.code(429).send({ error: 'RATE_LIMITED', message: 'Zu viele Anfragen, bitte kurz warten' });
return;
}
if (statusCode !== undefined && statusCode >= 400 && statusCode < 500) {
reply
.code(statusCode)
.send({ error: 'BAD_REQUEST', message: readMessage(error) ?? 'Ungueltige Anfrage' });
return;
}
request.log.error({ err: error }, 'Unbehandelter Fehler');
reply.code(500).send({ error: 'INTERNAL', message: 'Interner Fehler' });
});
app.get('/api/health', async () => ({ status: 'ok' }));
await app.register(authRoutes);
await app.register(playerRoutes);
await app.register(creditRoutes);
await app.register(characterRoutes);
await app.register(importRoutes);
await app.register(adminRoutes);
if (existsSync(webRoot)) {
await app.register(fastifyStatic, {
root: webRoot,
prefix: '/',
// Gehashte Assets duerfen lange liegen bleiben, index.html nie.
maxAge: '1y',
setHeaders: (res, path) => {
if (path.endsWith('index.html')) {
res.setHeader('Cache-Control', 'no-cache');
}
},
});
}
// Alle nicht-API-Pfade gehen an die SPA, damit Deep-Links funktionieren.
app.setNotFoundHandler((request, reply) => {
if (request.url.startsWith('/api/')) {
reply.code(404).send({ error: 'NOT_FOUND', message: 'Unbekannter Endpunkt' });
return;
}
if (existsSync(webRoot)) {
reply.sendFile('index.html');
return;
}
reply.code(404).send({ error: 'NOT_FOUND', message: 'Frontend ist nicht gebaut' });
});
return app;
}
+72
View File
@@ -0,0 +1,72 @@
import type { FastifyReply, FastifyRequest, preHandlerHookHandler } from 'fastify';
import type { Role } from '@prisma/client';
import { forbidden, unauthorized } from '../errors.js';
import { CSRF_HEADER, SESSION_COOKIE, csrfMatches, resolveSession } from './session.js';
import type { SessionUser } from './session.js';
declare module 'fastify' {
interface FastifyRequest {
user?: SessionUser;
sessionToken?: string;
}
}
const RANK: Record<Role, number> = { VIEWER: 1, EDITOR: 2, ADMIN: 3 };
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
// Ohne diese Ausnahme koennte sich niemand anmelden: vor dem Login gibt es
// weder Session noch ableitbares CSRF-Token.
const CSRF_EXEMPT = new Set(['/api/auth/login', '/api/auth/login/totp']);
export async function attachUser(request: FastifyRequest): Promise<void> {
const token = request.cookies[SESSION_COOKIE];
if (!token) return;
const user = await resolveSession(token);
if (user) {
request.user = user;
request.sessionToken = token;
}
}
export async function enforceCsrf(request: FastifyRequest, _reply: FastifyReply): Promise<void> {
if (SAFE_METHODS.has(request.method)) return;
if (CSRF_EXEMPT.has(request.url.split('?')[0] ?? '')) return;
const token = request.cookies[SESSION_COOKIE];
if (!token) return; // Ohne Session greift ohnehin requireAuth.
const header = request.headers[CSRF_HEADER];
const presented = Array.isArray(header) ? header[0] : header;
if (!csrfMatches(token, presented)) {
throw forbidden('CSRF-Token fehlt oder passt nicht');
}
}
export const requireAuth: preHandlerHookHandler = async (request) => {
if (!request.user) throw unauthorized();
};
export function requireRole(minimum: Role): preHandlerHookHandler {
return async (request) => {
if (!request.user) throw unauthorized();
if (RANK[request.user.role] < RANK[minimum]) {
throw forbidden();
}
};
}
// Solange ein Erstpasswort nicht gewechselt ist, bleiben schreibende Wege zu.
export const blockUntilPasswordChanged: preHandlerHookHandler = async (request) => {
if (request.user?.mustChangePassword && !SAFE_METHODS.has(request.method)) {
const path = request.url.split('?')[0] ?? '';
if (path !== '/api/account/password' && path !== '/api/auth/logout') {
throw forbidden('Bitte zuerst das Passwort aendern');
}
}
};
export const requireEditor = requireRole('EDITOR');
export const requireAdmin = requireRole('ADMIN');
+36
View File
@@ -0,0 +1,36 @@
import { hash, verify } from '@node-rs/argon2';
// OWASP-Empfehlung fuer Argon2id: 19 MiB, 2 Durchlaeufe, Parallelitaet 1.
const options = {
memoryCost: 19456,
timeCost: 2,
parallelism: 1,
} as const;
export function hashPassword(plain: string): Promise<string> {
return hash(plain, options);
}
export async function verifyPassword(digest: string, plain: string): Promise<boolean> {
try {
return await verify(digest, plain, options);
} catch {
// Kaputter oder fremdformatiger Hash gilt als nicht passend.
return false;
}
}
const MIN_LENGTH = 12;
export function validatePasswordStrength(plain: string): string | null {
if (plain.length < MIN_LENGTH) {
return `Passwort muss mindestens ${MIN_LENGTH} Zeichen lang sein`;
}
if (!/[a-z]/.test(plain) || !/[A-Z]/.test(plain)) {
return 'Passwort muss Gross- und Kleinbuchstaben enthalten';
}
if (!/[0-9]/.test(plain)) {
return 'Passwort muss mindestens eine Ziffer enthalten';
}
return null;
}
+115
View File
@@ -0,0 +1,115 @@
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import type { Role, User } from '@prisma/client';
import { prisma } from '../db.js';
import { env } from '../env.js';
export const SESSION_COOKIE = 'ccm_sid';
export const CSRF_COOKIE = 'ccm_csrf';
export const CSRF_HEADER = 'x-csrf-token';
export interface SessionUser {
id: number;
username: string;
role: Role;
mustChangePassword: boolean;
totpEnabled: boolean;
}
function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
// Das CSRF-Token wird aus dem Session-Token abgeleitet statt separat gespeichert.
// Ohne Kenntnis des httpOnly-Cookies und des SESSION_SECRET ist es nicht herstellbar.
export function csrfTokenFor(sessionToken: string): string {
return createHmac('sha256', env.SESSION_SECRET).update(sessionToken).digest('hex');
}
export function csrfMatches(sessionToken: string, presented: string | undefined): boolean {
if (!presented) return false;
const expected = csrfTokenFor(sessionToken);
const a = Buffer.from(expected);
const b = Buffer.from(presented);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
export function toSessionUser(user: User): SessionUser {
return {
id: user.id,
username: user.username,
role: user.role,
mustChangePassword: user.mustChangePassword,
totpEnabled: user.totpEnabled,
};
}
export async function createSession(
userId: number,
ip: string | undefined,
userAgent: string | undefined,
): Promise<{ token: string; expiresAt: Date }> {
const token = randomBytes(32).toString('base64url');
const expiresAt = new Date(Date.now() + env.SESSION_TTL_HOURS * 3600 * 1000);
await prisma.session.create({
data: {
tokenHash: hashToken(token),
userId,
expiresAt,
ip: ip ?? null,
userAgent: userAgent?.slice(0, 255) ?? null,
},
});
return { token, expiresAt };
}
export async function resolveSession(token: string): Promise<SessionUser | null> {
const session = await prisma.session.findUnique({
where: { tokenHash: hashToken(token) },
include: { user: true },
});
if (!session) return null;
if (session.expiresAt.getTime() < Date.now()) {
await prisma.session.delete({ where: { id: session.id } }).catch(() => undefined);
return null;
}
if (!session.user.isActive) {
await prisma.session.deleteMany({ where: { userId: session.userId } });
return null;
}
// Nicht bei jedem Request schreiben, sonst erzeugt jede Anfrage einen Write.
const staleAfter = 5 * 60 * 1000;
if (Date.now() - session.lastSeenAt.getTime() > staleAfter) {
await prisma.session
.update({ where: { id: session.id }, data: { lastSeenAt: new Date() } })
.catch(() => undefined);
}
return toSessionUser(session.user);
}
export async function destroySession(token: string): Promise<void> {
await prisma.session.deleteMany({ where: { tokenHash: hashToken(token) } });
}
export async function destroyAllSessionsFor(userId: number): Promise<void> {
await prisma.session.deleteMany({ where: { userId } });
}
/** Beendet alle Sitzungen des Nutzers ausser der gerade verwendeten. */
export async function destroyOtherSessionsFor(userId: number, keepToken: string): Promise<void> {
await prisma.session.deleteMany({
where: { userId, NOT: { tokenHash: hashToken(keepToken) } },
});
}
export async function cleanupExpiredSessions(): Promise<number> {
const result = await prisma.session.deleteMany({ where: { expiresAt: { lt: new Date() } } });
return result.count;
}
+33
View File
@@ -0,0 +1,33 @@
import { generateSecret as createSecret, generateURI, verify } from 'otplib';
import QRCode from 'qrcode';
const ISSUER = 'CC-Manager';
// Toleranz in Sekunden. Ein Zeitschritt in beide Richtungen faengt leicht
// abweichende Uhren zwischen Server und Authenticator-App ab.
const EPOCH_TOLERANCE_SECONDS = 30;
export function generateSecret(): string {
return createSecret();
}
export async function verifyTotp(secret: string, token: string): Promise<boolean> {
try {
const result = await verify({
secret,
token: token.replace(/\s/g, ''),
epochTolerance: EPOCH_TOLERANCE_SECONDS,
});
return result.valid;
} catch {
return false;
}
}
export function otpauthUrl(username: string, secret: string): string {
return generateURI({ issuer: ISSUER, label: username, secret });
}
export function qrDataUrl(otpauth: string): Promise<string> {
return QRCode.toDataURL(otpauth, { margin: 1, width: 240 });
}
+63
View File
@@ -0,0 +1,63 @@
import { randomBytes } from 'node:crypto';
import { ProductTarget } from '@prisma/client';
import { prisma } from './db.js';
import { env } from './env.js';
import { hashPassword } from './auth/password.js';
// Startpunkte fuer die Produkterkennung beim Import. Jederzeit im Admin-Bereich
// anpassbar, sobald die echten Produktnamen bekannt sind.
const DEFAULT_MAPPINGS = [
{ matchPattern: 'cc-paket', target: ProductTarget.CC_PACKAGE, priority: 10 },
{ matchPattern: 'cc paket', target: ProductTarget.CC_PACKAGE, priority: 11 },
{ matchPattern: 'custom character', target: ProductTarget.CC_PACKAGE, priority: 12 },
{ matchPattern: 'redesign', target: ProductTarget.REDESIGN_PACKAGE, priority: 20 },
];
export interface BootstrapResult {
createdAdmin: { username: string; password: string } | null;
createdMappings: number;
}
/**
* Legt beim allerersten Start einen Administrator an. Ohne gesetztes
* Bootstrap-Passwort wird eines erzeugt und einmalig ins Log geschrieben.
*/
export async function bootstrap(log: {
info: (msg: string) => void;
warn: (msg: string) => void;
}): Promise<BootstrapResult> {
const result: BootstrapResult = { createdAdmin: null, createdMappings: 0 };
const userCount = await prisma.user.count();
if (userCount === 0) {
const password = env.BOOTSTRAP_ADMIN_PASSWORD ?? randomBytes(12).toString('base64url');
await prisma.user.create({
data: {
username: env.BOOTSTRAP_ADMIN_USER,
passwordHash: await hashPassword(password),
role: 'ADMIN',
mustChangePassword: true,
},
});
result.createdAdmin = { username: env.BOOTSTRAP_ADMIN_USER, password };
log.warn('─'.repeat(60));
log.warn(`Administrator angelegt: ${env.BOOTSTRAP_ADMIN_USER}`);
if (!env.BOOTSTRAP_ADMIN_PASSWORD) {
log.warn(`Einmal-Passwort: ${password}`);
}
log.warn('Das Passwort muss bei der ersten Anmeldung geaendert werden.');
log.warn('─'.repeat(60));
}
const mappingCount = await prisma.productMapping.count();
if (mappingCount === 0) {
const created = await prisma.productMapping.createMany({ data: DEFAULT_MAPPINGS });
result.createdMappings = created.count;
log.info(`${created.count} Produkt-Mappings als Startwerte angelegt`);
}
return result;
}
+12
View File
@@ -0,0 +1,12 @@
import { PrismaClient } from '@prisma/client';
import { env } from './env.js';
export const prisma = new PrismaClient({
log: env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'],
});
export type Tx = Parameters<Parameters<PrismaClient['$transaction']>[0]>[0];
export async function disconnect(): Promise<void> {
await prisma.$disconnect();
}
+50
View File
@@ -0,0 +1,50 @@
import { z } from 'zod';
const schema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().positive().default(3000),
HOST: z.string().default('0.0.0.0'),
DATABASE_URL: z.string().min(1, 'DATABASE_URL fehlt'),
SESSION_SECRET: z.string().min(32, 'SESSION_SECRET muss mindestens 32 Zeichen lang sein'),
// Lebensdauer einer Session in Stunden.
SESSION_TTL_HOURS: z.coerce.number().int().positive().default(12),
// Wird beim ersten Start angelegt, solange noch kein Account existiert.
BOOTSTRAP_ADMIN_USER: z.string().min(3).default('admin'),
BOOTSTRAP_ADMIN_PASSWORD: z.string().min(12).optional(),
// Hinter einem Reverse Proxy noetig, damit Client-IPs im Audit-Log stimmen.
TRUST_PROXY: z
.string()
.default('true')
.transform((v) => v === 'true' || v === '1'),
// Muss in Produktion true sein (Cookies nur ueber HTTPS).
COOKIE_SECURE: z
.string()
.optional()
.transform((v) => (v === undefined ? undefined : v === 'true' || v === '1')),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
});
const parsed = schema.safeParse(process.env);
if (!parsed.success) {
const issues = parsed.error.issues.map((i) => ` - ${i.path.join('.')}: ${i.message}`).join('\n');
console.error(`Konfiguration unvollstaendig:\n${issues}`);
process.exit(1);
}
const raw = parsed.data;
export const env = {
...raw,
isProduction: raw.NODE_ENV === 'production',
// In Produktion ohne expliziten Wert: sichere Cookies erzwingen.
cookieSecure: raw.COOKIE_SECURE ?? raw.NODE_ENV === 'production',
};
export type Env = typeof env;
+18
View File
@@ -0,0 +1,18 @@
export class AppError extends Error {
constructor(
readonly statusCode: number,
readonly code: string,
message: string,
) {
super(message);
this.name = 'AppError';
}
}
export const badRequest = (code: string, message: string) => new AppError(400, code, message);
export const unauthorized = (message = 'Nicht angemeldet') =>
new AppError(401, 'UNAUTHORIZED', message);
export const forbidden = (message = 'Keine Berechtigung fuer diese Aktion') =>
new AppError(403, 'FORBIDDEN', message);
export const notFound = (what: string) => new AppError(404, 'NOT_FOUND', `${what} nicht gefunden`);
export const conflict = (code: string, message: string) => new AppError(409, code, message);
+50
View File
@@ -0,0 +1,50 @@
import { buildApp } from './app.js';
import { bootstrap } from './bootstrap.js';
import { cleanupExpiredSessions } from './auth/session.js';
import { prisma } from './db.js';
import { env } from './env.js';
const SESSION_CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
async function main(): Promise<void> {
const app = await buildApp();
try {
await prisma.$queryRaw`SELECT 1`;
} catch (error) {
app.log.error({ err: error }, 'Keine Verbindung zur Datenbank');
process.exit(1);
}
await bootstrap({
info: (msg) => app.log.info(msg),
warn: (msg) => app.log.warn(msg),
});
const cleanup = setInterval(() => {
cleanupExpiredSessions()
.then((count) => {
if (count > 0) app.log.debug(`${count} abgelaufene Sitzungen entfernt`);
})
.catch((error) => app.log.warn({ err: error }, 'Aufraeumen der Sitzungen fehlgeschlagen'));
}, SESSION_CLEANUP_INTERVAL_MS);
cleanup.unref();
const shutdown = async (signal: string): Promise<void> => {
app.log.info(`${signal} empfangen, fahre herunter`);
clearInterval(cleanup);
await app.close();
await prisma.$disconnect();
process.exit(0);
};
process.on('SIGTERM', () => void shutdown('SIGTERM'));
process.on('SIGINT', () => void shutdown('SIGINT'));
await app.listen({ port: env.PORT, host: env.HOST });
}
main().catch((error) => {
console.error('Start fehlgeschlagen:', error);
process.exit(1);
});
+310
View File
@@ -0,0 +1,310 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { ProductTarget, Role, type Prisma } from '@prisma/client';
import { prisma } from '../db.js';
import { requireAdmin } from '../auth/guard.js';
import { badRequest, conflict, notFound } from '../errors.js';
import { hashPassword, validatePasswordStrength } from '../auth/password.js';
import { destroyAllSessionsFor } from '../auth/session.js';
import { recordAudit } from '../services/audit.js';
import { contextOf, idParam, parseWith } from './helpers.js';
const userSelect = {
id: true,
username: true,
role: true,
isActive: true,
totpEnabled: true,
mustChangePassword: true,
lastLoginAt: true,
createdAt: true,
} satisfies Prisma.UserSelect;
// Verhindert, dass sich die Installation selbst den letzten Zugang entzieht.
async function assertNotLastAdmin(userId: number): Promise<void> {
const target = await prisma.user.findUnique({ where: { id: userId } });
if (!target || target.role !== Role.ADMIN || !target.isActive) return;
const activeAdmins = await prisma.user.count({ where: { role: Role.ADMIN, isActive: true } });
if (activeAdmins <= 1) {
throw conflict('LAST_ADMIN', 'Der letzte aktive Administrator kann nicht entfernt werden');
}
}
export async function adminRoutes(app: FastifyInstance): Promise<void> {
app.addHook('preHandler', requireAdmin);
// --- Nutzerverwaltung ---
app.get('/api/admin/users', async () => {
const items = await prisma.user.findMany({
orderBy: [{ isActive: 'desc' }, { username: 'asc' }],
select: userSelect,
});
return { items };
});
app.post('/api/admin/users', async (request, reply) => {
const body = parseWith(
z.object({
username: z
.string()
.trim()
.min(3)
.max(64)
.regex(/^[a-zA-Z0-9._-]+$/, 'Erlaubt sind Buchstaben, Ziffern, Punkt, Strich, Unterstrich'),
password: z.string().min(1),
role: z.nativeEnum(Role).default(Role.EDITOR),
}),
request.body,
);
const weak = validatePasswordStrength(body.password);
if (weak) throw badRequest('WEAK_PASSWORD', weak);
const exists = await prisma.user.findUnique({
where: { username: body.username },
select: { id: true },
});
if (exists) throw conflict('USERNAME_TAKEN', 'Dieser Benutzername ist vergeben');
const created = await prisma.user.create({
data: {
username: body.username,
passwordHash: await hashPassword(body.password),
role: body.role,
mustChangePassword: true,
},
select: userSelect,
});
await recordAudit(prisma, contextOf(request), {
action: 'admin.user_create',
targetType: 'user',
targetId: created.id,
details: { username: created.username, role: created.role },
});
reply.code(201);
return created;
});
app.patch('/api/admin/users/:id', async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({
role: z.nativeEnum(Role).optional(),
isActive: z.boolean().optional(),
}),
request.body,
);
const target = await prisma.user.findUnique({ where: { id } });
if (!target) throw notFound('Nutzer');
const losesAdmin =
(body.role !== undefined && body.role !== Role.ADMIN) || body.isActive === false;
if (losesAdmin) await assertNotLastAdmin(id);
const updated = await prisma.user.update({
where: { id },
data: {
...(body.role !== undefined ? { role: body.role } : {}),
...(body.isActive !== undefined ? { isActive: body.isActive } : {}),
},
select: userSelect,
});
// Ein gesperrter Account verliert seine offenen Sitzungen sofort.
if (body.isActive === false) await destroyAllSessionsFor(id);
await recordAudit(prisma, contextOf(request), {
action: 'admin.user_update',
targetType: 'user',
targetId: id,
details: { username: updated.username, role: updated.role, isActive: updated.isActive },
});
return updated;
});
app.post('/api/admin/users/:id/reset-password', async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(z.object({ newPassword: z.string().min(1) }), request.body);
const weak = validatePasswordStrength(body.newPassword);
if (weak) throw badRequest('WEAK_PASSWORD', weak);
const target = await prisma.user.findUnique({ where: { id } });
if (!target) throw notFound('Nutzer');
await prisma.user.update({
where: { id },
data: {
passwordHash: await hashPassword(body.newPassword),
mustChangePassword: true,
},
});
await destroyAllSessionsFor(id);
await recordAudit(prisma, contextOf(request), {
action: 'admin.user_reset_password',
targetType: 'user',
targetId: id,
details: { username: target.username },
});
return { ok: true };
});
app.delete('/api/admin/users/:id', async (request) => {
const { id } = parseWith(idParam, request.params);
if (id === request.user?.id) {
throw conflict('SELF_DELETE', 'Der eigene Account kann nicht geloescht werden');
}
const target = await prisma.user.findUnique({ where: { id } });
if (!target) throw notFound('Nutzer');
await assertNotLastAdmin(id);
await prisma.user.delete({ where: { id } });
await recordAudit(prisma, contextOf(request), {
action: 'admin.user_delete',
targetType: 'user',
targetId: id,
details: { username: target.username },
});
return { ok: true };
});
// --- Produkt-Mappings ---
app.get('/api/admin/product-mappings', async () => {
const items = await prisma.productMapping.findMany({
orderBy: [{ priority: 'asc' }, { id: 'asc' }],
});
return { items };
});
app.post('/api/admin/product-mappings', async (request, reply) => {
const body = parseWith(
z.object({
matchPattern: z.string().trim().min(1).max(200),
target: z.nativeEnum(ProductTarget),
priority: z.number().int().min(0).max(9999).default(100),
note: z.string().trim().max(300).optional(),
}),
request.body,
);
const created = await prisma.productMapping.create({
data: {
matchPattern: body.matchPattern,
target: body.target,
priority: body.priority,
note: body.note ?? null,
},
});
await recordAudit(prisma, contextOf(request), {
action: 'admin.mapping_create',
targetType: 'product_mapping',
targetId: created.id,
details: { matchPattern: created.matchPattern, target: created.target },
});
reply.code(201);
return created;
});
app.patch('/api/admin/product-mappings/:id', async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({
matchPattern: z.string().trim().min(1).max(200).optional(),
target: z.nativeEnum(ProductTarget).optional(),
priority: z.number().int().min(0).max(9999).optional(),
isActive: z.boolean().optional(),
note: z.string().trim().max(300).nullable().optional(),
}),
request.body,
);
const existing = await prisma.productMapping.findUnique({ where: { id } });
if (!existing) throw notFound('Mapping');
const updated = await prisma.productMapping.update({ where: { id }, data: body });
await recordAudit(prisma, contextOf(request), {
action: 'admin.mapping_update',
targetType: 'product_mapping',
targetId: id,
details: { matchPattern: updated.matchPattern, target: updated.target },
});
return updated;
});
app.delete('/api/admin/product-mappings/:id', async (request) => {
const { id } = parseWith(idParam, request.params);
const existing = await prisma.productMapping.findUnique({ where: { id } });
if (!existing) throw notFound('Mapping');
await prisma.productMapping.delete({ where: { id } });
await recordAudit(prisma, contextOf(request), {
action: 'admin.mapping_delete',
targetType: 'product_mapping',
targetId: id,
details: { matchPattern: existing.matchPattern },
});
return { ok: true };
});
// --- Audit-Log ---
app.get('/api/admin/audit', async (request) => {
const query = parseWith(
z.object({
action: z.string().trim().max(80).optional(),
targetType: z.string().trim().max(40).optional(),
targetId: z.string().trim().max(40).optional(),
take: z.coerce.number().int().min(1).max(200).default(100),
skip: z.coerce.number().int().min(0).default(0),
}),
request.query,
);
const where: Prisma.AuditLogWhereInput = {};
if (query.action) where.action = { contains: query.action, mode: 'insensitive' };
if (query.targetType) where.targetType = query.targetType;
if (query.targetId) where.targetId = query.targetId;
const [items, total] = await Promise.all([
prisma.auditLog.findMany({
where,
orderBy: { createdAt: 'desc' },
take: query.take,
skip: query.skip,
include: { actor: { select: { username: true } } },
}),
prisma.auditLog.count({ where }),
]);
return { items, total };
});
app.get('/api/admin/stats', async () => {
const [players, characters, activeCharacters, users, imports] = await Promise.all([
prisma.player.count(),
prisma.character.count(),
prisma.character.count({ where: { status: 'ACTIVE' } }),
prisma.user.count({ where: { isActive: true } }),
prisma.import.count({ where: { status: 'COMMITTED' } }),
]);
return { players, characters, activeCharacters, users, imports };
});
}
+304
View File
@@ -0,0 +1,304 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
import type { FastifyInstance, FastifyReply } from 'fastify';
import { z } from 'zod';
import { prisma } from '../db.js';
import { env } from '../env.js';
import { badRequest, unauthorized } from '../errors.js';
import { hashPassword, validatePasswordStrength, verifyPassword } from '../auth/password.js';
import {
CSRF_COOKIE,
SESSION_COOKIE,
createSession,
csrfTokenFor,
destroyAllSessionsFor,
destroyOtherSessionsFor,
destroySession,
} from '../auth/session.js';
import { requireAuth } from '../auth/guard.js';
import { generateSecret, otpauthUrl, qrDataUrl, verifyTotp } from '../auth/totp.js';
import { recordAudit } from '../services/audit.js';
import { contextOf, parseWith } from './helpers.js';
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
// Zwischenschritt zwischen Passwort und TOTP-Code. Signiert statt gespeichert,
// damit kein halb angemeldeter Zustand in der Datenbank liegt.
function signChallenge(userId: number): string {
const expiresAt = Date.now() + CHALLENGE_TTL_MS;
const payload = `${userId}.${expiresAt}`;
const signature = createHmac('sha256', env.SESSION_SECRET).update(payload).digest('base64url');
return `${payload}.${signature}`;
}
function verifyChallenge(token: string): number | null {
const parts = token.split('.');
if (parts.length !== 3) return null;
const [rawUserId, rawExpiry, signature] = parts as [string, string, string];
const expected = createHmac('sha256', env.SESSION_SECRET)
.update(`${rawUserId}.${rawExpiry}`)
.digest('base64url');
const a = Buffer.from(expected);
const b = Buffer.from(signature);
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
const expiresAt = Number(rawExpiry);
if (!Number.isFinite(expiresAt) || expiresAt < Date.now()) return null;
const userId = Number(rawUserId);
return Number.isInteger(userId) ? userId : null;
}
const loginSchema = z.object({
username: z.string().trim().min(1).max(64),
password: z.string().min(1).max(256),
});
const totpSchema = z.object({
challenge: z.string().min(1),
code: z.string().trim().min(6).max(10),
});
export async function authRoutes(app: FastifyInstance): Promise<void> {
const loginLimit = {
config: {
rateLimit: {
max: 10,
timeWindow: '5 minutes',
},
},
};
function issueSession(reply: FastifyReply, token: string, expiresAt: Date): void {
reply.setCookie(SESSION_COOKIE, token, {
path: '/',
httpOnly: true,
secure: env.cookieSecure,
sameSite: 'lax',
expires: expiresAt,
});
// Muss fuer das Double-Submit-Verfahren lesbar sein.
reply.setCookie(CSRF_COOKIE, csrfTokenFor(token), {
path: '/',
httpOnly: false,
secure: env.cookieSecure,
sameSite: 'lax',
expires: expiresAt,
});
}
app.post('/api/auth/login', loginLimit, async (request, reply) => {
const body = parseWith(loginSchema, request.body);
const user = await prisma.user.findUnique({ where: { username: body.username } });
// Auch ohne Treffer einmal hashen, damit die Antwortzeit nichts verraet.
const digest = user?.passwordHash ?? '$argon2id$v=19$m=19456,t=2,p=1$aaaaaaaaaaaaaaaa$aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
const passwordOk = await verifyPassword(digest, body.password);
if (!user || !passwordOk || !user.isActive) {
await recordAudit(prisma, { actorId: null, actorName: body.username, ip: request.ip }, {
action: 'auth.login_failed',
targetType: 'user',
targetId: user?.id ?? null,
}).catch(() => undefined);
throw unauthorized('Benutzername oder Passwort ist falsch');
}
if (user.totpEnabled && user.totpSecret) {
return { totpRequired: true, challenge: signChallenge(user.id) };
}
const { token, expiresAt } = await createSession(
user.id,
request.ip,
request.headers['user-agent'],
);
issueSession(reply, token, expiresAt);
await prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } });
await recordAudit(
{ auditLog: prisma.auditLog },
{ actorId: user.id, actorName: user.username, ip: request.ip },
{ action: 'auth.login', targetType: 'user', targetId: user.id },
);
return {
totpRequired: false,
user: {
id: user.id,
username: user.username,
role: user.role,
mustChangePassword: user.mustChangePassword,
totpEnabled: user.totpEnabled,
},
};
});
app.post('/api/auth/login/totp', loginLimit, async (request, reply) => {
const body = parseWith(totpSchema, request.body);
const userId = verifyChallenge(body.challenge);
if (userId === null) {
throw unauthorized('Die Anmeldung ist abgelaufen, bitte erneut starten');
}
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user || !user.isActive || !user.totpSecret) throw unauthorized();
if (!(await verifyTotp(user.totpSecret, body.code))) {
await recordAudit(prisma, { actorId: user.id, actorName: user.username, ip: request.ip }, {
action: 'auth.totp_failed',
targetType: 'user',
targetId: user.id,
}).catch(() => undefined);
throw unauthorized('Der Code stimmt nicht');
}
const { token, expiresAt } = await createSession(
user.id,
request.ip,
request.headers['user-agent'],
);
issueSession(reply, token, expiresAt);
await prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } });
await recordAudit(
{ auditLog: prisma.auditLog },
{ actorId: user.id, actorName: user.username, ip: request.ip },
{ action: 'auth.login', targetType: 'user', targetId: user.id },
);
return {
user: {
id: user.id,
username: user.username,
role: user.role,
mustChangePassword: user.mustChangePassword,
totpEnabled: user.totpEnabled,
},
};
});
app.post('/api/auth/logout', async (request, reply) => {
if (request.sessionToken) {
await destroySession(request.sessionToken);
}
reply.clearCookie(SESSION_COOKIE, { path: '/' });
reply.clearCookie(CSRF_COOKIE, { path: '/' });
return { ok: true };
});
app.get('/api/auth/me', async (request) => {
if (!request.user) return { user: null };
return { user: request.user };
});
// --- Eigener Account ---
app.post(
'/api/account/password',
{ preHandler: requireAuth },
async (request) => {
const body = parseWith(
z.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(1),
}),
request.body,
);
const user = await prisma.user.findUnique({ where: { id: request.user!.id } });
if (!user) throw unauthorized();
if (!(await verifyPassword(user.passwordHash, body.currentPassword))) {
throw badRequest('WRONG_PASSWORD', 'Das aktuelle Passwort ist falsch');
}
const weak = validatePasswordStrength(body.newPassword);
if (weak) throw badRequest('WEAK_PASSWORD', weak);
await prisma.user.update({
where: { id: user.id },
data: {
passwordHash: await hashPassword(body.newPassword),
mustChangePassword: false,
},
});
// Andere Sitzungen beenden, die eigene bleibt bestehen.
if (request.sessionToken) {
await destroyOtherSessionsFor(user.id, request.sessionToken);
}
await recordAudit(prisma, contextOf(request), {
action: 'account.password_changed',
targetType: 'user',
targetId: user.id,
});
return { ok: true };
},
);
app.post('/api/account/totp/setup', { preHandler: requireAuth }, async (request) => {
const secret = generateSecret();
// Erst nach bestaetigtem Code aktivieren, sonst sperrt sich der Nutzer aus.
await prisma.user.update({
where: { id: request.user!.id },
data: { totpSecret: secret, totpEnabled: false },
});
const url = otpauthUrl(request.user!.username, secret);
return { secret, otpauth: url, qr: await qrDataUrl(url) };
});
app.post('/api/account/totp/enable', { preHandler: requireAuth }, async (request) => {
const body = parseWith(z.object({ code: z.string().trim().min(6).max(10) }), request.body);
const user = await prisma.user.findUnique({ where: { id: request.user!.id } });
if (!user?.totpSecret) {
throw badRequest('NO_SECRET', 'Bitte zuerst die Einrichtung starten');
}
if (!(await verifyTotp(user.totpSecret, body.code))) {
throw badRequest('WRONG_CODE', 'Der Code stimmt nicht');
}
await prisma.user.update({ where: { id: user.id }, data: { totpEnabled: true } });
await recordAudit(prisma, contextOf(request), {
action: 'account.totp_enabled',
targetType: 'user',
targetId: user.id,
});
return { ok: true };
});
app.post('/api/account/totp/disable', { preHandler: requireAuth }, async (request) => {
const body = parseWith(z.object({ password: z.string().min(1) }), request.body);
const user = await prisma.user.findUnique({ where: { id: request.user!.id } });
if (!user) throw unauthorized();
if (!(await verifyPassword(user.passwordHash, body.password))) {
throw badRequest('WRONG_PASSWORD', 'Das Passwort ist falsch');
}
await prisma.user.update({
where: { id: user.id },
data: { totpEnabled: false, totpSecret: null },
});
await recordAudit(prisma, contextOf(request), {
action: 'account.totp_disabled',
targetType: 'user',
targetId: user.id,
});
return { ok: true };
});
app.post('/api/account/sessions/revoke', { preHandler: requireAuth }, async (request, reply) => {
await destroyAllSessionsFor(request.user!.id);
reply.clearCookie(SESSION_COOKIE, { path: '/' });
reply.clearCookie(CSRF_COOKIE, { path: '/' });
return { ok: true };
});
}
+95
View File
@@ -0,0 +1,95 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { CreditKind, InactiveReason } from '@prisma/client';
import { requireEditor } from '../auth/guard.js';
import {
createCharacter,
deactivateCharacter,
reactivateFree,
reactivatePaid,
redesignCharacter,
revertCharacter,
updateCharacter,
} from '../services/characters.js';
import { contextOf, idParam, optionalNote, parseWith } from './helpers.js';
const redesignKind = z.enum([CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]);
const reviveKind = z.enum([CreditKind.CC, CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]);
export async function characterRoutes(app: FastifyInstance): Promise<void> {
app.post('/api/players/:id/characters', { preHandler: requireEditor }, async (request, reply) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({
name: z.string().trim().min(1).max(160),
loreText: z.string().trim().max(5000).optional(),
note: optionalNote,
}),
request.body,
);
const result = await createCharacter(id, body, contextOf(request));
reply.code(201);
return result;
});
app.patch('/api/characters/:id', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({
name: z.string().trim().min(1).max(160).optional(),
loreText: z.string().trim().max(5000).nullable().optional(),
}),
request.body,
);
return updateCharacter(id, body, contextOf(request));
});
app.post('/api/characters/:id/redesign', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({
name: z.string().trim().min(1).max(160),
loreText: z.string().trim().max(5000).optional(),
useKind: redesignKind,
note: optionalNote,
}),
request.body,
);
return redesignCharacter(id, body, contextOf(request));
});
app.post('/api/characters/:id/deactivate', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({
reason: z.enum([InactiveReason.PLAYER_INACTIVITY, InactiveReason.OTHER]),
note: optionalNote,
}),
request.body,
);
return deactivateCharacter(id, body.reason, contextOf(request), body.note);
});
app.post('/api/characters/:id/reactivate-free', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
return reactivateFree(id, contextOf(request));
});
app.post('/api/characters/:id/reactivate-paid', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({ useKind: reviveKind, note: optionalNote }),
request.body,
);
return reactivatePaid(id, body.useKind, contextOf(request), body.note);
});
app.post('/api/characters/:id/revert', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({ reason: z.string().trim().min(1, 'Eine Begruendung ist Pflicht').max(500) }),
request.body,
);
return revertCharacter(id, body.reason, contextOf(request));
});
}
+60
View File
@@ -0,0 +1,60 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { CreditKind } from '@prisma/client';
import { requireAdmin, requireEditor } from '../auth/guard.js';
import {
bookAdjustment,
bookCcPackage,
bookKulanz,
bookRedesignPackage,
reverseGroup,
} from '../services/credits.js';
import { contextOf, idParam, optionalNote, parseWith } from './helpers.js';
const noteBody = z.object({ note: optionalNote });
export async function creditRoutes(app: FastifyInstance): Promise<void> {
app.post('/api/players/:id/credits/cc-package', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(noteBody, request.body ?? {});
return bookCcPackage(id, contextOf(request), { note: body.note });
});
app.post(
'/api/players/:id/credits/redesign-package',
{ preHandler: requireEditor },
async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(noteBody, request.body ?? {});
return bookRedesignPackage(id, contextOf(request), { note: body.note });
},
);
app.post('/api/players/:id/credits/kulanz', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(noteBody, request.body ?? {});
return bookKulanz(id, contextOf(request), { note: body.note });
});
app.post('/api/players/:id/credits/adjustment', { preHandler: requireAdmin }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({
kind: z.nativeEnum(CreditKind),
amount: z.number().int().refine((v) => v !== 0, 'Betrag darf nicht 0 sein'),
note: z.string().trim().min(1, 'Eine Begruendung ist Pflicht').max(500),
}),
request.body,
);
return bookAdjustment(id, body.kind, body.amount, body.note, contextOf(request));
});
app.post('/api/credits/:id/reverse', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({ reason: z.string().trim().min(1, 'Eine Begruendung ist Pflicht').max(500) }),
request.body,
);
return reverseGroup(id, body.reason, contextOf(request));
});
}
+32
View File
@@ -0,0 +1,32 @@
import type { FastifyRequest } from 'fastify';
import { z } from 'zod';
import { badRequest, unauthorized } from '../errors.js';
import type { ActionContext } from '../services/audit.js';
// z.output statt z.input: nur so zaehlen .default()-Werte als gesetzt.
export function parseWith<S extends z.ZodTypeAny>(schema: S, input: unknown): z.output<S> {
const result = schema.safeParse(input);
if (!result.success) {
const message = result.error.issues
.map((issue) => {
const path = issue.path.join('.');
return path ? `${path}: ${issue.message}` : issue.message;
})
.join('; ');
throw badRequest('VALIDATION_FAILED', message);
}
return result.data;
}
export function contextOf(request: FastifyRequest): ActionContext {
if (!request.user) throw unauthorized();
return {
actorId: request.user.id,
actorName: request.user.username,
ip: request.ip,
};
}
export const idParam = z.object({ id: z.coerce.number().int().positive() });
export const optionalNote = z.string().trim().max(500).optional();
+71
View File
@@ -0,0 +1,71 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { requireAdmin } from '../auth/guard.js';
import { badRequest } from '../errors.js';
import {
applyMapping,
commitImport,
createImport,
discardImport,
getImport,
listImports,
parseUpload,
} from '../services/imports.js';
import { contextOf, idParam, parseWith } from './helpers.js';
const mappingBody = z.object({
playerKey: z.string().min(1, 'Spalte fuer die Spieler-Kennung fehlt'),
playerName: z.string().optional(),
product: z.string().min(1, 'Spalte fuer den Produktnamen fehlt'),
orderId: z.string().optional(),
purchasedAt: z.string().optional(),
createMissingPlayers: z.boolean().default(false),
});
export async function importRoutes(app: FastifyInstance): Promise<void> {
// Der Import veraendert Guthaben in grosser Zahl und bleibt deshalb Admins vorbehalten.
app.post('/api/imports', { preHandler: requireAdmin }, async (request, reply) => {
const file = await request.file();
if (!file) throw badRequest('NO_FILE', 'Es wurde keine Datei hochgeladen');
const content = await file.toBuffer();
const parsed = parseUpload(file.filename, content);
const importId = await createImport(file.filename, parsed, contextOf(request));
reply.code(201);
return {
importId,
columns: parsed.columns,
rowCount: parsed.rows.length,
sample: parsed.rows.slice(0, 5),
};
});
app.get('/api/imports', { preHandler: requireAdmin }, async () => {
return { items: await listImports() };
});
app.get('/api/imports/:id', { preHandler: requireAdmin }, async (request) => {
const { id } = parseWith(idParam, request.params);
return getImport(id);
});
app.post('/api/imports/:id/mapping', { preHandler: requireAdmin }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(mappingBody, request.body);
const summary = await applyMapping(id, body, contextOf(request));
const record = await getImport(id);
return { summary, import: record };
});
app.post('/api/imports/:id/commit', { preHandler: requireAdmin }, async (request) => {
const { id } = parseWith(idParam, request.params);
return commitImport(id, contextOf(request));
});
app.post('/api/imports/:id/discard', { preHandler: requireAdmin }, async (request) => {
const { id } = parseWith(idParam, request.params);
await discardImport(id, contextOf(request));
return { ok: true };
});
}
+71
View File
@@ -0,0 +1,71 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { CreditKind } from '@prisma/client';
import { requireAdmin, requireAuth, requireEditor } from '../auth/guard.js';
import { listLedger } from '../services/credits.js';
import {
createPlayer,
deletePlayer,
getPlayerDetail,
searchPlayers,
updatePlayer,
} from '../services/players.js';
import { contextOf, idParam, parseWith } from './helpers.js';
const playerBody = z.object({
displayName: z.string().trim().min(1).max(120),
steamId: z.string().trim().max(64).nullish(),
forumName: z.string().trim().max(120).nullish(),
notes: z.string().trim().max(2000).nullish(),
});
export async function playerRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/players', { preHandler: requireAuth }, async (request) => {
const query = parseWith(
z.object({
q: z.string().trim().max(120).default(''),
take: z.coerce.number().int().min(1).max(200).default(50),
skip: z.coerce.number().int().min(0).default(0),
}),
request.query,
);
return searchPlayers(query.q, query.take, query.skip);
});
app.get('/api/players/:id', { preHandler: requireAuth }, async (request) => {
const { id } = parseWith(idParam, request.params);
return getPlayerDetail(id);
});
app.get('/api/players/:id/ledger', { preHandler: requireAuth }, async (request) => {
const { id } = parseWith(idParam, request.params);
const query = parseWith(
z.object({
kind: z.nativeEnum(CreditKind).optional(),
take: z.coerce.number().int().min(1).max(500).default(100),
skip: z.coerce.number().int().min(0).default(0),
}),
request.query,
);
return listLedger({ playerId: id, ...query });
});
app.post('/api/players', { preHandler: requireEditor }, async (request, reply) => {
const body = parseWith(playerBody, request.body);
const created = await createPlayer(body, contextOf(request));
reply.code(201);
return created;
});
app.patch('/api/players/:id', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(playerBody.partial(), request.body);
return updatePlayer(id, body, contextOf(request));
});
app.delete('/api/players/:id', { preHandler: requireAdmin }, async (request) => {
const { id } = parseWith(idParam, request.params);
await deletePlayer(id, contextOf(request));
return { ok: true };
});
}
+36
View File
@@ -0,0 +1,36 @@
import type { Prisma } from '@prisma/client';
import { prisma } from '../db.js';
export interface ActionContext {
// null fuer Vorgaenge ohne angemeldeten Account, etwa ein fehlgeschlagener Login.
actorId: number | null;
actorName: string;
ip?: string | undefined;
}
export interface AuditEntry {
action: string;
targetType: string;
targetId?: string | number | null;
details?: Prisma.InputJsonValue;
}
type Client = Pick<typeof prisma, 'auditLog'>;
export async function recordAudit(
client: Client,
ctx: ActionContext,
entry: AuditEntry,
): Promise<void> {
await client.auditLog.create({
data: {
actorUserId: ctx.actorId,
actorName: ctx.actorName,
action: entry.action,
targetType: entry.targetType,
targetId: entry.targetId == null ? null : String(entry.targetId),
details: entry.details ?? undefined,
ip: ctx.ip ?? null,
},
});
}
+427
View File
@@ -0,0 +1,427 @@
import { randomUUID } from 'node:crypto';
import {
CharacterStatus,
CreditKind,
CreditSource,
InactiveReason,
type Character,
} from '@prisma/client';
import { prisma } from '../db.js';
import { badRequest, conflict, notFound } from '../errors.js';
import { getBalances, type Balances } from './credits.js';
import { recordAudit, type ActionContext } from './audit.js';
export interface CharacterResult {
character: Character;
balances: Balances;
// Zeigt an, dass ein Topf nach der Buchung im Minus steht. Die Aktion wird
// trotzdem ausgefuehrt, das Ergebnis gehoert aber sichtbar gemacht.
overdrawn: boolean;
}
// Fuer ein Redesign zaehlen nur die beiden Redesign-Toepfe.
const REDESIGN_KINDS = [CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ] as const;
// Beim Wiederbeleben eines reworkten CCs darf zusaetzlich CC-Guthaben gezogen werden.
const REVIVE_KINDS = [CreditKind.CC, ...REDESIGN_KINDS] as const;
export type RedesignKind = (typeof REDESIGN_KINDS)[number];
export type ReviveKind = (typeof REVIVE_KINDS)[number];
function isOverdrawn(balances: Balances): boolean {
return balances.CC < 0 || balances.REDESIGN_PAID < 0 || balances.REDESIGN_KULANZ < 0;
}
async function loadCharacter(id: number): Promise<Character> {
const character = await prisma.character.findUnique({ where: { id } });
if (!character) throw notFound('CC');
return character;
}
/** Neuer CC. Kostet ein CC-Guthaben, Kulanz ist dafuer nicht einsetzbar. */
export async function createCharacter(
playerId: number,
input: { name: string; loreText?: string | undefined; note?: string | undefined },
ctx: ActionContext,
): Promise<CharacterResult> {
const player = await prisma.player.findUnique({ where: { id: playerId }, select: { id: true } });
if (!player) throw notFound('Spieler');
const character = await prisma.$transaction(async (tx) => {
const created = await tx.character.create({
data: {
playerId,
name: input.name,
loreText: input.loreText ?? null,
status: CharacterStatus.ACTIVE,
lineageId: randomUUID(),
createdById: ctx.actorId,
},
});
await tx.creditLedger.create({
data: {
playerId,
kind: CreditKind.CC,
amount: -1,
source: CreditSource.CONSUME,
groupId: randomUUID(),
characterId: created.id,
note: input.note ?? `Neuer CC: ${created.name}`,
createdById: ctx.actorId,
},
});
await recordAudit(tx, ctx, {
action: 'character.create',
targetType: 'character',
targetId: created.id,
details: { playerId, name: created.name },
});
return created;
});
const balances = await getBalances(playerId);
return { character, balances, overdrawn: isOverdrawn(balances) };
}
/**
* Redesign als Lineage: der alte CC geht auf INACTIVE/REWORKED, der neue
* entsteht als eigener Datensatz in derselben Kette. Damit greift spaeter
* automatisch die Regel, dass ein reworkter CC nur bezahlt zurueckkommt.
*/
export async function redesignCharacter(
characterId: number,
input: {
name: string;
loreText?: string | undefined;
useKind: RedesignKind;
note?: string | undefined;
},
ctx: ActionContext,
): Promise<CharacterResult & { predecessor: Character }> {
const old = await loadCharacter(characterId);
if (old.status !== CharacterStatus.ACTIVE) {
throw conflict('NOT_ACTIVE', 'Nur ein aktiver CC kann redesignt werden');
}
if (!REDESIGN_KINDS.includes(input.useKind)) {
throw badRequest('INVALID_KIND', 'Ein Redesign kostet bezahltes oder Kulanz-Redesign-Guthaben');
}
const result = await prisma.$transaction(async (tx) => {
const predecessor = await tx.character.update({
where: { id: old.id },
data: { status: CharacterStatus.INACTIVE, inactiveReason: InactiveReason.REWORKED },
});
const created = await tx.character.create({
data: {
playerId: old.playerId,
name: input.name,
loreText: input.loreText ?? null,
status: CharacterStatus.ACTIVE,
lineageId: old.lineageId,
predecessorId: old.id,
createdById: ctx.actorId,
},
});
await tx.creditLedger.create({
data: {
playerId: old.playerId,
kind: input.useKind,
amount: -1,
source: CreditSource.CONSUME,
groupId: randomUUID(),
characterId: created.id,
note: input.note ?? `Redesign: ${old.name} -> ${created.name}`,
createdById: ctx.actorId,
},
});
await recordAudit(tx, ctx, {
action: 'character.redesign',
targetType: 'character',
targetId: created.id,
details: {
playerId: old.playerId,
predecessorId: old.id,
from: old.name,
to: created.name,
useKind: input.useKind,
},
});
return { created, predecessor };
});
const balances = await getBalances(old.playerId);
return {
character: result.created,
predecessor: result.predecessor,
balances,
overdrawn: isOverdrawn(balances),
};
}
/** Deaktivierung ohne Guthaben-Effekt, etwa beim Serverputz wegen Inaktivitaet. */
export async function deactivateCharacter(
characterId: number,
reason: InactiveReason,
ctx: ActionContext,
note?: string,
): Promise<CharacterResult> {
const existing = await loadCharacter(characterId);
if (existing.status === CharacterStatus.INACTIVE) {
throw conflict('ALREADY_INACTIVE', 'Dieser CC ist bereits inaktiv');
}
if (reason === InactiveReason.REWORKED) {
throw badRequest(
'USE_REDESIGN',
'Ein Rework entsteht ueber die Redesign-Aktion, nicht ueber eine Deaktivierung',
);
}
const character = await prisma.$transaction(async (tx) => {
const updated = await tx.character.update({
where: { id: characterId },
data: { status: CharacterStatus.INACTIVE, inactiveReason: reason },
});
await recordAudit(tx, ctx, {
action: 'character.deactivate',
targetType: 'character',
targetId: characterId,
details: { reason, note: note ?? null, name: updated.name },
});
return updated;
});
const balances = await getBalances(character.playerId);
return { character, balances, overdrawn: isOverdrawn(balances) };
}
/**
* Gratis-Reaktivierung. Greift nur, wenn der CC ausschliesslich wegen
* Spieler-Inaktivitaet vom Server geputzt wurde: er existiert unveraendert
* weiter und kostet deshalb kein Guthaben.
*/
export async function reactivateFree(
characterId: number,
ctx: ActionContext,
): Promise<CharacterResult> {
const existing = await loadCharacter(characterId);
if (existing.status === CharacterStatus.ACTIVE) {
throw conflict('ALREADY_ACTIVE', 'Dieser CC ist bereits aktiv');
}
if (existing.inactiveReason !== InactiveReason.PLAYER_INACTIVITY) {
throw conflict(
'NOT_FREE',
'Nur ein wegen Spieler-Inaktivitaet entfernter CC kommt gratis zurueck. Dieser CC braucht eine bezahlte Reaktivierung.',
);
}
const character = await prisma.$transaction(async (tx) => {
const updated = await tx.character.update({
where: { id: characterId },
data: { status: CharacterStatus.ACTIVE, inactiveReason: null },
});
await recordAudit(tx, ctx, {
action: 'character.reactivate_free',
targetType: 'character',
targetId: characterId,
details: { playerId: updated.playerId, name: updated.name },
});
return updated;
});
const balances = await getBalances(character.playerId);
return { character, balances, overdrawn: isOverdrawn(balances) };
}
/**
* Bezahlte Reaktivierung fuer CCs, die im Original nicht mehr existieren
* (reworkt oder aus sonstigem Grund inaktiv). Kostet ein Redesign- oder
* ein CC-Guthaben, der Nutzer entscheidet welches.
*/
export async function reactivatePaid(
characterId: number,
useKind: ReviveKind,
ctx: ActionContext,
note?: string,
): Promise<CharacterResult> {
const existing = await loadCharacter(characterId);
if (existing.status === CharacterStatus.ACTIVE) {
throw conflict('ALREADY_ACTIVE', 'Dieser CC ist bereits aktiv');
}
if (existing.inactiveReason === InactiveReason.PLAYER_INACTIVITY) {
throw conflict(
'IS_FREE',
'Dieser CC kommt gratis zurueck, eine Guthaben-Buchung waere falsch',
);
}
if (!REVIVE_KINDS.includes(useKind)) {
throw badRequest('INVALID_KIND', 'Ungueltige Guthaben-Art fuer eine Reaktivierung');
}
const character = await prisma.$transaction(async (tx) => {
const updated = await tx.character.update({
where: { id: characterId },
data: { status: CharacterStatus.ACTIVE, inactiveReason: null },
});
await tx.creditLedger.create({
data: {
playerId: updated.playerId,
kind: useKind,
amount: -1,
source: CreditSource.CONSUME,
groupId: randomUUID(),
characterId: updated.id,
note: note ?? `Reaktivierung: ${updated.name}`,
createdById: ctx.actorId,
},
});
await recordAudit(tx, ctx, {
action: 'character.reactivate_paid',
targetType: 'character',
targetId: characterId,
details: {
playerId: updated.playerId,
name: updated.name,
useKind,
previousReason: existing.inactiveReason,
},
});
return updated;
});
const balances = await getBalances(character.playerId);
return { character, balances, overdrawn: isOverdrawn(balances) };
}
export async function updateCharacter(
characterId: number,
input: { name?: string; loreText?: string | null },
ctx: ActionContext,
): Promise<Character> {
await loadCharacter(characterId);
return prisma.$transaction(async (tx) => {
const updated = await tx.character.update({
where: { id: characterId },
data: {
...(input.name !== undefined ? { name: input.name } : {}),
...(input.loreText !== undefined ? { loreText: input.loreText } : {}),
},
});
await recordAudit(tx, ctx, {
action: 'character.update',
targetType: 'character',
targetId: characterId,
details: { name: updated.name },
});
return updated;
});
}
/**
* Nimmt einen irrtuemlich angelegten CC komplett zurueck: Verbrauchsbuchungen
* werden gegengebucht und ein durch das Redesign deaktivierter Vorgaenger
* kehrt in den aktiven Zustand zurueck.
*/
export async function revertCharacter(
characterId: number,
reason: string,
ctx: ActionContext,
): Promise<{ balances: Balances; restoredPredecessorId: number | null }> {
const existing = await prisma.character.findUnique({
where: { id: characterId },
include: { successors: { select: { id: true } }, predecessor: true },
});
if (!existing) throw notFound('CC');
if (existing.successors.length > 0) {
throw conflict(
'HAS_SUCCESSORS',
'Dieser CC hat Nachfolger in der Redesign-Kette. Bitte zuerst den juengsten CC zuruecknehmen.',
);
}
const consumed = await prisma.creditLedger.findMany({
where: { characterId, source: CreditSource.CONSUME },
include: { reversedBy: { select: { id: true } } },
});
const open = consumed.filter((entry) => entry.reversedBy === null);
const predecessor = existing.predecessor;
const restorePredecessor =
predecessor !== null &&
predecessor.status === CharacterStatus.INACTIVE &&
predecessor.inactiveReason === InactiveReason.REWORKED;
await prisma.$transaction(async (tx) => {
for (const entry of open) {
await tx.creditLedger.create({
data: {
playerId: entry.playerId,
kind: entry.kind,
amount: -entry.amount,
source: CreditSource.REVERSAL,
groupId: entry.groupId,
note: `Ruecknahme "${existing.name}": ${reason}`,
reversalOfId: entry.id,
createdById: ctx.actorId,
},
});
}
if (restorePredecessor && predecessor) {
await tx.character.update({
where: { id: predecessor.id },
data: { status: CharacterStatus.ACTIVE, inactiveReason: null },
});
}
await tx.character.delete({ where: { id: characterId } });
await recordAudit(tx, ctx, {
action: 'character.revert',
targetType: 'character',
targetId: characterId,
details: {
playerId: existing.playerId,
name: existing.name,
reason,
reversedLedgerIds: open.map((e) => e.id),
restoredPredecessorId: restorePredecessor ? predecessor?.id ?? null : null,
},
});
});
return {
balances: await getBalances(existing.playerId),
restoredPredecessorId: restorePredecessor ? predecessor?.id ?? null : null,
};
}
export async function listCharacters(playerId: number) {
return prisma.character.findMany({
where: { playerId },
orderBy: [{ status: 'asc' }, { createdAt: 'desc' }],
include: {
predecessor: { select: { id: true, name: true } },
successors: { select: { id: true, name: true } },
},
});
}
+325
View File
@@ -0,0 +1,325 @@
import { randomUUID } from 'node:crypto';
import { CreditKind, CreditSource, type Prisma } from '@prisma/client';
import { prisma } from '../db.js';
import { badRequest, conflict, notFound } from '../errors.js';
import { recordAudit, type ActionContext } from './audit.js';
export interface Balances {
CC: number;
REDESIGN_PAID: number;
REDESIGN_KULANZ: number;
// Fuer ein Redesign zaehlen beide Redesign-Toepfe zusammen.
redesignTotal: number;
}
const EMPTY: Balances = { CC: 0, REDESIGN_PAID: 0, REDESIGN_KULANZ: 0, redesignTotal: 0 };
function assemble(sums: Partial<Record<CreditKind, number>>): Balances {
const cc = sums.CC ?? 0;
const paid = sums.REDESIGN_PAID ?? 0;
const kulanz = sums.REDESIGN_KULANZ ?? 0;
return { CC: cc, REDESIGN_PAID: paid, REDESIGN_KULANZ: kulanz, redesignTotal: paid + kulanz };
}
export async function getBalances(playerId: number): Promise<Balances> {
const rows = await prisma.creditLedger.groupBy({
by: ['kind'],
where: { playerId },
_sum: { amount: true },
});
const sums: Partial<Record<CreditKind, number>> = {};
for (const row of rows) sums[row.kind] = row._sum.amount ?? 0;
return assemble(sums);
}
export async function getBalancesForMany(playerIds: number[]): Promise<Map<number, Balances>> {
const result = new Map<number, Balances>();
if (playerIds.length === 0) return result;
const rows = await prisma.creditLedger.groupBy({
by: ['playerId', 'kind'],
where: { playerId: { in: playerIds } },
_sum: { amount: true },
});
const grouped = new Map<number, Partial<Record<CreditKind, number>>>();
for (const row of rows) {
const entry = grouped.get(row.playerId) ?? {};
entry[row.kind] = row._sum.amount ?? 0;
grouped.set(row.playerId, entry);
}
for (const id of playerIds) {
const sums = grouped.get(id);
result.set(id, sums ? assemble(sums) : { ...EMPTY });
}
return result;
}
async function requirePlayer(playerId: number): Promise<void> {
const exists = await prisma.player.findUnique({ where: { id: playerId }, select: { id: true } });
if (!exists) throw notFound('Spieler');
}
export interface PackageRowParams {
playerId: number;
groupId: string;
note?: string | null;
importId?: number | null;
actorId: number | null;
}
/**
* Baut die Ledger-Zeilen eines gekauften Pakets. Die Regel, dass ein CC-Paket
* immer auch ein bezahltes Redesign enthaelt, steht nur hier, damit manuelle
* Eingabe und Import nicht auseinanderlaufen koennen.
*/
export function buildPackageRows(
target: 'CC_PACKAGE' | 'REDESIGN_PACKAGE',
params: PackageRowParams,
): Prisma.CreditLedgerCreateManyInput[] {
const base = {
playerId: params.playerId,
groupId: params.groupId,
note: params.note ?? null,
importId: params.importId ?? null,
createdById: params.actorId,
};
if (target === 'REDESIGN_PACKAGE') {
return [
{ ...base, kind: CreditKind.REDESIGN_PAID, amount: 1, source: CreditSource.REDESIGN_PACKAGE },
];
}
return [
{ ...base, kind: CreditKind.CC, amount: 1, source: CreditSource.CC_PACKAGE },
{ ...base, kind: CreditKind.REDESIGN_PAID, amount: 1, source: CreditSource.CC_PACKAGE },
];
}
/**
* Ein CC-Paket erzeugt immer beide Guthaben: einen CC und ein bezahltes
* Redesign. Beide Zeilen teilen sich eine groupId, damit ein Storno sie
* gemeinsam zurueckholt.
*/
export async function bookCcPackage(
playerId: number,
ctx: ActionContext,
options: { note?: string | undefined; importId?: number | undefined; groupId?: string } = {},
): Promise<{ groupId: string; balances: Balances }> {
await requirePlayer(playerId);
const groupId = options.groupId ?? randomUUID();
await prisma.$transaction(async (tx) => {
await tx.creditLedger.createMany({
data: buildPackageRows('CC_PACKAGE', {
playerId,
groupId,
note: options.note ?? null,
importId: options.importId ?? null,
actorId: ctx.actorId,
}),
});
await recordAudit(tx, ctx, {
action: 'credit.cc_package',
targetType: 'player',
targetId: playerId,
details: { groupId, note: options.note ?? null, importId: options.importId ?? null },
});
});
return { groupId, balances: await getBalances(playerId) };
}
export async function bookRedesignPackage(
playerId: number,
ctx: ActionContext,
options: { note?: string | undefined; importId?: number | undefined; groupId?: string } = {},
): Promise<{ groupId: string; balances: Balances }> {
await requirePlayer(playerId);
const groupId = options.groupId ?? randomUUID();
await prisma.$transaction(async (tx) => {
await tx.creditLedger.createMany({
data: buildPackageRows('REDESIGN_PACKAGE', {
playerId,
groupId,
note: options.note ?? null,
importId: options.importId ?? null,
actorId: ctx.actorId,
}),
});
await recordAudit(tx, ctx, {
action: 'credit.redesign_package',
targetType: 'player',
targetId: playerId,
details: { groupId, note: options.note ?? null, importId: options.importId ?? null },
});
});
return { groupId, balances: await getBalances(playerId) };
}
export async function bookKulanz(
playerId: number,
ctx: ActionContext,
options: { note?: string | undefined } = {},
): Promise<{ groupId: string; balances: Balances }> {
await requirePlayer(playerId);
const groupId = randomUUID();
await prisma.$transaction(async (tx) => {
await tx.creditLedger.create({
data: {
playerId,
kind: CreditKind.REDESIGN_KULANZ,
amount: 1,
source: CreditSource.KULANZ,
groupId,
note: options.note ?? null,
createdById: ctx.actorId,
},
});
await recordAudit(tx, ctx, {
action: 'credit.kulanz',
targetType: 'player',
targetId: playerId,
details: { groupId, note: options.note ?? null },
});
});
return { groupId, balances: await getBalances(playerId) };
}
/** Freie Korrekturbuchung. Vorbehalten fuer Administratoren. */
export async function bookAdjustment(
playerId: number,
kind: CreditKind,
amount: number,
note: string,
ctx: ActionContext,
): Promise<{ groupId: string; balances: Balances }> {
await requirePlayer(playerId);
if (!Number.isInteger(amount) || amount === 0) {
throw badRequest('INVALID_AMOUNT', 'Korrekturbetrag muss eine ganze Zahl ungleich 0 sein');
}
const groupId = randomUUID();
await prisma.$transaction(async (tx) => {
await tx.creditLedger.create({
data: {
playerId,
kind,
amount,
source: CreditSource.ADJUSTMENT,
groupId,
note,
createdById: ctx.actorId,
},
});
await recordAudit(tx, ctx, {
action: 'credit.adjustment',
targetType: 'player',
targetId: playerId,
details: { groupId, kind, amount, note },
});
});
return { groupId, balances: await getBalances(playerId) };
}
/**
* Storniert eine Buchung als Gegenbuchung, nicht als Loeschung: die Historie
* bleibt vollstaendig lesbar. Verbrauchszeilen sind ausgenommen, die werden
* ueber die zugehoerige CC-Aktion zurueckgenommen.
*/
export async function reverseGroup(
ledgerId: number,
reason: string,
ctx: ActionContext,
): Promise<{ balances: Balances; reversed: number }> {
const anchor = await prisma.creditLedger.findUnique({ where: { id: ledgerId } });
if (!anchor) throw notFound('Buchung');
if (anchor.source === CreditSource.CONSUME) {
throw conflict(
'CONSUME_NOT_REVERSIBLE',
'Verbrauchsbuchungen werden ueber die zugehoerige CC-Aktion zurueckgenommen, nicht direkt storniert',
);
}
if (anchor.source === CreditSource.REVERSAL) {
throw conflict('ALREADY_REVERSAL', 'Eine Stornobuchung kann nicht storniert werden');
}
const entries = await prisma.creditLedger.findMany({
where: { groupId: anchor.groupId, source: { not: CreditSource.REVERSAL } },
include: { reversedBy: { select: { id: true } } },
});
const open = entries.filter((e) => e.reversedBy === null);
if (open.length === 0) {
throw conflict('ALREADY_REVERSED', 'Diese Buchung wurde bereits storniert');
}
await prisma.$transaction(async (tx) => {
for (const entry of open) {
await tx.creditLedger.create({
data: {
playerId: entry.playerId,
kind: entry.kind,
amount: -entry.amount,
source: CreditSource.REVERSAL,
groupId: entry.groupId,
note: reason,
reversalOfId: entry.id,
createdById: ctx.actorId,
},
});
}
await recordAudit(tx, ctx, {
action: 'credit.reverse',
targetType: 'player',
targetId: anchor.playerId,
details: { groupId: anchor.groupId, reversedIds: open.map((e) => e.id), reason },
});
});
return { balances: await getBalances(anchor.playerId), reversed: open.length };
}
export interface LedgerFilter {
playerId: number;
kind?: CreditKind;
take?: number;
skip?: number;
}
export async function listLedger(filter: LedgerFilter) {
const where: Prisma.CreditLedgerWhereInput = { playerId: filter.playerId };
if (filter.kind) where.kind = filter.kind;
const [items, total] = await Promise.all([
prisma.creditLedger.findMany({
where,
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: filter.take ?? 100,
skip: filter.skip ?? 0,
include: {
createdBy: { select: { username: true } },
character: { select: { id: true, name: true } },
reversedBy: { select: { id: true } },
},
}),
prisma.creditLedger.count({ where }),
]);
return { items, total };
}
+434
View File
@@ -0,0 +1,434 @@
import { randomUUID } from 'node:crypto';
import { parse } from 'csv-parse/sync';
import { ImportStatus, ProductTarget, type Prisma } from '@prisma/client';
import { prisma } from '../db.js';
import { badRequest, conflict, notFound } from '../errors.js';
import { buildPackageRows } from './credits.js';
import { recordAudit, type ActionContext } from './audit.js';
export const PLANS = {
BOOK: 'BOOK',
CREATE_PLAYER_AND_BOOK: 'CREATE_PLAYER_AND_BOOK',
SKIP_DUPLICATE: 'SKIP_DUPLICATE',
SKIP_IGNORED: 'SKIP_IGNORED',
SKIP_UNMAPPED: 'SKIP_UNMAPPED',
SKIP_NO_PLAYER: 'SKIP_NO_PLAYER',
} as const;
export type Plan = (typeof PLANS)[keyof typeof PLANS];
export interface ColumnMapping {
playerKey: string;
playerName?: string | undefined;
product: string;
orderId?: string | undefined;
purchasedAt?: string | undefined;
createMissingPlayers: boolean;
}
export interface ParsedFile {
columns: string[];
rows: Record<string, string>[];
}
const MAX_ROWS = 20000;
// Deutsche Tabellen-Exporte kommen haeufig mit Semikolon statt Komma.
function detectDelimiter(sample: string): string {
const firstLine = sample.split(/\r?\n/, 1)[0] ?? '';
const candidates = [',', ';', '\t', '|'];
let best = ',';
let bestCount = 0;
for (const candidate of candidates) {
const count = firstLine.split(candidate).length - 1;
if (count > bestCount) {
best = candidate;
bestCount = count;
}
}
return best;
}
function normalizeRow(row: Record<string, unknown>): Record<string, string> {
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(row)) {
if (value === null || value === undefined) {
out[key] = '';
} else if (typeof value === 'object') {
out[key] = JSON.stringify(value);
} else {
out[key] = String(value).trim();
}
}
return out;
}
export function parseUpload(filename: string, content: Buffer): ParsedFile {
const text = content.toString('utf8').replace(/^/, '');
if (text.trim().length === 0) throw badRequest('EMPTY_FILE', 'Die Datei ist leer');
const isJson = filename.toLowerCase().endsWith('.json') || /^\s*[[{]/.test(text);
let rows: Record<string, string>[];
if (isJson) {
let data: unknown;
try {
data = JSON.parse(text);
} catch {
throw badRequest('INVALID_JSON', 'Die JSON-Datei konnte nicht gelesen werden');
}
// Manche Exporte verpacken die Liste in ein Huellobjekt.
const list = Array.isArray(data)
? data
: typeof data === 'object' && data !== null
? Object.values(data).find((v): v is unknown[] => Array.isArray(v))
: undefined;
if (!Array.isArray(list)) {
throw badRequest('INVALID_JSON', 'Erwartet wird eine Liste von Eintraegen');
}
rows = list
.filter((entry): entry is Record<string, unknown> => typeof entry === 'object' && entry !== null)
.map(normalizeRow);
} else {
try {
const parsed = parse(text, {
columns: true,
skip_empty_lines: true,
trim: true,
relax_column_count: true,
delimiter: detectDelimiter(text),
bom: true,
}) as Record<string, unknown>[];
rows = parsed.map(normalizeRow);
} catch {
throw badRequest('INVALID_CSV', 'Die CSV-Datei konnte nicht gelesen werden');
}
}
if (rows.length === 0) throw badRequest('NO_ROWS', 'Die Datei enthaelt keine Datenzeilen');
if (rows.length > MAX_ROWS) {
throw badRequest('TOO_MANY_ROWS', `Die Datei hat mehr als ${MAX_ROWS} Zeilen`);
}
const columns = [...new Set(rows.flatMap((row) => Object.keys(row)))];
return { columns, rows };
}
export async function createImport(
filename: string,
parsed: ParsedFile,
ctx: ActionContext,
): Promise<number> {
const created = await prisma.import.create({
data: {
filename,
status: ImportStatus.PENDING,
rowCount: parsed.rows.length,
importedById: ctx.actorId,
rows: {
create: parsed.rows.map((raw, index) => ({
lineNo: index + 1,
plan: PLANS.SKIP_UNMAPPED,
raw: raw as Prisma.InputJsonValue,
})),
},
},
});
await recordAudit(prisma, ctx, {
action: 'import.upload',
targetType: 'import',
targetId: created.id,
details: { filename, rowCount: parsed.rows.length },
});
return created.id;
}
function matchTarget(
productName: string,
mappings: { matchPattern: string; target: ProductTarget }[],
): ProductTarget | null {
const haystack = productName.toLowerCase();
for (const mapping of mappings) {
if (haystack.includes(mapping.matchPattern.toLowerCase())) return mapping.target;
}
return null;
}
function parseDate(value: string | undefined): Date | null {
if (!value) return null;
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
/**
* Rechnet fuer jede Zeile aus, was beim Buchen passieren wuerde. Wird vor der
* Bestaetigung angezeigt und beim Commit unveraendert ausgefuehrt.
*/
export async function applyMapping(importId: number, mapping: ColumnMapping, ctx: ActionContext) {
const record = await prisma.import.findUnique({
where: { id: importId },
include: { rows: { orderBy: { lineNo: 'asc' } } },
});
if (!record) throw notFound('Import');
if (record.status !== ImportStatus.PENDING) {
throw conflict('NOT_PENDING', 'Dieser Import wurde bereits abgeschlossen');
}
const productMappings = await prisma.productMapping.findMany({
where: { isActive: true },
orderBy: [{ priority: 'asc' }, { id: 'asc' }],
select: { matchPattern: true, target: true },
});
const rawRows = record.rows.map((row) => ({
id: row.id,
lineNo: row.lineNo,
raw: row.raw as Record<string, string>,
}));
const playerKeys = [
...new Set(rawRows.map((r) => (r.raw[mapping.playerKey] ?? '').trim()).filter(Boolean)),
];
const players = await prisma.player.findMany({
where: {
OR: [{ steamId: { in: playerKeys } }, { displayName: { in: playerKeys } }],
},
select: { id: true, steamId: true, displayName: true },
});
const bySteamId = new Map(players.filter((p) => p.steamId).map((p) => [p.steamId as string, p.id]));
const byName = new Map(players.map((p) => [p.displayName.toLowerCase(), p.id]));
const orderIds = mapping.orderId
? [...new Set(rawRows.map((r) => (r.raw[mapping.orderId as string] ?? '').trim()).filter(Boolean))]
: [];
const alreadyBooked = new Set(
orderIds.length > 0
? (
await prisma.importedOrder.findMany({
where: { externalId: { in: orderIds } },
select: { externalId: true },
})
).map((o) => o.externalId)
: [],
);
// Faengt Dubletten innerhalb derselben Datei ab.
const seenInFile = new Set<string>();
const updates: Prisma.PrismaPromise<unknown>[] = [];
const summary: Record<Plan, number> = {
BOOK: 0,
CREATE_PLAYER_AND_BOOK: 0,
SKIP_DUPLICATE: 0,
SKIP_IGNORED: 0,
SKIP_UNMAPPED: 0,
SKIP_NO_PLAYER: 0,
};
for (const row of rawRows) {
const playerKey = (row.raw[mapping.playerKey] ?? '').trim();
const playerName = mapping.playerName ? (row.raw[mapping.playerName] ?? '').trim() : '';
const productName = (row.raw[mapping.product] ?? '').trim();
const externalId = mapping.orderId ? (row.raw[mapping.orderId] ?? '').trim() : '';
const purchasedAt = parseDate(mapping.purchasedAt ? row.raw[mapping.purchasedAt] : undefined);
const target = productName ? matchTarget(productName, productMappings) : null;
const resolvedPlayerId =
bySteamId.get(playerKey) ?? byName.get(playerKey.toLowerCase()) ?? null;
let plan: Plan;
if (target === null) {
plan = PLANS.SKIP_UNMAPPED;
} else if (target === ProductTarget.IGNORE) {
plan = PLANS.SKIP_IGNORED;
} else if (externalId && (alreadyBooked.has(externalId) || seenInFile.has(externalId))) {
plan = PLANS.SKIP_DUPLICATE;
} else if (resolvedPlayerId !== null) {
plan = PLANS.BOOK;
} else if (mapping.createMissingPlayers && (playerKey || playerName)) {
plan = PLANS.CREATE_PLAYER_AND_BOOK;
} else {
plan = PLANS.SKIP_NO_PLAYER;
}
if (externalId && (plan === PLANS.BOOK || plan === PLANS.CREATE_PLAYER_AND_BOOK)) {
seenInFile.add(externalId);
}
summary[plan] += 1;
updates.push(
prisma.importRow.update({
where: { id: row.id },
data: {
playerKey: playerKey || null,
playerName: playerName || null,
productName: productName || null,
externalId: externalId || null,
purchasedAt,
target,
resolvedPlayerId,
plan,
},
}),
);
}
await prisma.$transaction([
...updates,
prisma.import.update({
where: { id: importId },
data: { mapping: mapping as unknown as Prisma.InputJsonValue },
}),
]);
await recordAudit(prisma, ctx, {
action: 'import.preview',
targetType: 'import',
targetId: importId,
details: { mapping: mapping as unknown as Prisma.InputJsonValue, summary },
});
return summary;
}
export async function getImport(importId: number, includeRows = true) {
const record = await prisma.import.findUnique({
where: { id: importId },
include: {
importedBy: { select: { username: true } },
...(includeRows ? { rows: { orderBy: { lineNo: 'asc' as const } } } : {}),
},
});
if (!record) throw notFound('Import');
return record;
}
/** Fuehrt den zuvor angezeigten Plan aus. Alles oder nichts. */
export async function commitImport(importId: number, ctx: ActionContext) {
const record = await prisma.import.findUnique({
where: { id: importId },
include: { rows: { orderBy: { lineNo: 'asc' } } },
});
if (!record) throw notFound('Import');
if (record.status !== ImportStatus.PENDING) {
throw conflict('NOT_PENDING', 'Dieser Import wurde bereits abgeschlossen');
}
const bookable = record.rows.filter(
(row) => row.plan === PLANS.BOOK || row.plan === PLANS.CREATE_PLAYER_AND_BOOK,
);
if (bookable.length === 0) {
throw conflict('NOTHING_TO_BOOK', 'Es gibt keine Zeilen zum Buchen');
}
const result = await prisma.$transaction(async (tx) => {
// Mehrere Zeilen koennen denselben neuen Spieler betreffen.
const createdPlayers = new Map<string, number>();
let booked = 0;
for (const row of bookable) {
const key = (row.playerKey ?? '').trim();
let playerId = row.resolvedPlayerId;
if (playerId === null) {
const cacheKey = key.toLowerCase();
const cached = createdPlayers.get(cacheKey);
if (cached !== undefined) {
playerId = cached;
} else {
const created = await tx.player.create({
data: {
displayName: row.playerName?.trim() || key || `Unbekannt ${row.lineNo}`,
steamId: key || null,
},
});
createdPlayers.set(cacheKey, created.id);
playerId = created.id;
}
}
const target = row.target === ProductTarget.REDESIGN_PACKAGE ? 'REDESIGN_PACKAGE' : 'CC_PACKAGE';
const groupId = randomUUID();
await tx.creditLedger.createMany({
data: buildPackageRows(target, {
playerId,
groupId,
note: row.productName ? `Import: ${row.productName}` : `Import #${record.id}`,
importId: record.id,
actorId: ctx.actorId,
}),
});
if (row.externalId) {
await tx.importedOrder.create({
data: {
externalId: row.externalId,
importId: record.id,
playerId,
target: row.target ?? ProductTarget.CC_PACKAGE,
groupId,
},
});
}
booked += 1;
}
const updated = await tx.import.update({
where: { id: importId },
data: {
status: ImportStatus.COMMITTED,
committedAt: new Date(),
bookedCount: booked,
},
});
await recordAudit(tx, ctx, {
action: 'import.commit',
targetType: 'import',
targetId: importId,
details: {
filename: record.filename,
booked,
createdPlayers: createdPlayers.size,
},
});
return { booked, createdPlayers: createdPlayers.size, import: updated };
});
return result;
}
export async function discardImport(importId: number, ctx: ActionContext): Promise<void> {
const record = await prisma.import.findUnique({ where: { id: importId } });
if (!record) throw notFound('Import');
if (record.status === ImportStatus.COMMITTED) {
throw conflict('ALREADY_COMMITTED', 'Ein gebuchter Import kann nicht verworfen werden');
}
await prisma.import.update({
where: { id: importId },
data: { status: ImportStatus.DISCARDED },
});
await recordAudit(prisma, ctx, {
action: 'import.discard',
targetType: 'import',
targetId: importId,
details: { filename: record.filename },
});
}
export async function listImports(take = 50) {
return prisma.import.findMany({
orderBy: { createdAt: 'desc' },
take,
include: { importedBy: { select: { username: true } } },
});
}
+157
View File
@@ -0,0 +1,157 @@
import type { Prisma } from '@prisma/client';
import { prisma } from '../db.js';
import { conflict, notFound } from '../errors.js';
import { getBalances, getBalancesForMany, listLedger, type Balances } from './credits.js';
import { listCharacters } from './characters.js';
import { recordAudit, type ActionContext } from './audit.js';
export interface PlayerInput {
displayName: string;
steamId?: string | null;
forumName?: string | null;
notes?: string | null;
}
export async function searchPlayers(query: string, take = 50, skip = 0) {
const trimmed = query.trim();
const where: Prisma.PlayerWhereInput =
trimmed.length === 0
? {}
: {
OR: [
{ displayName: { contains: trimmed, mode: 'insensitive' } },
{ steamId: { contains: trimmed, mode: 'insensitive' } },
{ forumName: { contains: trimmed, mode: 'insensitive' } },
],
};
const [players, total] = await Promise.all([
prisma.player.findMany({
where,
orderBy: { displayName: 'asc' },
take,
skip,
include: { _count: { select: { characters: true } } },
}),
prisma.player.count({ where }),
]);
const balances = await getBalancesForMany(players.map((p) => p.id));
const activeCounts = await prisma.character.groupBy({
by: ['playerId'],
where: { playerId: { in: players.map((p) => p.id) }, status: 'ACTIVE' },
_count: { _all: true },
});
const activeByPlayer = new Map(activeCounts.map((c) => [c.playerId, c._count._all]));
return {
total,
items: players.map((player) => ({
id: player.id,
displayName: player.displayName,
steamId: player.steamId,
forumName: player.forumName,
characterCount: player._count.characters,
activeCharacterCount: activeByPlayer.get(player.id) ?? 0,
balances: balances.get(player.id) as Balances,
})),
};
}
export async function getPlayerDetail(playerId: number) {
const player = await prisma.player.findUnique({ where: { id: playerId } });
if (!player) throw notFound('Spieler');
const [balances, characters, ledger] = await Promise.all([
getBalances(playerId),
listCharacters(playerId),
listLedger({ playerId, take: 200 }),
]);
return { player, balances, characters, ledger: ledger.items, ledgerTotal: ledger.total };
}
async function assertSteamIdFree(steamId: string, exceptId?: number): Promise<void> {
const existing = await prisma.player.findUnique({ where: { steamId }, select: { id: true } });
if (existing && existing.id !== exceptId) {
throw conflict('STEAMID_TAKEN', 'Diese SteamID ist bereits einem Spieler zugeordnet');
}
}
export async function createPlayer(input: PlayerInput, ctx: ActionContext) {
const steamId = input.steamId?.trim() || null;
if (steamId) await assertSteamIdFree(steamId);
return prisma.$transaction(async (tx) => {
const created = await tx.player.create({
data: {
displayName: input.displayName.trim(),
steamId,
forumName: input.forumName?.trim() || null,
notes: input.notes?.trim() || null,
},
});
await recordAudit(tx, ctx, {
action: 'player.create',
targetType: 'player',
targetId: created.id,
details: { displayName: created.displayName, steamId: created.steamId },
});
return created;
});
}
export async function updatePlayer(playerId: number, input: Partial<PlayerInput>, ctx: ActionContext) {
const existing = await prisma.player.findUnique({ where: { id: playerId } });
if (!existing) throw notFound('Spieler');
const steamId = input.steamId === undefined ? undefined : input.steamId?.trim() || null;
if (steamId) await assertSteamIdFree(steamId, playerId);
return prisma.$transaction(async (tx) => {
const updated = await tx.player.update({
where: { id: playerId },
data: {
...(input.displayName !== undefined ? { displayName: input.displayName.trim() } : {}),
...(steamId !== undefined ? { steamId } : {}),
...(input.forumName !== undefined ? { forumName: input.forumName?.trim() || null } : {}),
...(input.notes !== undefined ? { notes: input.notes?.trim() || null } : {}),
},
});
await recordAudit(tx, ctx, {
action: 'player.update',
targetType: 'player',
targetId: playerId,
details: { displayName: updated.displayName, steamId: updated.steamId },
});
return updated;
});
}
export async function deletePlayer(playerId: number, ctx: ActionContext): Promise<void> {
const existing = await prisma.player.findUnique({
where: { id: playerId },
include: { _count: { select: { characters: true, ledger: true } } },
});
if (!existing) throw notFound('Spieler');
await prisma.$transaction(async (tx) => {
await tx.player.delete({ where: { id: playerId } });
await recordAudit(tx, ctx, {
action: 'player.delete',
targetType: 'player',
targetId: playerId,
details: {
displayName: existing.displayName,
steamId: existing.steamId,
removedCharacters: existing._count.characters,
removedLedgerRows: existing._count.ledger,
},
});
});
}
+36
View File
@@ -0,0 +1,36 @@
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { PGlite } from '@electric-sql/pglite';
import { PGLiteSocketServer } from '@electric-sql/pglite-socket';
const here = dirname(fileURLToPath(import.meta.url));
const MIGRATION = join(here, '..', 'prisma', 'migrations', '20250101000000_init', 'migration.sql');
export interface TestDatabase {
url: string;
stop: () => Promise<void>;
}
/**
* Startet eine Postgres-Instanz im Speicher und stellt sie ueber einen lokalen
* Port bereit, damit Prisma sich wie an eine echte Datenbank verbindet.
* So laufen die Tests ohne Docker oder installiertes Postgres.
*/
export async function startTestDatabase(port: number): Promise<TestDatabase> {
const db = await PGlite.create();
await db.exec(readFileSync(MIGRATION, 'utf8'));
const server = new PGLiteSocketServer({ db, port, host: '127.0.0.1' });
await server.start();
return {
// connection_limit=1, weil die Testinstanz immer nur eine Verbindung
// gleichzeitig bedient. Echtes Postgres braucht die Einschraenkung nicht.
url: `postgresql://postgres:postgres@127.0.0.1:${port}/postgres?connection_limit=1`,
stop: async () => {
await server.stop();
await db.close();
},
};
}
+288
View File
@@ -0,0 +1,288 @@
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import type { FastifyInstance } from 'fastify';
import { startTestDatabase, type TestDatabase } from './harness.js';
type Db = typeof import('../src/db.js');
let database: TestDatabase;
let app: FastifyInstance;
let db: Db;
const PASSWORD = 'TestPasswort123';
interface Credentials {
session: string;
csrf: string;
}
async function login(username: string, password = PASSWORD): Promise<Credentials> {
const response = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: { username, password },
});
assert.equal(response.statusCode, 200, `Login von ${username} fehlgeschlagen`);
const session = response.cookies.find((c) => c.name === 'ccm_sid');
const csrf = response.cookies.find((c) => c.name === 'ccm_csrf');
assert.ok(session && csrf, 'Sitzungs-Cookies fehlen');
return { session: session.value, csrf: csrf.value };
}
function authHeaders(credentials: Credentials, withCsrf = true) {
return {
cookie: `ccm_sid=${credentials.session}; ccm_csrf=${credentials.csrf}`,
...(withCsrf ? { 'x-csrf-token': credentials.csrf } : {}),
};
}
before(async () => {
database = await startTestDatabase(54330);
process.env.DATABASE_URL = database.url;
process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000';
process.env.NODE_ENV = 'test';
process.env.COOKIE_SECURE = 'false';
db = await import('../src/db.js');
const { hashPassword } = await import('../src/auth/password.js');
const { buildApp } = await import('../src/app.js');
const passwordHash = await hashPassword(PASSWORD);
await db.prisma.user.createMany({
data: [
{ username: 'chef', passwordHash, role: 'ADMIN' },
{ username: 'team', passwordHash, role: 'EDITOR' },
{ username: 'gast', passwordHash, role: 'VIEWER' },
{ username: 'neu', passwordHash, role: 'EDITOR', mustChangePassword: true },
{ username: 'gesperrt', passwordHash, role: 'EDITOR', isActive: false },
],
});
app = await buildApp();
await app.ready();
});
after(async () => {
await app.close();
await db.prisma.$disconnect();
await database.stop();
});
describe('Anmeldung', () => {
it('weist ein falsches Passwort ab', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: { username: 'chef', password: 'falsch' },
});
assert.equal(response.statusCode, 401);
});
it('verraet nicht, ob es den Benutzernamen gibt', async () => {
const unknownUser = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: { username: 'gibtesnicht', password: 'falsch' },
});
const wrongPassword = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: { username: 'chef', password: 'falsch' },
});
assert.equal(unknownUser.statusCode, wrongPassword.statusCode);
assert.equal(unknownUser.json().message, wrongPassword.json().message);
});
it('laesst ein gesperrtes Konto nicht herein', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: { username: 'gesperrt', password: PASSWORD },
});
assert.equal(response.statusCode, 401);
});
it('setzt bei Erfolg beide Cookies', async () => {
const credentials = await login('chef');
assert.ok(credentials.session.length > 20);
assert.ok(credentials.csrf.length > 20);
});
});
describe('CSRF-Schutz', () => {
it('blockt eine schreibende Anfrage ohne Token', async () => {
const credentials = await login('team');
const response = await app.inject({
method: 'POST',
url: '/api/players',
headers: authHeaders(credentials, false),
payload: { displayName: 'Ohne Token' },
});
assert.equal(response.statusCode, 403);
assert.equal(response.json().error, 'FORBIDDEN');
});
it('blockt einen gefaelschten Token', async () => {
const credentials = await login('team');
const response = await app.inject({
method: 'POST',
url: '/api/players',
headers: { ...authHeaders(credentials, false), 'x-csrf-token': 'a'.repeat(64) },
payload: { displayName: 'Falscher Token' },
});
assert.equal(response.statusCode, 403);
});
it('laesst die Anfrage mit passendem Token durch', async () => {
const credentials = await login('team');
const response = await app.inject({
method: 'POST',
url: '/api/players',
headers: authHeaders(credentials),
payload: { displayName: 'Mit Token' },
});
assert.equal(response.statusCode, 201);
});
it('laesst lesende Anfragen ohne Token zu', async () => {
const credentials = await login('gast');
const response = await app.inject({
method: 'GET',
url: '/api/players',
headers: authHeaders(credentials, false),
});
assert.equal(response.statusCode, 200);
});
});
describe('Rollen', () => {
it('ein Viewer darf lesen, aber nichts anlegen', async () => {
const credentials = await login('gast');
const read = await app.inject({
method: 'GET',
url: '/api/players',
headers: authHeaders(credentials),
});
const write = await app.inject({
method: 'POST',
url: '/api/players',
headers: authHeaders(credentials),
payload: { displayName: 'Verboten' },
});
assert.equal(read.statusCode, 200);
assert.equal(write.statusCode, 403);
});
it('ein Editor kommt nicht in den Admin-Bereich', async () => {
const credentials = await login('team');
const response = await app.inject({
method: 'GET',
url: '/api/admin/users',
headers: authHeaders(credentials),
});
assert.equal(response.statusCode, 403);
});
it('ein Admin kommt in den Admin-Bereich', async () => {
const credentials = await login('chef');
const response = await app.inject({
method: 'GET',
url: '/api/admin/users',
headers: authHeaders(credentials),
});
assert.equal(response.statusCode, 200);
assert.ok(Array.isArray(response.json().items));
});
it('ohne Anmeldung ist alles zu', async () => {
const response = await app.inject({ method: 'GET', url: '/api/players' });
assert.equal(response.statusCode, 401);
});
});
describe('Erstpasswort', () => {
it('sperrt schreibende Aktionen bis zum Wechsel', async () => {
const credentials = await login('neu');
const response = await app.inject({
method: 'POST',
url: '/api/players',
headers: authHeaders(credentials),
payload: { displayName: 'Zu frueh' },
});
assert.equal(response.statusCode, 403);
assert.match(response.json().message, /Passwort/);
});
it('erlaubt genau den Passwortwechsel', async () => {
const credentials = await login('neu');
const response = await app.inject({
method: 'POST',
url: '/api/account/password',
headers: authHeaders(credentials),
payload: { currentPassword: PASSWORD, newPassword: 'NeuesPasswort456' },
});
assert.equal(response.statusCode, 200);
const user = await db.prisma.user.findUniqueOrThrow({ where: { username: 'neu' } });
assert.equal(user.mustChangePassword, false);
});
});
describe('Guthaben ueber die API', () => {
it('ein CC-Paket schlaegt in beiden Toepfen auf', async () => {
const credentials = await login('team');
const created = await app.inject({
method: 'POST',
url: '/api/players',
headers: authHeaders(credentials),
payload: { displayName: 'API-Spieler' },
});
const playerId = created.json().id;
const booked = await app.inject({
method: 'POST',
url: `/api/players/${playerId}/credits/cc-package`,
headers: authHeaders(credentials),
payload: {},
});
assert.equal(booked.statusCode, 200);
assert.deepEqual(booked.json().balances, {
CC: 1,
REDESIGN_PAID: 1,
REDESIGN_KULANZ: 0,
redesignTotal: 1,
});
});
it('eine unbekannte API-Route liefert sauberes JSON', async () => {
const response = await app.inject({ method: 'GET', url: '/api/gibtesnicht' });
assert.equal(response.statusCode, 404);
assert.equal(response.json().error, 'NOT_FOUND');
});
});
+203
View File
@@ -0,0 +1,203 @@
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { startTestDatabase, type TestDatabase } from './harness.js';
type Imports = typeof import('../src/services/imports.js');
type Credits = typeof import('../src/services/credits.js');
type Db = typeof import('../src/db.js');
let database: TestDatabase;
let imports: Imports;
let credits: Credits;
let db: Db;
let ctx: { actorId: number; actorName: string };
const CSV = [
'Bestellnummer;Steam-ID;Spielername;Produkt;Kaufdatum',
'A-1001;76561198000000001;Rabe;CC-Paket Gross;2025-01-15',
'A-1002;76561198000000002;Fuchs;Redesign Gutschein;2025-01-16',
'A-1003;76561198000000001;Rabe;Spielerfarbe Blau;2025-01-17',
'A-1004;76561198000000003;Dachs;CC-Paket Klein;2025-01-18',
].join('\n');
const MAPPING = {
playerKey: 'Steam-ID',
playerName: 'Spielername',
product: 'Produkt',
orderId: 'Bestellnummer',
purchasedAt: 'Kaufdatum',
createMissingPlayers: true,
};
before(async () => {
database = await startTestDatabase(54332);
process.env.DATABASE_URL = database.url;
process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000';
process.env.NODE_ENV = 'test';
db = await import('../src/db.js');
imports = await import('../src/services/imports.js');
credits = await import('../src/services/credits.js');
const user = await db.prisma.user.create({
data: { username: 'importeur', passwordHash: 'x', role: 'ADMIN' },
});
ctx = { actorId: user.id, actorName: user.username };
await db.prisma.productMapping.createMany({
data: [
{ matchPattern: 'cc-paket', target: 'CC_PACKAGE', priority: 10 },
{ matchPattern: 'redesign', target: 'REDESIGN_PACKAGE', priority: 20 },
{ matchPattern: 'spielerfarbe', target: 'IGNORE', priority: 30 },
],
});
});
after(async () => {
await db.prisma.$disconnect();
await database.stop();
});
describe('Datei einlesen', () => {
it('erkennt Semikolon als Trennzeichen', () => {
const parsed = imports.parseUpload('export.csv', Buffer.from(CSV, 'utf8'));
assert.equal(parsed.rows.length, 4);
assert.deepEqual(parsed.columns, [
'Bestellnummer',
'Steam-ID',
'Spielername',
'Produkt',
'Kaufdatum',
]);
});
it('liest auch JSON-Listen', () => {
const json = JSON.stringify([{ order: 'B-1', product: 'CC-Paket', user: '765' }]);
const parsed = imports.parseUpload('export.json', Buffer.from(json, 'utf8'));
assert.equal(parsed.rows.length, 1);
assert.equal(parsed.rows[0]?.product, 'CC-Paket');
});
it('weist eine leere Datei ab', () => {
assert.throws(() => imports.parseUpload('leer.csv', Buffer.from('', 'utf8')));
});
});
describe('Vorschau und Buchung', () => {
it('ordnet jede Zeile ihrem Plan zu', async () => {
const parsed = imports.parseUpload('export.csv', Buffer.from(CSV, 'utf8'));
const importId = await imports.createImport('export.csv', parsed, ctx);
const summary = await imports.applyMapping(importId, MAPPING, ctx);
assert.equal(summary.CREATE_PLAYER_AND_BOOK, 3, 'drei neue Spieler mit gueltigem Produkt');
assert.equal(summary.SKIP_IGNORED, 1, 'die Spielerfarbe wird ignoriert');
assert.equal(summary.SKIP_DUPLICATE, 0);
});
it('bucht CC-Pakete mit beiden Guthaben und legt Spieler an', async () => {
const parsed = imports.parseUpload('export.csv', Buffer.from(CSV, 'utf8'));
const importId = await imports.createImport('lauf1.csv', parsed, ctx);
await imports.applyMapping(importId, MAPPING, ctx);
const result = await imports.commitImport(importId, ctx);
assert.equal(result.booked, 3);
assert.equal(result.createdPlayers, 3);
const rabe = await db.prisma.player.findUniqueOrThrow({
where: { steamId: '76561198000000001' },
});
const balances = await credits.getBalances(rabe.id);
assert.equal(balances.CC, 1, 'CC-Paket gibt einen CC');
assert.equal(balances.REDESIGN_PAID, 1, 'und das inklusive Redesign');
const fuchs = await db.prisma.player.findUniqueOrThrow({
where: { steamId: '76561198000000002' },
});
const fuchsBalances = await credits.getBalances(fuchs.id);
assert.equal(fuchsBalances.CC, 0, 'ein Redesign-Paket gibt keinen CC');
assert.equal(fuchsBalances.REDESIGN_PAID, 1);
});
it('bucht dieselbe Bestell-ID kein zweites Mal', async () => {
const parsed = imports.parseUpload('export.csv', Buffer.from(CSV, 'utf8'));
const importId = await imports.createImport('lauf2.csv', parsed, ctx);
const summary = await imports.applyMapping(importId, MAPPING, ctx);
assert.equal(summary.SKIP_DUPLICATE, 3, 'alle bereits gebuchten Bestellungen');
assert.equal(summary.BOOK, 0);
assert.equal(summary.CREATE_PLAYER_AND_BOOK, 0);
await assert.rejects(
() => imports.commitImport(importId, ctx),
(error: Error & { code?: string }) => error.code === 'NOTHING_TO_BOOK',
);
});
it('erkennt Dubletten auch innerhalb einer Datei', async () => {
const doubled = [
'Bestellnummer;Steam-ID;Produkt',
'C-1;76561198000000009;CC-Paket',
'C-1;76561198000000009;CC-Paket',
].join('\n');
const parsed = imports.parseUpload('doppelt.csv', Buffer.from(doubled, 'utf8'));
const importId = await imports.createImport('doppelt.csv', parsed, ctx);
const summary = await imports.applyMapping(
importId,
{
playerKey: 'Steam-ID',
product: 'Produkt',
orderId: 'Bestellnummer',
createMissingPlayers: true,
},
ctx,
);
assert.equal(summary.CREATE_PLAYER_AND_BOOK, 1);
assert.equal(summary.SKIP_DUPLICATE, 1);
});
it('ueberspringt unbekannte Spieler, wenn sie nicht angelegt werden sollen', async () => {
const csv = ['Bestellnummer;Steam-ID;Produkt', 'D-1;76561198000009999;CC-Paket'].join('\n');
const parsed = imports.parseUpload('fremd.csv', Buffer.from(csv, 'utf8'));
const importId = await imports.createImport('fremd.csv', parsed, ctx);
const summary = await imports.applyMapping(
importId,
{
playerKey: 'Steam-ID',
product: 'Produkt',
orderId: 'Bestellnummer',
createMissingPlayers: false,
},
ctx,
);
assert.equal(summary.SKIP_NO_PLAYER, 1);
});
it('ein abgeschlossener Import laesst sich nicht erneut buchen', async () => {
const csv = ['Bestellnummer;Steam-ID;Produkt', 'E-1;76561198000000001;CC-Paket'].join('\n');
const parsed = imports.parseUpload('nochmal.csv', Buffer.from(csv, 'utf8'));
const importId = await imports.createImport('nochmal.csv', parsed, ctx);
await imports.applyMapping(
importId,
{ playerKey: 'Steam-ID', product: 'Produkt', orderId: 'Bestellnummer', createMissingPlayers: false },
ctx,
);
await imports.commitImport(importId, ctx);
await assert.rejects(
() => imports.commitImport(importId, ctx),
(error: Error & { code?: string }) => error.code === 'NOT_PENDING',
);
});
});
+286
View File
@@ -0,0 +1,286 @@
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { startTestDatabase, type TestDatabase } from './harness.js';
// Die Module lesen beim Laden die Umgebung, deshalb werden sie erst nach dem
// Start der Testdatenbank dynamisch importiert.
type Credits = typeof import('../src/services/credits.js');
type Characters = typeof import('../src/services/characters.js');
type Db = typeof import('../src/db.js');
let database: TestDatabase;
let credits: Credits;
let characters: Characters;
let db: Db;
let ctx: { actorId: number; actorName: string };
async function freshPlayer(name: string): Promise<number> {
const player = await db.prisma.player.create({ data: { displayName: name } });
return player.id;
}
before(async () => {
database = await startTestDatabase(54329);
process.env.DATABASE_URL = database.url;
process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000';
process.env.NODE_ENV = 'test';
db = await import('../src/db.js');
credits = await import('../src/services/credits.js');
characters = await import('../src/services/characters.js');
const user = await db.prisma.user.create({
data: { username: 'tester', passwordHash: 'x', role: 'ADMIN' },
});
ctx = { actorId: user.id, actorName: user.username };
});
after(async () => {
await db.prisma.$disconnect();
await database.stop();
});
describe('Guthaben-Toepfe', () => {
it('ein CC-Paket erzeugt CC UND bezahltes Redesign', async () => {
const playerId = await freshPlayer('Paket-Spieler');
const { balances } = await credits.bookCcPackage(playerId, ctx);
assert.equal(balances.CC, 1);
assert.equal(balances.REDESIGN_PAID, 1);
assert.equal(balances.REDESIGN_KULANZ, 0);
});
it('ein Redesign-Paket erzeugt nur Redesign-Guthaben', async () => {
const playerId = await freshPlayer('Redesign-Spieler');
const { balances } = await credits.bookRedesignPackage(playerId, ctx);
assert.equal(balances.CC, 0);
assert.equal(balances.REDESIGN_PAID, 1);
});
it('Kulanz landet im eigenen Topf und zaehlt zur Redesign-Summe', async () => {
const playerId = await freshPlayer('Kulanz-Spieler');
const { balances } = await credits.bookKulanz(playerId, ctx);
assert.equal(balances.REDESIGN_KULANZ, 1);
assert.equal(balances.REDESIGN_PAID, 0);
assert.equal(balances.redesignTotal, 1);
});
it('ein ueberzogenes Konto wird zugelassen, nicht blockiert', async () => {
const playerId = await freshPlayer('Minus-Spieler');
const result = await characters.createCharacter(playerId, { name: 'Vorab-CC' }, ctx);
assert.equal(result.balances.CC, -1);
assert.equal(result.overdrawn, true);
});
});
describe('CC-Lebenszyklus', () => {
it('ein neuer CC verbraucht genau ein CC-Guthaben', async () => {
const playerId = await freshPlayer('Ersteller');
await credits.bookCcPackage(playerId, ctx);
const result = await characters.createCharacter(playerId, { name: 'Dr. Bright' }, ctx);
assert.equal(result.character.status, 'ACTIVE');
assert.equal(result.balances.CC, 0);
assert.equal(result.balances.REDESIGN_PAID, 1, 'das inklusive Redesign bleibt erhalten');
assert.equal(result.overdrawn, false);
});
it('ein Redesign setzt den alten CC auf REWORKED und verkettet den neuen', async () => {
const playerId = await freshPlayer('Redesigner');
await credits.bookCcPackage(playerId, ctx);
const first = await characters.createCharacter(playerId, { name: 'Alt' }, ctx);
const result = await characters.redesignCharacter(
first.character.id,
{ name: 'Neu', useKind: 'REDESIGN_PAID' },
ctx,
);
assert.equal(result.predecessor.status, 'INACTIVE');
assert.equal(result.predecessor.inactiveReason, 'REWORKED');
assert.equal(result.character.status, 'ACTIVE');
assert.equal(result.character.predecessorId, first.character.id);
assert.equal(
result.character.lineageId,
first.character.lineageId,
'die Kette behaelt ihre lineageId',
);
assert.equal(result.balances.REDESIGN_PAID, 0);
});
it('ein Redesign kann auch aus dem Kulanz-Topf bezahlt werden', async () => {
const playerId = await freshPlayer('Kulanz-Redesigner');
await credits.bookCcPackage(playerId, ctx);
await credits.bookKulanz(playerId, ctx);
const first = await characters.createCharacter(playerId, { name: 'Alt' }, ctx);
const result = await characters.redesignCharacter(
first.character.id,
{ name: 'Neu', useKind: 'REDESIGN_KULANZ' },
ctx,
);
assert.equal(result.balances.REDESIGN_KULANZ, 0);
assert.equal(result.balances.REDESIGN_PAID, 1, 'der bezahlte Topf bleibt unangetastet');
});
});
describe('Reaktivierung', () => {
it('ein wegen Inaktivitaet entfernter CC kommt gratis zurueck', async () => {
const playerId = await freshPlayer('Inaktiver');
await credits.bookCcPackage(playerId, ctx);
const created = await characters.createCharacter(playerId, { name: 'Geputzt' }, ctx);
await characters.deactivateCharacter(created.character.id, 'PLAYER_INACTIVITY', ctx);
const before = await credits.getBalances(playerId);
const result = await characters.reactivateFree(created.character.id, ctx);
assert.equal(result.character.status, 'ACTIVE');
assert.equal(result.character.inactiveReason, null);
assert.deepEqual(result.balances, before, 'kein Guthaben wurde bewegt');
});
it('ein reworkter CC kommt NICHT gratis zurueck', async () => {
const playerId = await freshPlayer('Rework-Spieler');
await credits.bookCcPackage(playerId, ctx);
const first = await characters.createCharacter(playerId, { name: 'Alt' }, ctx);
await characters.redesignCharacter(
first.character.id,
{ name: 'Neu', useKind: 'REDESIGN_PAID' },
ctx,
);
await assert.rejects(
() => characters.reactivateFree(first.character.id, ctx),
(error: Error & { code?: string }) => error.code === 'NOT_FREE',
);
});
it('ein reworkter CC kommt gegen CC-Guthaben zurueck', async () => {
const playerId = await freshPlayer('Wiederbeleber');
await credits.bookCcPackage(playerId, ctx);
await credits.bookCcPackage(playerId, ctx);
const first = await characters.createCharacter(playerId, { name: 'Alt' }, ctx);
await characters.redesignCharacter(
first.character.id,
{ name: 'Neu', useKind: 'REDESIGN_PAID' },
ctx,
);
const before = await credits.getBalances(playerId);
const result = await characters.reactivatePaid(first.character.id, 'CC', ctx);
assert.equal(result.character.status, 'ACTIVE');
assert.equal(result.balances.CC, before.CC - 1);
});
it('eine bezahlte Reaktivierung wird bei Gratis-Faellen verweigert', async () => {
const playerId = await freshPlayer('Gratis-Fall');
await credits.bookCcPackage(playerId, ctx);
const created = await characters.createCharacter(playerId, { name: 'Geputzt' }, ctx);
await characters.deactivateCharacter(created.character.id, 'PLAYER_INACTIVITY', ctx);
await assert.rejects(
() => characters.reactivatePaid(created.character.id, 'CC', ctx),
(error: Error & { code?: string }) => error.code === 'IS_FREE',
);
});
});
describe('Korrekturen', () => {
it('ein Paket-Storno nimmt beide Zeilen gemeinsam zurueck', async () => {
const playerId = await freshPlayer('Storno-Spieler');
const booked = await credits.bookCcPackage(playerId, ctx);
const entry = await db.prisma.creditLedger.findFirstOrThrow({
where: { groupId: booked.groupId },
});
const result = await credits.reverseGroup(entry.id, 'Doppelt erfasst', ctx);
assert.equal(result.reversed, 2, 'beide Zeilen des Pakets');
assert.equal(result.balances.CC, 0);
assert.equal(result.balances.REDESIGN_PAID, 0);
});
it('dieselbe Buchung laesst sich kein zweites Mal stornieren', async () => {
const playerId = await freshPlayer('Doppel-Storno');
const booked = await credits.bookKulanz(playerId, ctx);
const entry = await db.prisma.creditLedger.findFirstOrThrow({
where: { groupId: booked.groupId },
});
await credits.reverseGroup(entry.id, 'Erste Korrektur', ctx);
await assert.rejects(
() => credits.reverseGroup(entry.id, 'Nochmal', ctx),
(error: Error & { code?: string }) => error.code === 'ALREADY_REVERSED',
);
});
it('eine Verbrauchsbuchung wird nicht direkt storniert', async () => {
const playerId = await freshPlayer('Verbrauch-Storno');
await credits.bookCcPackage(playerId, ctx);
const created = await characters.createCharacter(playerId, { name: 'CC' }, ctx);
const consume = await db.prisma.creditLedger.findFirstOrThrow({
where: { characterId: created.character.id, source: 'CONSUME' },
});
await assert.rejects(
() => credits.reverseGroup(consume.id, 'Versuch', ctx),
(error: Error & { code?: string }) => error.code === 'CONSUME_NOT_REVERSIBLE',
);
});
it('eine Ruecknahme bucht das Guthaben zurueck und reaktiviert den Vorgaenger', async () => {
const playerId = await freshPlayer('Ruecknehmer');
await credits.bookCcPackage(playerId, ctx);
const first = await characters.createCharacter(playerId, { name: 'Original' }, ctx);
const redesigned = await characters.redesignCharacter(
first.character.id,
{ name: 'Versehen', useKind: 'REDESIGN_PAID' },
ctx,
);
const result = await characters.revertCharacter(
redesigned.character.id,
'Falscher Spieler',
ctx,
);
assert.equal(result.restoredPredecessorId, first.character.id);
assert.equal(result.balances.REDESIGN_PAID, 1, 'das Redesign-Guthaben ist wieder da');
const restored = await db.prisma.character.findUniqueOrThrow({
where: { id: first.character.id },
});
assert.equal(restored.status, 'ACTIVE');
assert.equal(restored.inactiveReason, null);
});
it('eine Ruecknahme mitten in der Kette wird abgelehnt', async () => {
const playerId = await freshPlayer('Ketten-Spieler');
await credits.bookCcPackage(playerId, ctx);
await credits.bookRedesignPackage(playerId, ctx);
const first = await characters.createCharacter(playerId, { name: 'A' }, ctx);
const second = await characters.redesignCharacter(
first.character.id,
{ name: 'B', useKind: 'REDESIGN_PAID' },
ctx,
);
await characters.redesignCharacter(
second.character.id,
{ name: 'C', useKind: 'REDESIGN_PAID' },
ctx,
);
await assert.rejects(
() => characters.revertCharacter(second.character.id, 'Zu spaet', ctx),
(error: Error & { code?: string }) => error.code === 'HAS_SUCCESSORS',
);
});
});
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2023"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"sourceMap": true,
"declaration": false
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex, nofollow" />
<title>CC-Manager</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@cc-manager/web",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.3.0"
},
"devDependencies": {
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.8.2",
"vite": "^6.2.1"
}
}
+97
View File
@@ -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 <div className="boot">Lade …</div>;
if (!user) return <Navigate to="/login" replace />;
// Ein frisch angelegtes Konto muss zuerst sein Passwort wechseln.
if (user.mustChangePassword) return <Navigate to="/account" replace />;
if (minimum && !can(minimum)) return <Navigate to="/" replace />;
return <>{children}</>;
}
export default function App() {
const { user, loading } = useSession();
return (
<Routes>
<Route
path="/login"
element={loading ? <div className="boot">Lade …</div> : user ? <Navigate to="/" replace /> : <Login />}
/>
<Route
path="/account"
element={
loading ? (
<div className="boot">Lade …</div>
) : !user ? (
<Navigate to="/login" replace />
) : (
<Layout>
<Account />
</Layout>
)
}
/>
<Route
path="/"
element={
<Protected>
<Layout>
<Dashboard />
</Layout>
</Protected>
}
/>
<Route
path="/players/:id"
element={
<Protected>
<Layout>
<PlayerDetail />
</Layout>
</Protected>
}
/>
<Route
path="/import"
element={
<Protected minimum="ADMIN">
<Layout>
<Import />
</Layout>
</Protected>
}
/>
<Route
path="/admin"
element={
<Protected minimum="ADMIN">
<Layout>
<Admin />
</Layout>
</Protected>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
+43
View File
@@ -0,0 +1,43 @@
import type { Balances } from '../lib/types';
function Card({ label, value, hint }: { label: string; value: number; hint?: string }) {
return (
<div className={`balance-card${value < 0 ? ' is-negative' : ''}`}>
<span className="balance-label">{label}</span>
<span className="balance-value">{value}</span>
{hint && <span className="balance-hint">{hint}</span>}
</div>
);
}
export default function BalanceCards({ balances }: { balances: Balances }) {
const canCreate = balances.CC >= 1;
const canRedesign = balances.redesignTotal >= 1;
return (
<section className="balances" aria-label="Guthaben">
<div className="balance-grid">
<Card label="CC-Guthaben" value={balances.CC} />
<Card label="Redesign (bezahlt)" value={balances.REDESIGN_PAID} />
<Card label="Redesign (Kulanz)" value={balances.REDESIGN_KULANZ} />
</div>
<div className="eligibility">
<span className={canCreate ? 'ok' : 'no'}>
{canCreate ? '✓' : '✕'} Neuer CC {canCreate ? 'moeglich' : 'nicht moeglich'}
</span>
<span className={canRedesign ? 'ok' : 'no'}>
{canRedesign ? '✓' : '✕'} Redesign {canRedesign ? 'moeglich' : 'nicht moeglich'}
{canRedesign && ` (${balances.redesignTotal} verfuegbar)`}
</span>
</div>
{(balances.CC < 0 || balances.REDESIGN_PAID < 0 || balances.REDESIGN_KULANZ < 0) && (
<p className="overdrawn-hint">
Mindestens ein Topf ist im Minus. Das ist erlaubt, deutet aber meist auf ein noch nicht
erfasstes Paket hin.
</p>
)}
</section>
);
}
+45
View File
@@ -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 (
<div className="shell">
<header className="topbar">
<div className="topbar-inner">
<NavLink to="/" className="brand">
CC-Manager
</NavLink>
<nav className="nav">
<NavLink to="/" end>
Spieler
</NavLink>
{can('ADMIN') && <NavLink to="/import">Import</NavLink>}
{can('ADMIN') && <NavLink to="/admin">Verwaltung</NavLink>}
</nav>
<div className="topbar-right">
<NavLink to="/account" className="user-chip">
{user?.username}
<span className="role-tag">{user?.role}</span>
</NavLink>
<button type="button" className="btn btn-ghost" onClick={handleLogout}>
Abmelden
</button>
</div>
</div>
</header>
<main className="content">{children}</main>
</div>
);
}
+40
View File
@@ -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 (
<div className="modal-backdrop" onMouseDown={onClose}>
<div
className="modal"
role="dialog"
aria-modal="true"
aria-label={title}
onMouseDown={(event) => event.stopPropagation()}
>
<div className="modal-head">
<h2>{title}</h2>
<button type="button" className="btn btn-ghost" onClick={onClose} aria-label="Schliessen">
✕
</button>
</div>
<div className="modal-body">{children}</div>
{footer && <div className="modal-foot">{footer}</div>}
</div>
</div>
);
}
+18
View File
@@ -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 (
<div className={`notice notice-${kind}`} role={kind === 'error' ? 'alert' : 'status'}>
<span>{children}</span>
{onDismiss && (
<button type="button" className="notice-close" onClick={onDismiss} aria-label="Ausblenden">
✕
</button>
)}
</div>
);
}
+102
View File
@@ -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<T>(path: string, options: RequestOptions = {}): Promise<T> {
const method = options.method ?? 'GET';
const headers: Record<string, string> = {};
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<T>(path: string, file: File): Promise<T> {
const form = new FormData();
form.append('file', file);
const headers: Record<string, string> = {};
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 };
}
}
+49
View File
@@ -0,0 +1,49 @@
import type { CreditKind, CreditSource, InactiveReason, ImportPlan } from './types';
export const KIND_LABEL: Record<CreditKind, string> = {
CC: 'CC-Guthaben',
REDESIGN_PAID: 'Redesign (bezahlt)',
REDESIGN_KULANZ: 'Redesign (Kulanz)',
};
export const SOURCE_LABEL: Record<CreditSource, string> = {
CC_PACKAGE: 'CC-Paket',
REDESIGN_PACKAGE: 'Redesign-Paket',
KULANZ: 'Kulanz',
ADJUSTMENT: 'Korrektur',
CONSUME: 'Verbrauch',
REVERSAL: 'Storno',
};
export const REASON_LABEL: Record<InactiveReason, string> = {
PLAYER_INACTIVITY: 'Spieler-Inaktivitaet',
REWORKED: 'Redesignt',
OTHER: 'Sonstiger Grund',
};
export const PLAN_LABEL: Record<ImportPlan, string> = {
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);
}
+63
View File
@@ -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<void>;
logout: () => Promise<void>;
can: (minimum: Role) => boolean;
}
const RANK: Record<Role, number> = { VIEWER: 1, EDITOR: 2, ADMIN: 3 };
const SessionContext = createContext<SessionState | null>(null);
export function SessionProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<SessionUser | null>(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<SessionState>(
() => ({ user, loading, setUser, refresh, logout, can }),
[user, loading, refresh, logout, can],
);
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
}
export function useSession(): SessionState {
const context = useContext(SessionContext);
if (!context) throw new Error('useSession benoetigt einen SessionProvider');
return context;
}
+156
View File
@@ -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<string, string>;
}
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[];
}
+19
View File
@@ -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(
<StrictMode>
<BrowserRouter>
<SessionProvider>
<App />
</SessionProvider>
</BrowserRouter>
</StrictMode>,
);
+203
View File
@@ -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<string | null>(null);
const [flash, setFlash] = useState<string | null>(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 (
<div className="page narrow">
<div className="page-head">
<h1>Mein Konto</h1>
</div>
{user?.mustChangePassword && (
<Notice kind="warn">
Dieses Konto verwendet noch ein vergebenes Erstpasswort. Bitte jetzt aendern — die
uebrigen Seiten sind bis dahin gesperrt.
</Notice>
)}
{error && <Notice kind="error" onDismiss={() => setError(null)}>{error}</Notice>}
{flash && <Notice kind="success" onDismiss={() => setFlash(null)}>{flash}</Notice>}
<section className="block">
<h2>Passwort aendern</h2>
<form onSubmit={changePassword}>
<label htmlFor="current">Aktuelles Passwort</label>
<input
id="current"
type="password"
autoComplete="current-password"
value={current}
onChange={(event) => setCurrent(event.target.value)}
required
/>
<label htmlFor="next">Neues Passwort</label>
<input
id="next"
type="password"
autoComplete="new-password"
value={next}
onChange={(event) => setNext(event.target.value)}
required
/>
<p className="muted small">
Mindestens 12 Zeichen, Gross- und Kleinbuchstaben sowie eine Ziffer.
</p>
<label htmlFor="repeat">Neues Passwort wiederholen</label>
<input
id="repeat"
type="password"
autoComplete="new-password"
value={repeat}
onChange={(event) => setRepeat(event.target.value)}
required
/>
<button type="submit" className="btn btn-primary" disabled={busy}>
{busy ? 'Speichere …' : 'Passwort aendern'}
</button>
</form>
</section>
<section className="block">
<h2>Zwei-Faktor-Anmeldung</h2>
{user?.totpEnabled ? (
<>
<Notice kind="success">Zwei-Faktor-Anmeldung ist fuer dieses Konto aktiv.</Notice>
<button type="button" className="btn btn-danger" onClick={() => void disableTotp()}>
Deaktivieren
</button>
</>
) : totpSetup ? (
<form onSubmit={enableTotp}>
<p className="muted">
QR-Code in der Authenticator-App scannen und den angezeigten Code eintragen.
</p>
<img className="qr" src={totpSetup.qr} alt="QR-Code zur Einrichtung" />
<p className="muted small">
Falls Scannen nicht geht, Schluessel manuell eintragen:{' '}
<code className="mono">{totpSetup.secret}</code>
</p>
<label htmlFor="totp">Code aus der App</label>
<input
id="totp"
inputMode="numeric"
value={totpCode}
onChange={(event) => setTotpCode(event.target.value)}
maxLength={10}
required
/>
<div className="form-actions">
<button type="button" className="btn btn-ghost" onClick={() => setTotpSetup(null)}>
Abbrechen
</button>
<button type="submit" className="btn btn-primary" disabled={busy}>
Aktivieren
</button>
</div>
</form>
) : (
<>
<p className="muted">
Schuetzt das Konto zusaetzlich mit einem Einmalcode. Fuer Administratoren empfohlen.
</p>
<button type="button" className="btn" onClick={() => void startTotp()}>
Einrichten
</button>
</>
)}
</section>
</div>
);
}
+507
View File
@@ -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>
);
}
+208
View File
@@ -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 (
<span className={`mini-counter${value < 0 ? ' is-negative' : ''}`} title={title}>
{value}
</span>
);
}
export default function Dashboard() {
const { can } = useSession();
const [query, setQuery] = useState('');
const [result, setResult] = useState<SearchResult>({ total: 0, items: [] });
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<SearchResult>(`/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 (
<div className="page">
<div className="page-head">
<h1>Spieler</h1>
{can('EDITOR') && (
<button type="button" className="btn btn-primary" onClick={() => setCreating(true)}>
Spieler anlegen
</button>
)}
</div>
<input
className="search"
type="search"
placeholder="Name, SteamID oder Forum-Name suchen …"
value={query}
onChange={(event) => setQuery(event.target.value)}
autoFocus
/>
{error && <Notice kind="error">{error}</Notice>}
<div className="table-wrap">
<table className="table">
<thead>
<tr>
<th>Spieler</th>
<th>SteamID</th>
<th className="num">CC</th>
<th className="num">Redesign</th>
<th className="num">Kulanz</th>
<th className="num">Aktive CCs</th>
</tr>
</thead>
<tbody>
{result.items.map((player) => (
<tr key={player.id}>
<td>
<Link to={`/players/${player.id}`} className="link-strong">
{player.displayName}
</Link>
{player.forumName && <span className="muted"> · {player.forumName}</span>}
</td>
<td className="mono muted">{player.steamId ?? '—'}</td>
<td className="num">
<Counter value={player.balances.CC} title="CC-Guthaben" />
</td>
<td className="num">
<Counter value={player.balances.REDESIGN_PAID} title="Redesign-Guthaben" />
</td>
<td className="num">
<Counter value={player.balances.REDESIGN_KULANZ} title="Kulanz-Guthaben" />
</td>
<td className="num muted">
{player.activeCharacterCount} / {player.characterCount}
</td>
</tr>
))}
{!loading && result.items.length === 0 && (
<tr>
<td colSpan={6} className="empty">
{query ? 'Kein Treffer.' : 'Noch keine Spieler erfasst.'}
</td>
</tr>
)}
</tbody>
</table>
</div>
{loading && <p className="muted">Lade …</p>}
{!loading && result.total > result.items.length && (
<p className="muted">
{result.items.length} von {result.total} angezeigt — Suche eingrenzen.
</p>
)}
{creating && (
<CreatePlayerModal
onClose={() => setCreating(false)}
onCreated={() => {
setCreating(false);
void load(query);
}}
/>
)}
</div>
);
}
function CreatePlayerModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
const [displayName, setDisplayName] = useState('');
const [steamId, setSteamId] = useState('');
const [forumName, setForumName] = useState('');
const [error, setError] = useState<string | null>(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 (
<Modal title="Spieler anlegen" onClose={onClose}>
<form onSubmit={submit}>
{error && <Notice kind="error">{error}</Notice>}
<label htmlFor="displayName">Anzeigename</label>
<input
id="displayName"
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
autoFocus
required
/>
<label htmlFor="steamId">SteamID (optional)</label>
<input id="steamId" value={steamId} onChange={(event) => setSteamId(event.target.value)} />
<label htmlFor="forumName">Forum-Name (optional)</label>
<input
id="forumName"
value={forumName}
onChange={(event) => setForumName(event.target.value)}
/>
<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>
);
}
+388
View File
@@ -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<string, string>[];
}
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<keyof Omit<MappingForm, 'createMissingPlayers'>, 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<UploadResult | null>(null);
const [mapping, setMapping] = useState<MappingForm>(EMPTY_MAPPING);
const [preview, setPreview] = useState<{
summary: Record<ImportPlan, number>;
import: ImportRecord;
} | null>(null);
const [error, setError] = useState<string | null>(null);
const [flash, setFlash] = useState<string | null>(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<UploadResult>('/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<ImportPlan, number>; 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 (
<div className="page">
<div className="page-head">
<h1>Import</h1>
</div>
{error && <Notice kind="error" onDismiss={() => setError(null)}>{error}</Notice>}
{flash && <Notice kind="success" onDismiss={() => setFlash(null)}>{flash}</Notice>}
{!upload && (
<section className="block">
<Notice kind="info">
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.
</Notice>
<label className="file-drop">
<input
type="file"
accept=".csv,.json,.tsv,.txt"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) void handleUpload(file);
}}
disabled={busy}
/>
<span>{busy ? 'Lade hoch …' : 'Datei auswaehlen'}</span>
</label>
</section>
)}
{upload && !preview && (
<section className="block">
<h2>Spalten zuordnen</h2>
<p className="muted">
{upload.rowCount} Zeilen aus <strong>{upload.columns.length}</strong> Spalten gelesen.
</p>
<form onSubmit={handlePreview}>
<div className="grid-2">
<MappingSelect
label="Spieler-Kennung (Pflicht)"
value={mapping.playerKey}
columns={upload.columns}
required
onChange={(value) => setMapping({ ...mapping, playerKey: value })}
/>
<MappingSelect
label="Produktname (Pflicht)"
value={mapping.product}
columns={upload.columns}
required
onChange={(value) => setMapping({ ...mapping, product: value })}
/>
<MappingSelect
label="Anzeigename"
value={mapping.playerName}
columns={upload.columns}
onChange={(value) => setMapping({ ...mapping, playerName: value })}
/>
<MappingSelect
label="Bestell-ID (fuer Dedup)"
value={mapping.orderId}
columns={upload.columns}
onChange={(value) => setMapping({ ...mapping, orderId: value })}
/>
<MappingSelect
label="Kaufdatum"
value={mapping.purchasedAt}
columns={upload.columns}
onChange={(value) => setMapping({ ...mapping, purchasedAt: value })}
/>
</div>
<label className="checkbox">
<input
type="checkbox"
checked={mapping.createMissingPlayers}
onChange={(event) =>
setMapping({ ...mapping, createMissingPlayers: event.target.checked })
}
/>
<span>Unbekannte Spieler automatisch anlegen</span>
</label>
{!mapping.orderId && (
<Notice kind="warn">
Ohne Bestell-ID kann ein zweiter Import dieselben Kaeufe erneut buchen.
</Notice>
)}
<div className="form-actions">
<button type="button" className="btn btn-ghost" onClick={() => void handleDiscard()}>
Abbrechen
</button>
<button type="submit" className="btn btn-primary" disabled={busy}>
{busy ? 'Berechne …' : 'Vorschau erzeugen'}
</button>
</div>
</form>
<h3>Beispielzeilen</h3>
<div className="table-wrap">
<table className="table small">
<thead>
<tr>
{upload.columns.map((column) => (
<th key={column}>{column}</th>
))}
</tr>
</thead>
<tbody>
{upload.sample.map((row, index) => (
<tr key={index}>
{upload.columns.map((column) => (
<td key={column} className="muted">
{row[column] ?? ''}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
{preview && (
<section className="block">
<h2>Vorschau</h2>
<div className="summary-grid">
{(Object.keys(PLAN_LABEL) as ImportPlan[]).map((plan) => (
<div
key={plan}
className={`summary-card${
plan === 'BOOK' || plan === 'CREATE_PLAYER_AND_BOOK' ? ' is-active' : ''
}`}
>
<span className="summary-value">{preview.summary[plan] ?? 0}</span>
<span className="summary-label">{PLAN_LABEL[plan]}</span>
</div>
))}
</div>
{bookable === 0 ? (
<Notice kind="warn">
Keine Zeile wuerde gebucht. Pruefe die Spaltenzuordnung oder die Produkt-Mappings
in der Verwaltung.
</Notice>
) : (
<Notice kind="info">
{bookable} Zeilen werden gebucht. Ein CC-Paket erzeugt dabei automatisch ein
CC-Guthaben <em>und</em> ein bezahltes Redesign-Guthaben.
</Notice>
)}
<div className="table-wrap scroll">
<table className="table small">
<thead>
<tr>
<th>#</th>
<th>Spieler</th>
<th>Produkt</th>
<th>Bestell-ID</th>
<th>Datum</th>
<th>Ergebnis</th>
</tr>
</thead>
<tbody>
{(preview.import.rows ?? []).map((row) => (
<tr key={row.id} className={row.plan.startsWith('SKIP') ? 'is-skipped' : undefined}>
<td className="muted">{row.lineNo}</td>
<td>
{row.playerName || row.playerKey || '—'}
{row.resolvedPlayerId === null && row.plan === 'CREATE_PLAYER_AND_BOOK' && (
<span className="badge badge-new">neu</span>
)}
</td>
<td className="muted">{row.productName ?? '—'}</td>
<td className="mono muted">{row.externalId ?? '—'}</td>
<td className="muted">{formatDate(row.purchasedAt)}</td>
<td className={row.plan.startsWith('SKIP') ? 'muted' : 'pos'}>
{PLAN_LABEL[row.plan]}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="form-actions">
<button type="button" className="btn btn-ghost" onClick={() => void handleDiscard()}>
Verwerfen
</button>
<button type="button" className="btn btn-ghost" onClick={() => setPreview(null)}>
Zuordnung aendern
</button>
<button
type="button"
className="btn btn-primary"
disabled={busy || bookable === 0}
onClick={() => void handleCommit()}
>
{busy ? 'Buche …' : `${bookable} Zeilen buchen`}
</button>
</div>
</section>
)}
</div>
);
}
function MappingSelect({
label,
value,
columns,
required,
onChange,
}: {
label: string;
value: string;
columns: string[];
required?: boolean;
onChange: (value: string) => void;
}) {
return (
<div className="field">
<label>{label}</label>
<select value={value} onChange={(event) => onChange(event.target.value)} required={required}>
<option value="">— nicht zuordnen —</option>
{columns.map((column) => (
<option key={column} value={column}>
{column}
</option>
))}
</select>
</div>
);
}
+141
View File
@@ -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<string | null>(null);
const [error, setError] = useState<string | null>(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<LoginResponse>('/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 (
<div className="login-page">
<div className="login-card">
<h1>CC-Manager</h1>
<p className="login-sub">
{challenge ? 'Bestaetigungscode eingeben' : 'Bitte anmelden'}
</p>
{error && <Notice kind="error">{error}</Notice>}
{challenge ? (
<form onSubmit={submitTotp}>
<label htmlFor="code">Code aus der Authenticator-App</label>
<input
id="code"
inputMode="numeric"
autoComplete="one-time-code"
value={code}
onChange={(event) => setCode(event.target.value)}
maxLength={10}
autoFocus
required
/>
<button type="submit" className="btn btn-primary btn-block" disabled={busy}>
{busy ? 'Pruefe …' : 'Bestaetigen'}
</button>
<button
type="button"
className="btn btn-ghost btn-block"
onClick={() => {
setChallenge(null);
setCode('');
setError(null);
}}
>
Zurueck
</button>
</form>
) : (
<form onSubmit={submitPassword}>
<label htmlFor="username">Benutzername</label>
<input
id="username"
autoComplete="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
autoFocus
required
/>
<label htmlFor="password">Passwort</label>
<input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
<button type="submit" className="btn btn-primary btn-block" disabled={busy}>
{busy ? 'Melde an …' : 'Anmelden'}
</button>
</form>
)}
</div>
</div>
);
}
+730
View File
@@ -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<Detail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [flash, setFlash] = useState<string | null>(null);
const [dialog, setDialog] = useState<DialogState>({ type: 'none' });
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
try {
setDetail(await api<Detail>(`/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<unknown>, 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 <p className="muted">Lade …</p>;
if (!detail) return <Notice kind="error">{error ?? 'Spieler nicht gefunden'}</Notice>;
const editor = can('EDITOR');
const active = detail.characters.filter((c) => c.status === 'ACTIVE');
const inactive = detail.characters.filter((c) => c.status === 'INACTIVE');
return (
<div className="page">
<div className="page-head">
<div>
<Link to="/" className="back-link">
← Alle Spieler
</Link>
<h1>{detail.player.displayName}</h1>
<p className="muted mono">
{detail.player.steamId ?? 'keine SteamID'}
{detail.player.forumName && ` · Forum: ${detail.player.forumName}`}
</p>
</div>
</div>
{error && <Notice kind="error" onDismiss={() => setError(null)}>{error}</Notice>}
{flash && <Notice kind="success" onDismiss={() => setFlash(null)}>{flash}</Notice>}
<BalanceCards balances={detail.balances} />
{editor && (
<section className="action-bar">
<button
type="button"
className="btn"
disabled={busy}
onClick={() =>
void run(
() => api(`/api/players/${playerId}/credits/cc-package`, { method: 'POST', body: {} }),
'CC-Paket eingetragen: +1 CC und +1 Redesign.',
)
}
>
+ CC-Paket
</button>
<button
type="button"
className="btn"
disabled={busy}
onClick={() =>
void run(
() =>
api(`/api/players/${playerId}/credits/redesign-package`, {
method: 'POST',
body: {},
}),
'Redesign-Paket eingetragen: +1 Redesign.',
)
}
>
+ Redesign-Paket
</button>
<button
type="button"
className="btn"
disabled={busy}
onClick={() =>
void run(
() => api(`/api/players/${playerId}/credits/kulanz`, { method: 'POST', body: {} }),
'Kulanz-Redesign eingetragen: +1 Kulanz.',
)
}
>
+ Kulanz
</button>
<button
type="button"
className="btn btn-primary"
disabled={busy}
onClick={() => setDialog({ type: 'newCharacter' })}
>
Neuer CC
</button>
</section>
)}
<section className="block">
<h2>Aktive CCs ({active.length})</h2>
{active.length === 0 ? (
<p className="muted">Keine aktiven CCs.</p>
) : (
<ul className="cc-list">
{active.map((character) => (
<li key={character.id} className="cc-item">
<div>
<strong>{character.name}</strong>
{character.predecessor && (
<span className="muted"> · Nachfolger von {character.predecessor.name}</span>
)}
{character.loreText && <p className="lore">{character.loreText}</p>}
<span className="muted small">Seit {formatDate(character.createdAt)}</span>
</div>
{editor && (
<div className="cc-actions">
<button
type="button"
className="btn btn-small"
onClick={() => setDialog({ type: 'redesign', character })}
>
Redesign
</button>
<button
type="button"
className="btn btn-small btn-ghost"
onClick={() => setDialog({ type: 'deactivate', character })}
>
Deaktivieren
</button>
<button
type="button"
className="btn btn-small btn-danger"
onClick={() => setDialog({ type: 'revert', character })}
>
Zuruecknehmen
</button>
</div>
)}
</li>
))}
</ul>
)}
</section>
<section className="block">
<h2>Inaktive CCs ({inactive.length})</h2>
{inactive.length === 0 ? (
<p className="muted">Keine inaktiven CCs.</p>
) : (
<ul className="cc-list">
{inactive.map((character) => {
const free = character.inactiveReason === 'PLAYER_INACTIVITY';
return (
<li key={character.id} className="cc-item is-inactive">
<div>
<strong>{character.name}</strong>
<span className={`badge ${free ? 'badge-free' : 'badge-paid'}`}>
{character.inactiveReason
? REASON_LABEL[character.inactiveReason]
: 'Unbekannt'}
</span>
{character.successors.length > 0 && (
<span className="muted">
{' '}
· Ersetzt durch {character.successors.map((s) => s.name).join(', ')}
</span>
)}
<p className="muted small">
{free
? 'Kommt gratis zurueck — kostet kein Guthaben.'
: 'Reaktivierung kostet ein Redesign- oder CC-Guthaben.'}
</p>
</div>
{editor && (
<div className="cc-actions">
{free ? (
<button
type="button"
className="btn btn-small btn-primary"
disabled={busy}
onClick={() =>
void run(
() =>
api(`/api/characters/${character.id}/reactivate-free`, {
method: 'POST',
body: {},
}),
`"${character.name}" ist wieder aktiv — kostenlos.`,
)
}
>
Gratis reaktivieren
</button>
) : (
<button
type="button"
className="btn btn-small"
onClick={() => setDialog({ type: 'reactivatePaid', character })}
>
Bezahlt reaktivieren
</button>
)}
</div>
)}
</li>
);
})}
</ul>
)}
</section>
<section className="block">
<h2>Guthaben-Historie</h2>
<div className="table-wrap">
<table className="table">
<thead>
<tr>
<th>Zeitpunkt</th>
<th>Topf</th>
<th className="num">Menge</th>
<th>Anlass</th>
<th>Notiz</th>
<th>Von</th>
<th />
</tr>
</thead>
<tbody>
{detail.ledger.map((entry) => (
<tr key={entry.id} className={entry.reversedBy ? 'is-reversed' : undefined}>
<td className="muted small">{formatDate(entry.createdAt)}</td>
<td>{KIND_LABEL[entry.kind]}</td>
<td className={`num ${entry.amount < 0 ? 'neg' : 'pos'}`}>
{formatAmount(entry.amount)}
</td>
<td>{SOURCE_LABEL[entry.source]}</td>
<td className="muted small">
{entry.note ?? '—'}
{entry.character && ` (${entry.character.name})`}
</td>
<td className="muted small">{entry.createdBy?.username ?? 'System'}</td>
<td className="num">
{editor &&
!entry.reversedBy &&
entry.source !== 'CONSUME' &&
entry.source !== 'REVERSAL' && (
<button
type="button"
className="btn btn-tiny btn-ghost"
onClick={() => setDialog({ type: 'reverse', entry })}
>
Storno
</button>
)}
{entry.reversedBy && <span className="muted small">storniert</span>}
</td>
</tr>
))}
{detail.ledger.length === 0 && (
<tr>
<td colSpan={7} className="empty">
Noch keine Buchungen.
</td>
</tr>
)}
</tbody>
</table>
</div>
{detail.ledgerTotal > detail.ledger.length && (
<p className="muted">
{detail.ledger.length} von {detail.ledgerTotal} Buchungen angezeigt.
</p>
)}
</section>
{dialog.type === 'newCharacter' && (
<CharacterFormModal
title="Neuen CC anlegen"
submitLabel="Anlegen"
hint="Kostet 1 CC-Guthaben. Kulanz-Guthaben ist dafuer nicht einsetzbar."
busy={busy}
onClose={() => 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' && (
<CharacterFormModal
title={`Redesign von "${dialog.character.name}"`}
submitLabel="Redesign durchfuehren"
hint={`"${dialog.character.name}" wird inaktiv (redesignt), der neue CC tritt an seine Stelle.`}
busy={busy}
withKind={['REDESIGN_KULANZ', 'REDESIGN_PAID']}
balances={detail.balances}
onClose={() => 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' && (
<DeactivateModal
character={dialog.character}
busy={busy}
onClose={() => 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' && (
<KindChoiceModal
title={`"${dialog.character.name}" reaktivieren`}
hint="Dieser CC existiert im Original nicht mehr. Die Reaktivierung kostet ein Guthaben."
kinds={['REDESIGN_KULANZ', 'REDESIGN_PAID', 'CC']}
balances={detail.balances}
busy={busy}
onClose={() => 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' && (
<ReasonModal
title={`"${dialog.character.name}" zuruecknehmen`}
hint="Der CC wird geloescht, verbrauchte Guthaben werden zurueckgebucht. Fuer Fehleintraege gedacht."
confirmLabel="Zuruecknehmen"
danger
busy={busy}
onClose={() => 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' && (
<ReasonModal
title="Buchung stornieren"
hint="Es wird eine Gegenbuchung erzeugt. Bei einem CC-Paket betrifft das beide Zeilen."
confirmLabel="Stornieren"
busy={busy}
onClose={() => setDialog({ type: 'none' })}
onSubmit={(reason) =>
void run(
() =>
api(`/api/credits/${dialog.entry.id}/reverse`, {
method: 'POST',
body: { reason },
}),
'Buchung storniert.',
)
}
/>
)}
</div>
);
}
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<CreditKind>(withKind?.[0] ?? 'CC');
return (
<Modal title={title} onClose={onClose}>
<form
onSubmit={(event) => {
event.preventDefault();
onSubmit({
name,
...(loreText.trim() ? { loreText: loreText.trim() } : {}),
...(withKind ? { useKind } : {}),
});
}}
>
<Notice kind="info">{hint}</Notice>
<label htmlFor="cc-name">Name des CCs</label>
<input
id="cc-name"
value={name}
onChange={(event) => setName(event.target.value)}
autoFocus
required
/>
<label htmlFor="cc-lore">Lore / Notiz (optional)</label>
<textarea
id="cc-lore"
rows={3}
value={loreText}
onChange={(event) => setLoreText(event.target.value)}
/>
{withKind && balances && (
<>
<label>Welches Guthaben soll verbraucht werden?</label>
<div className="radio-group">
{withKind.map((kind) => (
<label key={kind} className="radio">
<input
type="radio"
name="useKind"
value={kind}
checked={useKind === kind}
onChange={() => setUseKind(kind)}
/>
<span>
{KIND_LABEL[kind]}
<span className={balances[kind] < 1 ? 'neg' : 'muted'}>
{' '}
({balances[kind]} verfuegbar)
</span>
</span>
</label>
))}
</div>
</>
)}
<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 ? 'Speichere …' : submitLabel}
</button>
</div>
</form>
</Modal>
);
}
function KindChoiceModal({
title,
hint,
kinds,
balances,
busy,
onClose,
onSubmit,
}: {
title: string;
hint: string;
kinds: CreditKind[];
balances: { CC: number; REDESIGN_PAID: number; REDESIGN_KULANZ: number };
busy: boolean;
onClose: () => void;
onSubmit: (kind: CreditKind) => void;
}) {
const [useKind, setUseKind] = useState<CreditKind>(kinds[0] ?? 'CC');
return (
<Modal title={title} onClose={onClose}>
<form
onSubmit={(event) => {
event.preventDefault();
onSubmit(useKind);
}}
>
<Notice kind="info">{hint}</Notice>
<div className="radio-group">
{kinds.map((kind) => (
<label key={kind} className="radio">
<input
type="radio"
name="useKind"
value={kind}
checked={useKind === kind}
onChange={() => setUseKind(kind)}
/>
<span>
{KIND_LABEL[kind]}
<span className={balances[kind] < 1 ? 'neg' : 'muted'}>
{' '}
({balances[kind]} verfuegbar)
</span>
</span>
</label>
))}
</div>
<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 ? 'Speichere …' : 'Bestaetigen'}
</button>
</div>
</form>
</Modal>
);
}
function DeactivateModal({
character,
busy,
onClose,
onSubmit,
}: {
character: Character;
busy: boolean;
onClose: () => void;
onSubmit: (reason: 'PLAYER_INACTIVITY' | 'OTHER') => void;
}) {
const [reason, setReason] = useState<'PLAYER_INACTIVITY' | 'OTHER'>('PLAYER_INACTIVITY');
return (
<Modal title={`"${character.name}" deaktivieren`} onClose={onClose}>
<form
onSubmit={(event) => {
event.preventDefault();
onSubmit(reason);
}}
>
<Notice kind="info">
Der Grund entscheidet, ob die spaetere Reaktivierung kostenlos ist.
</Notice>
<div className="radio-group">
<label className="radio">
<input
type="radio"
name="reason"
checked={reason === 'PLAYER_INACTIVITY'}
onChange={() => setReason('PLAYER_INACTIVITY')}
/>
<span>
<strong>Spieler-Inaktivitaet</strong>
<span className="muted"> — vom Server geputzt, kommt gratis zurueck</span>
</span>
</label>
<label className="radio">
<input
type="radio"
name="reason"
checked={reason === 'OTHER'}
onChange={() => setReason('OTHER')}
/>
<span>
<strong>Sonstiger Grund</strong>
<span className="muted"> — Reaktivierung kostet ein Guthaben</span>
</span>
</label>
</div>
<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 ? 'Speichere …' : 'Deaktivieren'}
</button>
</div>
</form>
</Modal>
);
}
function ReasonModal({
title,
hint,
confirmLabel,
danger,
busy,
onClose,
onSubmit,
}: {
title: string;
hint: string;
confirmLabel: string;
danger?: boolean;
busy: boolean;
onClose: () => void;
onSubmit: (reason: string) => void;
}) {
const [reason, setReason] = useState('');
return (
<Modal title={title} onClose={onClose}>
<form
onSubmit={(event) => {
event.preventDefault();
onSubmit(reason);
}}
>
<Notice kind={danger ? 'warn' : 'info'}>{hint}</Notice>
<label htmlFor="reason">Begruendung</label>
<input
id="reason"
value={reason}
onChange={(event) => setReason(event.target.value)}
placeholder="z. B. versehentlich doppelt eingetragen"
autoFocus
required
/>
<div className="form-actions">
<button type="button" className="btn btn-ghost" onClick={onClose}>
Abbrechen
</button>
<button
type="submit"
className={`btn ${danger ? 'btn-danger' : 'btn-primary'}`}
disabled={busy}
>
{busy ? 'Speichere …' : confirmLabel}
</button>
</div>
</form>
</Modal>
);
}
+819
View File
@@ -0,0 +1,819 @@
:root {
--bg: #12141a;
--bg-raised: #1a1d26;
--bg-input: #21252f;
--border: #2c3140;
--border-strong: #3a4154;
--text: #e6e8ee;
--text-muted: #9099ad;
--accent: #5b8cff;
--accent-hover: #6d99ff;
--positive: #4ac28a;
--negative: #f2626b;
--warn: #e0a34a;
--radius: 8px;
--radius-lg: 12px;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
font-size: 15px;
line-height: 1.55;
}
h1 {
font-size: 1.6rem;
margin: 0 0 0.2rem;
}
h2 {
font-size: 1.15rem;
margin: 0 0 0.75rem;
}
h3 {
font-size: 1rem;
margin: 1.5rem 0 0.5rem;
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.boot {
padding: 3rem;
text-align: center;
color: var(--text-muted);
}
/* --- Rahmen --- */
.shell {
min-height: 100vh;
}
.topbar {
background: var(--bg-raised);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 10;
}
.topbar-inner {
max-width: 1200px;
margin: 0 auto;
padding: 0.7rem 1.5rem;
display: flex;
align-items: center;
gap: 1.5rem;
}
.brand {
font-weight: 700;
font-size: 1.05rem;
color: var(--text);
}
.brand:hover {
text-decoration: none;
color: var(--accent);
}
.nav {
display: flex;
gap: 0.35rem;
flex: 1;
}
.nav a {
padding: 0.35rem 0.75rem;
border-radius: var(--radius);
color: var(--text-muted);
font-weight: 500;
}
.nav a:hover {
background: var(--bg-input);
color: var(--text);
text-decoration: none;
}
.nav a.active {
background: var(--bg-input);
color: var(--text);
}
.topbar-right {
display: flex;
align-items: center;
gap: 0.6rem;
}
.user-chip {
display: flex;
align-items: center;
gap: 0.45rem;
color: var(--text-muted);
font-size: 0.9rem;
}
.user-chip:hover {
text-decoration: none;
color: var(--text);
}
.role-tag {
font-size: 0.7rem;
padding: 0.1rem 0.4rem;
border: 1px solid var(--border-strong);
border-radius: 4px;
letter-spacing: 0.03em;
}
.content {
max-width: 1200px;
margin: 0 auto;
padding: 1.75rem 1.5rem 4rem;
}
.page.narrow {
max-width: 620px;
}
.page-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1.25rem;
}
.back-link {
font-size: 0.85rem;
color: var(--text-muted);
}
/* --- Bausteine --- */
.block {
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.25rem;
margin-bottom: 1.25rem;
}
.block-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.block-head h2 {
margin: 0;
}
.muted {
color: var(--text-muted);
}
.small {
font-size: 0.85rem;
}
.mono {
font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
font-size: 0.85em;
}
.pos {
color: var(--positive);
}
.neg {
color: var(--negative);
}
.num {
text-align: right;
}
.link-strong {
font-weight: 600;
}
/* --- Guthaben --- */
.balances {
margin-bottom: 1.25rem;
}
.balance-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.85rem;
}
.balance-card {
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.1rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.balance-card.is-negative {
border-color: var(--negative);
background: color-mix(in srgb, var(--negative) 8%, var(--bg-raised));
}
.balance-label {
font-size: 0.85rem;
color: var(--text-muted);
}
.balance-value {
font-size: 2.4rem;
font-weight: 700;
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
.balance-card.is-negative .balance-value {
color: var(--negative);
}
.eligibility {
display: flex;
gap: 1.25rem;
margin-top: 0.75rem;
font-size: 0.9rem;
flex-wrap: wrap;
}
.eligibility .ok {
color: var(--positive);
}
.eligibility .no {
color: var(--text-muted);
}
.overdrawn-hint {
margin: 0.6rem 0 0;
font-size: 0.875rem;
color: var(--warn);
}
.mini-counter {
display: inline-block;
min-width: 1.9rem;
padding: 0.1rem 0.4rem;
border-radius: 5px;
background: var(--bg-input);
text-align: center;
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.mini-counter.is-negative {
background: color-mix(in srgb, var(--negative) 20%, transparent);
color: var(--negative);
}
/* --- CC-Listen --- */
.action-bar {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
margin-bottom: 1.25rem;
}
.cc-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.cc-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
padding: 0.85rem 1rem;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.cc-item.is-inactive {
opacity: 0.85;
border-style: dashed;
}
.cc-actions {
display: flex;
gap: 0.4rem;
flex-shrink: 0;
flex-wrap: wrap;
justify-content: flex-end;
}
.lore {
margin: 0.35rem 0;
font-size: 0.9rem;
color: var(--text-muted);
max-width: 60ch;
}
.badge {
display: inline-block;
margin-left: 0.5rem;
padding: 0.1rem 0.45rem;
border-radius: 4px;
font-size: 0.72rem;
font-weight: 600;
background: var(--bg);
border: 1px solid var(--border-strong);
color: var(--text-muted);
}
.badge-free {
border-color: var(--positive);
color: var(--positive);
}
.badge-paid {
border-color: var(--warn);
color: var(--warn);
}
.badge-new {
border-color: var(--accent);
color: var(--accent);
}
/* --- Tabellen --- */
.table-wrap {
overflow-x: auto;
border: 1px solid var(--border);
border-radius: var(--radius);
}
.table-wrap.scroll {
max-height: 60vh;
overflow-y: auto;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 0.925rem;
}
.table.small {
font-size: 0.85rem;
}
.table th {
text-align: left;
padding: 0.6rem 0.8rem;
background: var(--bg-input);
border-bottom: 1px solid var(--border);
font-weight: 600;
font-size: 0.82rem;
color: var(--text-muted);
position: sticky;
top: 0;
}
.table td {
padding: 0.6rem 0.8rem;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
.table tbody tr:last-child td {
border-bottom: none;
}
.table tbody tr:hover {
background: color-mix(in srgb, var(--accent) 5%, transparent);
}
.table .empty {
text-align: center;
color: var(--text-muted);
padding: 2rem;
}
.is-reversed td {
opacity: 0.45;
text-decoration: line-through;
}
.is-skipped td {
opacity: 0.5;
}
.row-actions {
display: flex;
gap: 0.3rem;
justify-content: flex-end;
}
.details {
max-width: 40ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* --- Formulare --- */
label {
display: block;
margin: 0.85rem 0 0.3rem;
font-size: 0.875rem;
font-weight: 500;
}
input[type='text'],
input[type='password'],
input[type='search'],
input:not([type]),
select,
textarea {
width: 100%;
padding: 0.5rem 0.7rem;
background: var(--bg-input);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
color: var(--text);
font: inherit;
font-size: 0.925rem;
}
input:focus,
select:focus,
textarea:focus {
outline: 2px solid var(--accent);
outline-offset: -1px;
border-color: transparent;
}
textarea {
resize: vertical;
}
.search {
margin-bottom: 1rem;
}
.field label {
margin-top: 0;
}
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.85rem;
margin-bottom: 0.85rem;
}
.inline-form {
display: flex;
gap: 0.6rem;
margin: 1rem 0;
}
.inline-form input {
flex: 1;
}
.inline-form select {
width: auto;
}
.checkbox,
.radio {
display: flex;
align-items: flex-start;
gap: 0.55rem;
margin: 0.5rem 0;
font-weight: 400;
cursor: pointer;
}
.checkbox input,
.radio input {
width: auto;
margin-top: 0.3rem;
}
.radio-group {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding: 0.6rem 0.8rem;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
margin-top: 1.25rem;
}
.file-drop {
display: block;
margin: 1rem 0 0;
padding: 2.5rem;
border: 2px dashed var(--border-strong);
border-radius: var(--radius-lg);
text-align: center;
cursor: pointer;
color: var(--text-muted);
}
.file-drop:hover {
border-color: var(--accent);
color: var(--text);
}
.file-drop input {
display: none;
}
.qr {
display: block;
margin: 1rem 0;
border-radius: var(--radius);
background: #fff;
padding: 0.5rem;
}
/* --- Buttons --- */
.btn {
padding: 0.5rem 0.9rem;
background: var(--bg-input);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
color: var(--text);
font: inherit;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
}
.btn:hover:not(:disabled) {
background: var(--border);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.btn-ghost {
background: transparent;
border-color: transparent;
color: var(--text-muted);
}
.btn-ghost:hover:not(:disabled) {
background: var(--bg-input);
color: var(--text);
}
.btn-danger {
background: transparent;
border-color: var(--negative);
color: var(--negative);
}
.btn-danger:hover:not(:disabled) {
background: color-mix(in srgb, var(--negative) 15%, transparent);
}
.btn-small {
padding: 0.3rem 0.65rem;
font-size: 0.85rem;
}
.btn-tiny {
padding: 0.2rem 0.5rem;
font-size: 0.78rem;
}
.btn-block {
width: 100%;
margin-top: 0.85rem;
}
/* --- Hinweise --- */
.notice {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
padding: 0.7rem 0.9rem;
border-radius: var(--radius);
margin: 0.85rem 0;
font-size: 0.9rem;
border: 1px solid;
}
.notice-error {
background: color-mix(in srgb, var(--negative) 12%, transparent);
border-color: var(--negative);
color: var(--negative);
}
.notice-success {
background: color-mix(in srgb, var(--positive) 12%, transparent);
border-color: var(--positive);
color: var(--positive);
}
.notice-warn {
background: color-mix(in srgb, var(--warn) 12%, transparent);
border-color: var(--warn);
color: var(--warn);
}
.notice-info {
background: var(--bg-input);
border-color: var(--border);
color: var(--text-muted);
}
.notice-close {
background: none;
border: none;
color: inherit;
cursor: pointer;
opacity: 0.7;
font-size: 0.85rem;
padding: 0;
}
/* --- Modal --- */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 3rem 1rem;
z-index: 100;
overflow-y: auto;
}
.modal {
background: var(--bg-raised);
border: 1px solid var(--border-strong);
border-radius: var(--radius-lg);
width: 100%;
max-width: 520px;
}
.modal-head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border);
}
.modal-head h2 {
margin: 0;
font-size: 1.05rem;
}
.modal-body {
padding: 0.5rem 1.25rem 1.25rem;
}
.modal-foot {
padding: 1rem 1.25rem;
border-top: 1px solid var(--border);
}
/* --- Tabs --- */
.tabs {
display: flex;
gap: 0.3rem;
margin-bottom: 1.25rem;
border-bottom: 1px solid var(--border);
}
.tab {
padding: 0.55rem 1rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-muted);
font: inherit;
font-weight: 500;
cursor: pointer;
}
.tab:hover {
color: var(--text);
}
.tab.is-active {
color: var(--text);
border-bottom-color: var(--accent);
}
/* --- Import-Zusammenfassung --- */
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.6rem;
margin: 1rem 0;
}
.summary-card {
padding: 0.75rem;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius);
text-align: center;
}
.summary-card.is-active {
border-color: var(--accent);
}
.summary-value {
display: block;
font-size: 1.6rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.summary-label {
display: block;
font-size: 0.78rem;
color: var(--text-muted);
}
/* --- Login --- */
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.login-card {
width: 100%;
max-width: 370px;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 2rem;
}
.login-card h1 {
text-align: center;
margin-bottom: 0.2rem;
}
.login-sub {
text-align: center;
color: var(--text-muted);
margin: 0 0 1.25rem;
font-size: 0.9rem;
}
/* --- Schmale Viewports --- */
@media (max-width: 820px) {
.balance-grid {
grid-template-columns: 1fr;
}
.grid-2 {
grid-template-columns: 1fr;
}
.topbar-inner {
flex-wrap: wrap;
gap: 0.75rem;
}
.cc-item {
flex-direction: column;
}
.cc-actions {
justify-content: flex-start;
}
.content {
padding: 1.25rem 1rem 3rem;
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
// Landet im Server-Image neben dem kompilierten Backend.
outDir: '../server/public',
emptyOutDir: true,
sourcemap: false,
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: false,
},
},
},
});