Compare commits
8
Commits
638a88f0de
...
3ec0ff4df1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ec0ff4df1 | ||
|
|
b988a82765 | ||
|
|
245666d363 | ||
|
|
a7228e63d1 | ||
|
|
f3e31cdd17 | ||
|
|
3d2b87297e | ||
|
|
a1b764155a | ||
|
|
d7e7a3b186 |
@@ -0,0 +1,14 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
**/dist
|
||||
server/public
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
**/*.log
|
||||
coverage
|
||||
.idea
|
||||
.vscode
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -1,3 +1,178 @@
|
||||
# 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).
|
||||
|
||||
@@ -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:
|
||||
@@ -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 "$@"
|
||||
Generated
+5059
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 } },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 } } },
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user