Umlaute statt Umschreibungen
Oberflaeche, Fehlermeldungen und Kommentare schrieben durchgehend ae, oe, ue und ss. Dafuer gab es keinen Grund - die Anwendung ist deutschsprachig und laeuft durchgehend in UTF-8. Betroffen waren 55 Dateien, darunter alle sichtbaren Texte: Guthaben-Zaehler, Import, Nutzerverwaltung, Passwortwechsel und die Zwei-Faktor-Einrichtung. Der Name einer Migration bleibt im README-Beispiel bewusst bei ASCII, weil daraus ein Verzeichnisname wird.
This commit is contained in:
+8
-8
@@ -1,32 +1,32 @@
|
|||||||
# Kopie als .env neben die compose.yaml legen und Werte ersetzen.
|
# Kopie als .env neben die compose.yaml legen und Werte ersetzen.
|
||||||
|
|
||||||
# Passwort des Datenbank-Nutzers. Lang und zufaellig waehlen.
|
# Passwort des Datenbank-Nutzers. Lang und zufällig wählen.
|
||||||
# Achtung: Ein $ im Wert wird von Docker Compose als Variable gedeutet und
|
# Achtung: Ein $ im Wert wird von Docker Compose als Variable gedeutet und
|
||||||
# verschluckt. Entweder $ vermeiden oder als $$ schreiben.
|
# verschluckt. Entweder $ vermeiden oder als $$ schreiben.
|
||||||
DB_PASSWORD=hier-ein-langes-zufaelliges-passwort
|
DB_PASSWORD=hier-ein-langes-zufälliges-passwort
|
||||||
|
|
||||||
# Schluessel fuer Sitzungen und CSRF-Token, mindestens 32 Zeichen.
|
# Schlüssel für Sitzungen und CSRF-Token, mindestens 32 Zeichen.
|
||||||
# Erzeugen z. B. mit: openssl rand -base64 48
|
# Erzeugen z. B. mit: openssl rand -base64 48
|
||||||
SESSION_SECRET=hier-einen-langen-zufallsstring-einsetzen
|
SESSION_SECRET=hier-einen-langen-zufallsstring-einsetzen
|
||||||
|
|
||||||
# Erster Administrator, wird nur angelegt solange noch kein Konto existiert.
|
# Erster Administrator, wird nur angelegt solange noch kein Konto existiert.
|
||||||
# Bleibt das Passwort leer, erzeugt die App eines und schreibt es einmalig
|
# 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.
|
# ins Container-Log. In beiden Fällen ist ein Wechsel beim ersten Login Pflicht.
|
||||||
BOOTSTRAP_ADMIN_USER=admin
|
BOOTSTRAP_ADMIN_USER=admin
|
||||||
BOOTSTRAP_ADMIN_PASSWORD=
|
BOOTSTRAP_ADMIN_PASSWORD=
|
||||||
|
|
||||||
# --- Optional ---
|
# --- Optional ---
|
||||||
|
|
||||||
# Name des Docker-Netzwerks, in dem der Reverse Proxy laeuft. Der Proxy legt es
|
# Name des Docker-Netzwerks, in dem der Reverse Proxy läuft. Der Proxy legt es
|
||||||
# an; bei Nginx Proxy Manager heisst es meist nginx_default.
|
# an; bei Nginx Proxy Manager heisst es meist nginx_default.
|
||||||
# PROXY_NETWORK=nginx_default
|
# PROXY_NETWORK=nginx_default
|
||||||
|
|
||||||
# Adresse der GExtension-Instanz fuer den Paket-Abgleich. Der Parser ist auf
|
# Adresse der GExtension-Instanz für den Paket-Abgleich. Der Parser ist auf
|
||||||
# deren Seitenaufbau gebaut; ein anderes Shop-System laesst sich damit nicht
|
# deren Seitenaufbau gebaut; ein anderes Shop-System lässt sich damit nicht
|
||||||
# auslesen. Leer lassen, wenn der Abgleich nicht genutzt wird.
|
# auslesen. Leer lassen, wenn der Abgleich nicht genutzt wird.
|
||||||
# GEXTENSION_BASE_URL=https://gextension.senseigaming.de
|
# GEXTENSION_BASE_URL=https://gextension.senseigaming.de
|
||||||
|
|
||||||
# Aufbewahrung der Audit-Eintraege in Tagen (Standard: 365, 0 = nie loeschen).
|
# Aufbewahrung der Audit-Einträge in Tagen (Standard: 365, 0 = nie löschen).
|
||||||
# AUDIT_RETENTION_DAYS=365
|
# AUDIT_RETENTION_DAYS=365
|
||||||
|
|
||||||
# Gueltigkeit einer Sitzung in Stunden (Standard: 12).
|
# Gueltigkeit einer Sitzung in Stunden (Standard: 12).
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
# --- Build ---
|
# --- Build ---
|
||||||
FROM node:22-slim AS build
|
FROM node:22-slim AS build
|
||||||
|
|
||||||
# Prisma benoetigt OpenSSL fuer seine Query-Engine.
|
# Prisma benötigt OpenSSL für seine Query-Engine.
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends openssl \
|
RUN apt-get update && apt-get install -y --no-install-recommends openssl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|||||||
@@ -223,9 +223,11 @@ Image: Fastify liefert die API und die fertigen Frontend-Dateien aus.
|
|||||||
Nach Änderungen am Datenmodell:
|
Nach Änderungen am Datenmodell:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run migrate:dev -- --name beschreibung-der-aenderung
|
npm run migrate:dev -- --name kurze-beschreibung
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Der Name wird zum Verzeichnisnamen der Migration — dort also besser bei ASCII bleiben.
|
||||||
|
|
||||||
## Sicherheit
|
## Sicherheit
|
||||||
|
|
||||||
- Passwörter mit Argon2id gehasht, Mindestlänge 12 Zeichen.
|
- Passwörter mit Argon2id gehasht, Mindestlänge 12 Zeichen.
|
||||||
|
|||||||
+2
-2
@@ -12,7 +12,7 @@ services:
|
|||||||
BOOTSTRAP_ADMIN_USER: ${BOOTSTRAP_ADMIN_USER:-admin}
|
BOOTSTRAP_ADMIN_USER: ${BOOTSTRAP_ADMIN_USER:-admin}
|
||||||
BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-}
|
BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-}
|
||||||
TRUST_PROXY: "true"
|
TRUST_PROXY: "true"
|
||||||
# Nur noetig, wenn der GExtension-Abgleich eine andere Instanz ansprechen soll.
|
# Nur nötig, wenn der GExtension-Abgleich eine andere Instanz ansprechen soll.
|
||||||
GEXTENSION_BASE_URL: ${GEXTENSION_BASE_URL:-https://gextension.senseigaming.de}
|
GEXTENSION_BASE_URL: ${GEXTENSION_BASE_URL:-https://gextension.senseigaming.de}
|
||||||
AUDIT_RETENTION_DAYS: ${AUDIT_RETENTION_DAYS:-365}
|
AUDIT_RETENTION_DAYS: ${AUDIT_RETENTION_DAYS:-365}
|
||||||
expose:
|
expose:
|
||||||
@@ -66,7 +66,7 @@ networks:
|
|||||||
#
|
#
|
||||||
# Ohne Reverse Proxy in Docker: diesen Block und die networks-Liste beim
|
# Ohne Reverse Proxy in Docker: diesen Block und die networks-Liste beim
|
||||||
# app-Service entfernen und stattdessen einen Port mappen (ports: "3000:3000").
|
# app-Service entfernen und stattdessen einen Port mappen (ports: "3000:3000").
|
||||||
# Dann aber selbst fuer HTTPS sorgen - die App gehoert nicht ungeschuetzt ins Netz.
|
# Dann aber selbst für HTTPS sorgen - die App gehört nicht ungeschuetzt ins Netz.
|
||||||
proxy:
|
proxy:
|
||||||
external: true
|
external: true
|
||||||
name: ${PROXY_NETWORK:-nginx_default}
|
name: ${PROXY_NETWORK:-nginx_default}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Schema aktuell halten, bevor die App startet. Bereits angewandte
|
# Schema aktuell halten, bevor die App startet. Bereits angewandte
|
||||||
# Migrationen werden dabei uebersprungen.
|
# Migrationen werden dabei übersprungen.
|
||||||
echo "Wende Datenbank-Migrationen an ..."
|
echo "Wende Datenbank-Migrationen an ..."
|
||||||
npx prisma migrate deploy --schema server/prisma/schema.prisma
|
npx prisma migrate deploy --schema server/prisma/schema.prisma
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
# Nur fuer die lokale Entwicklung. Als server/.env kopieren.
|
# Nur für die lokale Entwicklung. Als server/.env kopieren.
|
||||||
# Im Container kommen diese Werte aus der compose.yaml.
|
# Im Container kommen diese Werte aus der compose.yaml.
|
||||||
|
|
||||||
DATABASE_URL=postgresql://ccuser:passwort@localhost:5432/ccmanager
|
DATABASE_URL=postgresql://ccuser:passwort@localhost:5432/ccmanager
|
||||||
@@ -7,7 +7,7 @@ SESSION_SECRET=lokaler-entwicklungsschluessel-mindestens-32-zeichen
|
|||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
# Ohne HTTPS muessen die Cookies unsicher ausgeliefert werden duerfen.
|
# Ohne HTTPS müssen die Cookies unsicher ausgeliefert werden dürfen.
|
||||||
COOKIE_SECURE=false
|
COOKIE_SECURE=false
|
||||||
|
|
||||||
# Erstadministrator beim ersten Start.
|
# Erstadministrator beim ersten Start.
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ model Player {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
/// Von GExtension gemeldeter Verbrauchszaehler. Reine Anzeige zum Abgleich,
|
/// Von GExtension gemeldeter Verbrauchszähler. Reine Anzeige zum Abgleich,
|
||||||
/// wird nie automatisch mit dem Ledger verrechnet.
|
/// wird nie automatisch mit dem Ledger verrechnet.
|
||||||
gextensionUsage Int?
|
gextensionUsage Int?
|
||||||
gextensionSyncedAt DateTime?
|
gextensionSyncedAt DateTime?
|
||||||
@@ -110,7 +110,7 @@ model Player {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Pakete, deren Titel nach CC oder Redesign klingt, sich aber keiner
|
/// Pakete, deren Titel nach CC oder Redesign klingt, sich aber keiner
|
||||||
/// Guthaben-Art sicher zuordnen laesst. Wird nie automatisch gebucht,
|
/// Guthaben-Art sicher zuordnen lässt. Wird nie automatisch gebucht,
|
||||||
/// sondern im CC-Manager von Hand entschieden.
|
/// sondern im CC-Manager von Hand entschieden.
|
||||||
model UnmatchedPackage {
|
model UnmatchedPackage {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
@@ -146,7 +146,7 @@ model CreditLedger {
|
|||||||
source CreditSource
|
source CreditSource
|
||||||
|
|
||||||
/// Klammert zusammengehoerige Zeilen: ein CC-Paket schreibt zwei Zeilen
|
/// Klammert zusammengehoerige Zeilen: ein CC-Paket schreibt zwei Zeilen
|
||||||
/// mit derselben groupId, damit ein Storno beide zurueckholt.
|
/// mit derselben groupId, damit ein Storno beide zurückholt.
|
||||||
groupId String @default(uuid())
|
groupId String @default(uuid())
|
||||||
|
|
||||||
characterId Int?
|
characterId Int?
|
||||||
@@ -157,7 +157,7 @@ model CreditLedger {
|
|||||||
|
|
||||||
note String?
|
note String?
|
||||||
|
|
||||||
/// Storno erfolgt als Gegenbuchung, nicht als Loeschung. Das @unique
|
/// Storno erfolgt als Gegenbuchung, nicht als Löschung. Das @unique
|
||||||
/// verhindert, dass eine Zeile zweimal storniert wird.
|
/// verhindert, dass eine Zeile zweimal storniert wird.
|
||||||
reversalOfId Int? @unique
|
reversalOfId Int? @unique
|
||||||
reversalOf CreditLedger? @relation("LedgerReversal", fields: [reversalOfId], references: [id], onDelete: NoAction, onUpdate: NoAction)
|
reversalOf CreditLedger? @relation("LedgerReversal", fields: [reversalOfId], references: [id], onDelete: NoAction, onUpdate: NoAction)
|
||||||
@@ -181,7 +181,7 @@ model Character {
|
|||||||
loreText String?
|
loreText String?
|
||||||
status CharacterStatus @default(ACTIVE)
|
status CharacterStatus @default(ACTIVE)
|
||||||
inactiveReason InactiveReason?
|
inactiveReason InactiveReason?
|
||||||
/// Klartext zum Grund, etwa "Einheit bis Maerz entfernt". Nur zur Anzeige.
|
/// Klartext zum Grund, etwa "Einheit bis März entfernt". Nur zur Anzeige.
|
||||||
inactiveNote String?
|
inactiveNote String?
|
||||||
|
|
||||||
/// Alle CCs einer Redesign-Kette teilen sich die lineageId.
|
/// Alle CCs einer Redesign-Kette teilen sich die lineageId.
|
||||||
@@ -237,7 +237,7 @@ model ImportRow {
|
|||||||
target ProductTarget?
|
target ProductTarget?
|
||||||
resolvedPlayerId Int?
|
resolvedPlayerId Int?
|
||||||
|
|
||||||
/// Was beim Buchen passieren wuerde: BOOK, CREATE_PLAYER_AND_BOOK,
|
/// Was beim Buchen passieren würde: BOOK, CREATE_PLAYER_AND_BOOK,
|
||||||
/// SKIP_DUPLICATE, SKIP_IGNORED, SKIP_UNMAPPED, SKIP_NO_PLAYER
|
/// SKIP_DUPLICATE, SKIP_IGNORED, SKIP_UNMAPPED, SKIP_NO_PLAYER
|
||||||
plan String
|
plan String
|
||||||
raw Json
|
raw Json
|
||||||
|
|||||||
+5
-5
@@ -106,7 +106,7 @@ export async function buildApp(): Promise<FastifyInstance> {
|
|||||||
if (statusCode !== undefined && statusCode >= 400 && statusCode < 500) {
|
if (statusCode !== undefined && statusCode >= 400 && statusCode < 500) {
|
||||||
reply
|
reply
|
||||||
.code(statusCode)
|
.code(statusCode)
|
||||||
.send({ error: 'BAD_REQUEST', message: readMessage(error) ?? 'Ungueltige Anfrage' });
|
.send({ error: 'BAD_REQUEST', message: readMessage(error) ?? 'Ungültige Anfrage' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,17 +128,17 @@ export async function buildApp(): Promise<FastifyInstance> {
|
|||||||
await app.register(fastifyStatic, {
|
await app.register(fastifyStatic, {
|
||||||
root: webRoot,
|
root: webRoot,
|
||||||
prefix: '/',
|
prefix: '/',
|
||||||
// maxAge wuerde den Header fuer *alle* Dateien setzen und dabei auch die
|
// maxAge würde den Header für *alle* Dateien setzen und dabei auch die
|
||||||
// index.html erwischen - die muss aber bei jedem Aufruf neu geholt werden,
|
// index.html erwischen - die muss aber bei jedem Aufruf neu geholt werden,
|
||||||
// sonst zeigt der Browser nach einem Update monatelang die alte Oberflaeche.
|
// sonst zeigt der Browser nach einem Update monatelang die alte Oberfläche.
|
||||||
// Deshalb hier abschalten und beides unten selbst setzen.
|
// Deshalb hier abschalten und beides unten selbst setzen.
|
||||||
cacheControl: false,
|
cacheControl: false,
|
||||||
setHeaders: (res, path) => {
|
setHeaders: (res, path) => {
|
||||||
if (path.endsWith('.html')) {
|
if (path.endsWith('.html')) {
|
||||||
res.setHeader('Cache-Control', 'no-cache, must-revalidate');
|
res.setHeader('Cache-Control', 'no-cache, must-revalidate');
|
||||||
} else {
|
} else {
|
||||||
// Assets tragen einen Inhalts-Hash im Namen: aendert sich der Inhalt,
|
// Assets tragen einen Inhalts-Hash im Namen: ändert sich der Inhalt,
|
||||||
// aendert sich der Dateiname. Die duerfen beliebig lange liegen.
|
// ändert sich der Dateiname. Die dürfen beliebig lange liegen.
|
||||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const RANK: Record<Role, number> = { VIEWER: 1, EDITOR: 2, ADMIN: 3 };
|
|||||||
|
|
||||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||||
|
|
||||||
// Ohne diese Ausnahme koennte sich niemand anmelden: vor dem Login gibt es
|
// Ohne diese Ausnahme könnte sich niemand anmelden: vor dem Login gibt es
|
||||||
// weder Session noch ableitbares CSRF-Token.
|
// weder Session noch ableitbares CSRF-Token.
|
||||||
const CSRF_EXEMPT = new Set(['/api/auth/login', '/api/auth/login/totp']);
|
const CSRF_EXEMPT = new Set(['/api/auth/login', '/api/auth/login/totp']);
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ export const blockUntilPasswordChanged: preHandlerHookHandler = async (request)
|
|||||||
if (request.user?.mustChangePassword && !SAFE_METHODS.has(request.method)) {
|
if (request.user?.mustChangePassword && !SAFE_METHODS.has(request.method)) {
|
||||||
const path = request.url.split('?')[0] ?? '';
|
const path = request.url.split('?')[0] ?? '';
|
||||||
if (path !== '/api/account/password' && path !== '/api/auth/logout') {
|
if (path !== '/api/account/password' && path !== '/api/auth/logout') {
|
||||||
throw forbidden('Bitte zuerst das Passwort aendern');
|
throw forbidden('Bitte zuerst das Passwort ändern');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { hash, verify } from '@node-rs/argon2';
|
import { hash, verify } from '@node-rs/argon2';
|
||||||
|
|
||||||
// OWASP-Empfehlung fuer Argon2id: 19 MiB, 2 Durchlaeufe, Parallelitaet 1.
|
// OWASP-Empfehlung für Argon2id: 19 MiB, 2 Durchläufe, Parallelität 1.
|
||||||
const options = {
|
const options = {
|
||||||
memoryCost: 19456,
|
memoryCost: 19456,
|
||||||
timeCost: 2,
|
timeCost: 2,
|
||||||
@@ -27,7 +27,7 @@ export function validatePasswordStrength(plain: string): string | null {
|
|||||||
return `Passwort muss mindestens ${MIN_LENGTH} Zeichen lang sein`;
|
return `Passwort muss mindestens ${MIN_LENGTH} Zeichen lang sein`;
|
||||||
}
|
}
|
||||||
if (!/[a-z]/.test(plain) || !/[A-Z]/.test(plain)) {
|
if (!/[a-z]/.test(plain) || !/[A-Z]/.test(plain)) {
|
||||||
return 'Passwort muss Gross- und Kleinbuchstaben enthalten';
|
return 'Passwort muss Groß- und Kleinbuchstaben enthalten';
|
||||||
}
|
}
|
||||||
if (!/[0-9]/.test(plain)) {
|
if (!/[0-9]/.test(plain)) {
|
||||||
return 'Passwort muss mindestens eine Ziffer enthalten';
|
return 'Passwort muss mindestens eine Ziffer enthalten';
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export async function destroyAllSessionsFor(userId: number): Promise<void> {
|
|||||||
await prisma.session.deleteMany({ where: { userId } });
|
await prisma.session.deleteMany({ where: { userId } });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Beendet alle Sitzungen des Nutzers ausser der gerade verwendeten. */
|
/** Beendet alle Sitzungen des Nutzers außer der gerade verwendeten. */
|
||||||
export async function destroyOtherSessionsFor(userId: number, keepToken: string): Promise<void> {
|
export async function destroyOtherSessionsFor(userId: number, keepToken: string): Promise<void> {
|
||||||
await prisma.session.deleteMany({
|
await prisma.session.deleteMany({
|
||||||
where: { userId, NOT: { tokenHash: hashToken(keepToken) } },
|
where: { userId, NOT: { tokenHash: hashToken(keepToken) } },
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import QRCode from 'qrcode';
|
|||||||
|
|
||||||
const ISSUER = 'CC-Manager';
|
const ISSUER = 'CC-Manager';
|
||||||
|
|
||||||
// Toleranz in Sekunden. Ein Zeitschritt in beide Richtungen faengt leicht
|
// Toleranz in Sekunden. Ein Zeitschritt in beide Richtungen fängt leicht
|
||||||
// abweichende Uhren zwischen Server und Authenticator-App ab.
|
// abweichende Uhren zwischen Server und Authenticator-App ab.
|
||||||
const EPOCH_TOLERANCE_SECONDS = 30;
|
const EPOCH_TOLERANCE_SECONDS = 30;
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import { prisma } from './db.js';
|
|||||||
import { env } from './env.js';
|
import { env } from './env.js';
|
||||||
import { hashPassword } from './auth/password.js';
|
import { hashPassword } from './auth/password.js';
|
||||||
|
|
||||||
// Startpunkte fuer die Produkterkennung beim Import. Jederzeit im Admin-Bereich
|
// Startpunkte für die Produkterkennung beim Import. Jederzeit im Admin-Bereich
|
||||||
// anpassbar, sobald die echten Produktnamen bekannt sind.
|
// anpassbar, sobald die echten Produktnamen bekannt sind.
|
||||||
// Reihenfolge ist entscheidend: "Custom Character Redesign" enthaelt beide
|
// Reihenfolge ist entscheidend: "Custom Character Redesign" enthält beide
|
||||||
// Stichworte und muss als Redesign zaehlen, nicht als CC-Paket. Deshalb steht
|
// Stichworte und muss als Redesign zählen, nicht als CC-Paket. Deshalb steht
|
||||||
// das Redesign-Muster vorn.
|
// das Redesign-Muster vorn.
|
||||||
const DEFAULT_MAPPINGS = [
|
const DEFAULT_MAPPINGS = [
|
||||||
{ matchPattern: 'redesign', target: ProductTarget.REDESIGN_PACKAGE, priority: 5 },
|
{ matchPattern: 'redesign', target: ProductTarget.REDESIGN_PACKAGE, priority: 5 },
|
||||||
@@ -52,7 +52,7 @@ export async function bootstrap(log: {
|
|||||||
if (!env.BOOTSTRAP_ADMIN_PASSWORD) {
|
if (!env.BOOTSTRAP_ADMIN_PASSWORD) {
|
||||||
log.warn(`Einmal-Passwort: ${password}`);
|
log.warn(`Einmal-Passwort: ${password}`);
|
||||||
}
|
}
|
||||||
log.warn('Das Passwort muss bei der ersten Anmeldung geaendert werden.');
|
log.warn('Das Passwort muss bei der ersten Anmeldung geändert werden.');
|
||||||
log.warn('─'.repeat(60));
|
log.warn('─'.repeat(60));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-5
@@ -15,23 +15,23 @@ const schema = z.object({
|
|||||||
BOOTSTRAP_ADMIN_USER: z.string().min(3).default('admin'),
|
BOOTSTRAP_ADMIN_USER: z.string().min(3).default('admin'),
|
||||||
BOOTSTRAP_ADMIN_PASSWORD: z.string().min(12).optional(),
|
BOOTSTRAP_ADMIN_PASSWORD: z.string().min(12).optional(),
|
||||||
|
|
||||||
// Hinter einem Reverse Proxy noetig, damit Client-IPs im Audit-Log stimmen.
|
// Hinter einem Reverse Proxy nötig, damit Client-IPs im Audit-Log stimmen.
|
||||||
TRUST_PROXY: z
|
TRUST_PROXY: z
|
||||||
.string()
|
.string()
|
||||||
.default('true')
|
.default('true')
|
||||||
.transform((v) => v === 'true' || v === '1'),
|
.transform((v) => v === 'true' || v === '1'),
|
||||||
|
|
||||||
// Muss in Produktion true sein (Cookies nur ueber HTTPS).
|
// Muss in Produktion true sein (Cookies nur über HTTPS).
|
||||||
COOKIE_SECURE: z
|
COOKIE_SECURE: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.transform((v) => (v === undefined ? undefined : v === 'true' || v === '1')),
|
.transform((v) => (v === undefined ? undefined : v === 'true' || v === '1')),
|
||||||
|
|
||||||
// Basis-Adresse der GExtension-Instanz. Der Parser ist auf deren Aufbau
|
// Basis-Adresse der GExtension-Instanz. Der Parser ist auf deren Aufbau
|
||||||
// zugeschnitten; eine voellig andere Software liesse sich damit nicht lesen.
|
// zugeschnitten; eine völlig andere Software ließe sich damit nicht lesen.
|
||||||
GEXTENSION_BASE_URL: z.string().url().default('https://gextension.senseigaming.de'),
|
GEXTENSION_BASE_URL: z.string().url().default('https://gextension.senseigaming.de'),
|
||||||
|
|
||||||
// Aufbewahrung der Audit-Eintraege in Tagen. 0 schaltet das Aufraeumen ab.
|
// Aufbewahrung der Audit-Einträge in Tagen. 0 schaltet das Aufräumen ab.
|
||||||
// Die Guthaben-Historie ist davon nicht betroffen, die bleibt dauerhaft.
|
// Die Guthaben-Historie ist davon nicht betroffen, die bleibt dauerhaft.
|
||||||
AUDIT_RETENTION_DAYS: z.coerce.number().int().min(0).default(365),
|
AUDIT_RETENTION_DAYS: z.coerce.number().int().min(0).default(365),
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ const parsed = schema.safeParse(process.env);
|
|||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const issues = parsed.error.issues.map((i) => ` - ${i.path.join('.')}: ${i.message}`).join('\n');
|
const issues = parsed.error.issues.map((i) => ` - ${i.path.join('.')}: ${i.message}`).join('\n');
|
||||||
console.error(`Konfiguration unvollstaendig:\n${issues}`);
|
console.error(`Konfiguration unvollständig:\n${issues}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export class AppError extends Error {
|
|||||||
export const badRequest = (code: string, message: string) => new AppError(400, code, message);
|
export const badRequest = (code: string, message: string) => new AppError(400, code, message);
|
||||||
export const unauthorized = (message = 'Nicht angemeldet') =>
|
export const unauthorized = (message = 'Nicht angemeldet') =>
|
||||||
new AppError(401, 'UNAUTHORIZED', message);
|
new AppError(401, 'UNAUTHORIZED', message);
|
||||||
export const forbidden = (message = 'Keine Berechtigung fuer diese Aktion') =>
|
export const forbidden = (message = 'Keine Berechtigung für diese Aktion') =>
|
||||||
new AppError(403, 'FORBIDDEN', message);
|
new AppError(403, 'FORBIDDEN', message);
|
||||||
export const notFound = (what: string) => new AppError(404, 'NOT_FOUND', `${what} nicht gefunden`);
|
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);
|
export const conflict = (code: string, message: string) => new AppError(409, code, message);
|
||||||
|
|||||||
+3
-3
@@ -27,13 +27,13 @@ async function main(): Promise<void> {
|
|||||||
.then((count) => {
|
.then((count) => {
|
||||||
if (count > 0) app.log.debug(`${count} abgelaufene Sitzungen entfernt`);
|
if (count > 0) app.log.debug(`${count} abgelaufene Sitzungen entfernt`);
|
||||||
})
|
})
|
||||||
.catch((error) => app.log.warn({ err: error }, 'Aufraeumen der Sitzungen fehlgeschlagen'));
|
.catch((error) => app.log.warn({ err: error }, 'Aufräumen der Sitzungen fehlgeschlagen'));
|
||||||
|
|
||||||
cleanupOldAuditEntries()
|
cleanupOldAuditEntries()
|
||||||
.then((count) => {
|
.then((count) => {
|
||||||
if (count > 0) app.log.info(`${count} abgelaufene Audit-Eintraege entfernt`);
|
if (count > 0) app.log.info(`${count} abgelaufene Audit-Einträge entfernt`);
|
||||||
})
|
})
|
||||||
.catch((error) => app.log.warn({ err: error }, 'Aufraeumen des Audit-Logs fehlgeschlagen'));
|
.catch((error) => app.log.warn({ err: error }, 'Aufräumen des Audit-Logs fehlgeschlagen'));
|
||||||
}, CLEANUP_INTERVAL_MS);
|
}, CLEANUP_INTERVAL_MS);
|
||||||
cleanup.unref();
|
cleanup.unref();
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { normalizeSteamId } from './steamid.js';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Liest die Paket-Liste eines GExtension-Profils aus. Die Seite bettet die
|
* Liest die Paket-Liste eines GExtension-Profils aus. Die Seite bettet die
|
||||||
* Kaeufe als donations_data-Zuweisungen ins HTML ein, also wird das JSON
|
* Käufe als donations_data-Zuweisungen ins HTML ein, also wird das JSON
|
||||||
* gelesen statt die gerenderte Tabelle geparst - das ist deutlich robuster
|
* gelesen statt die gerenderte Tabelle geparst - das ist deutlich robuster
|
||||||
* gegenueber Layout-Aenderungen und liefert zusaetzlich Kaufdatum, Betrag
|
* gegenüber Layout-Änderungen und liefert zusätzlich Kaufdatum, Betrag
|
||||||
* und den Storno-Status.
|
* und den Storno-Status.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -91,11 +91,11 @@ export type Classification =
|
|||||||
| { kind: 'unclear' }
|
| { kind: 'unclear' }
|
||||||
| { kind: 'ignore' };
|
| { kind: 'ignore' };
|
||||||
|
|
||||||
// Nur der SCP-RP-Bereich fuehrt CC-relevante Pakete.
|
// Nur der SCP-RP-Bereich führt CC-relevante Pakete.
|
||||||
const RELEVANT_BUNDLE = 'scp-rp';
|
const RELEVANT_BUNDLE = 'scp-rp';
|
||||||
|
|
||||||
// Faengt Titel ab, die offensichtlich mit CCs zu tun haben, aber von keiner
|
// Fängt Titel ab, die offensichtlich mit CCs zu tun haben, aber von keiner
|
||||||
// Regel sicher zugeordnet werden - die gehen in die Handpruefung statt geraten
|
// Regel sicher zugeordnet werden - die gehen in die Handprüfung statt geraten
|
||||||
// zu werden.
|
// zu werden.
|
||||||
const CC_HINT = /(^|\W)(cc|custom\s*charact|custom\s*charakt|redesign)/i;
|
const CC_HINT = /(^|\W)(cc|custom\s*charact|custom\s*charakt|redesign)/i;
|
||||||
|
|
||||||
@@ -104,8 +104,8 @@ const USAGE_TIMES = /\|\s*(\d+)\s*mal/i;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Ordnet einen Paket-Titel einer Guthaben-Art zu. Die Reihenfolge ist
|
* Ordnet einen Paket-Titel einer Guthaben-Art zu. Die Reihenfolge ist
|
||||||
* bewusst: "Custom Character Redesign" enthaelt beide Stichworte und muss als
|
* bewusst: "Custom Character Redesign" enthält beide Stichworte und muss als
|
||||||
* Redesign zaehlen, nicht als CC-Paket.
|
* Redesign zählen, nicht als CC-Paket.
|
||||||
*/
|
*/
|
||||||
export function classifyPackage(pkg: ScrapedPackage): Classification {
|
export function classifyPackage(pkg: ScrapedPackage): Classification {
|
||||||
if (pkg.revoked) return { kind: 'ignore' };
|
if (pkg.revoked) return { kind: 'ignore' };
|
||||||
@@ -132,11 +132,11 @@ export function classifyPackage(pkg: ScrapedPackage): Classification {
|
|||||||
return { kind: 'ignore' };
|
return { kind: 'ignore' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Standard ist die Instanz, fuer die der Parser gebaut wurde. Laeuft GExtension
|
// Standard ist die Instanz, für die der Parser gebaut wurde. Läuft GExtension
|
||||||
// bei einer anderen Community unter eigener Domain, reicht ein anderer Wert.
|
// bei einer anderen Community unter eigener Domain, reicht ein anderer Wert.
|
||||||
export const DEFAULT_BASE_URL = 'https://gextension.senseigaming.de';
|
export const DEFAULT_BASE_URL = 'https://gextension.senseigaming.de';
|
||||||
|
|
||||||
// Gemeinsam genutzte Community-Infrastruktur: hoechstens eine Anfrage pro Sekunde.
|
// Gemeinsam genutzte Community-Infrastruktur: höchstens eine Anfrage pro Sekunde.
|
||||||
const MIN_REQUEST_GAP_MS = 1000;
|
const MIN_REQUEST_GAP_MS = 1000;
|
||||||
let lastRequestAt = 0;
|
let lastRequestAt = 0;
|
||||||
|
|
||||||
@@ -156,7 +156,7 @@ export async function fetchProfile(
|
|||||||
options: FetchOptions = {},
|
options: FetchOptions = {},
|
||||||
): Promise<ScrapedProfile> {
|
): Promise<ScrapedProfile> {
|
||||||
const steamId64 = normalizeSteamId(steamId);
|
const steamId64 = normalizeSteamId(steamId);
|
||||||
if (!steamId64) throw badRequest('INVALID_STEAMID', `Keine gueltige SteamID: ${steamId}`);
|
if (!steamId64) throw badRequest('INVALID_STEAMID', `Keine gültige SteamID: ${steamId}`);
|
||||||
|
|
||||||
const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
|
const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
|
||||||
const timeoutMs = options.timeoutMs ?? 15000;
|
const timeoutMs = options.timeoutMs ?? 15000;
|
||||||
@@ -171,7 +171,7 @@ export async function fetchProfile(
|
|||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
headers: {
|
headers: {
|
||||||
// Ehrliche Kennzeichnung statt Browser-Tarnung.
|
// Ehrliche Kennzeichnung statt Browser-Tarnung.
|
||||||
'user-agent': 'CC-Manager/1.0 (Paket-Abgleich fuer das CC-Team)',
|
'user-agent': 'CC-Manager/1.0 (Paket-Abgleich für das CC-Team)',
|
||||||
accept: 'text/html',
|
accept: 'text/html',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -183,7 +183,7 @@ export async function fetchProfile(
|
|||||||
return parseProfile(await response.text(), steamId64);
|
return parseProfile(await response.text(), steamId64);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.name === 'AbortError') {
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
throw badRequest('PROFILE_TIMEOUT', 'Zeitueberschreitung beim Abruf des Profils');
|
throw badRequest('PROFILE_TIMEOUT', 'Zeitüberschreitung beim Abruf des Profils');
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const STEAM3 = /^\[?U:1:(\d+)\]?$/i;
|
|||||||
const STEAM64 = /^\d{17}$/;
|
const STEAM64 = /^\d{17}$/;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bringt eine Eingabe auf die 64-Bit-Form. Gibt null zurueck, wenn der Wert
|
* Bringt eine Eingabe auf die 64-Bit-Form. Gibt null zurück, wenn der Wert
|
||||||
* nach keiner bekannten Steam-Schreibweise aussieht.
|
* nach keiner bekannten Steam-Schreibweise aussieht.
|
||||||
*/
|
*/
|
||||||
export function normalizeSteamId(input: string): string | null {
|
export function normalizeSteamId(input: string): string | null {
|
||||||
@@ -37,14 +37,14 @@ export function normalizeSteamId(input: string): string | null {
|
|||||||
|
|
||||||
if (STEAM64.test(value)) {
|
if (STEAM64.test(value)) {
|
||||||
const numeric = BigInt(value);
|
const numeric = BigInt(value);
|
||||||
// Alles unterhalb des Offsets waere keine Nutzer-ID.
|
// Alles unterhalb des Offsets wäre keine Nutzer-ID.
|
||||||
return numeric > BASE ? numeric.toString() : null;
|
return numeric > BASE ? numeric.toString() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Legacy-Form zur Anzeige, etwa fuer Server-Konsolen. */
|
/** Legacy-Form zur Anzeige, etwa für Server-Konsolen. */
|
||||||
export function toLegacySteamId(steamId64: string): string | null {
|
export function toLegacySteamId(steamId64: string): string | null {
|
||||||
if (!STEAM64.test(steamId64)) return null;
|
if (!STEAM64.test(steamId64)) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
app.delete('/api/admin/users/:id', async (request) => {
|
app.delete('/api/admin/users/:id', async (request) => {
|
||||||
const { id } = parseWith(idParam, request.params);
|
const { id } = parseWith(idParam, request.params);
|
||||||
if (id === request.user?.id) {
|
if (id === request.user?.id) {
|
||||||
throw conflict('SELF_DELETE', 'Der eigene Account kann nicht geloescht werden');
|
throw conflict('SELF_DELETE', 'Der eigene Account kann nicht gelöscht werden');
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = await prisma.user.findUnique({ where: { id } });
|
const target = await prisma.user.findUnique({ where: { id } });
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
expires: expiresAt,
|
expires: expiresAt,
|
||||||
});
|
});
|
||||||
// Muss fuer das Double-Submit-Verfahren lesbar sein.
|
// Muss für das Double-Submit-Verfahren lesbar sein.
|
||||||
reply.setCookie(CSRF_COOKIE, csrfTokenFor(token), {
|
reply.setCookie(CSRF_COOKIE, csrfTokenFor(token), {
|
||||||
path: '/',
|
path: '/',
|
||||||
httpOnly: false,
|
httpOnly: false,
|
||||||
@@ -91,7 +91,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
const body = parseWith(loginSchema, request.body);
|
const body = parseWith(loginSchema, request.body);
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({ where: { username: body.username } });
|
const user = await prisma.user.findUnique({ where: { username: body.username } });
|
||||||
// Auch ohne Treffer einmal hashen, damit die Antwortzeit nichts verraet.
|
// Auch ohne Treffer einmal hashen, damit die Antwortzeit nichts verrät.
|
||||||
const digest = user?.passwordHash ?? '$argon2id$v=19$m=19456,t=2,p=1$aaaaaaaaaaaaaaaa$aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
const digest = user?.passwordHash ?? '$argon2id$v=19$m=19456,t=2,p=1$aaaaaaaaaaaaaaaa$aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
||||||
const passwordOk = await verifyPassword(digest, body.password);
|
const passwordOk = await verifyPassword(digest, body.password);
|
||||||
|
|
||||||
@@ -250,7 +250,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
|
|
||||||
app.post('/api/account/totp/setup', { preHandler: requireAuth }, async (request) => {
|
app.post('/api/account/totp/setup', { preHandler: requireAuth }, async (request) => {
|
||||||
const secret = generateSecret();
|
const secret = generateSecret();
|
||||||
// Erst nach bestaetigtem Code aktivieren, sonst sperrt sich der Nutzer aus.
|
// Erst nach bestätigtem Code aktivieren, sonst sperrt sich der Nutzer aus.
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { id: request.user!.id },
|
where: { id: request.user!.id },
|
||||||
data: { totpSecret: secret, totpEnabled: false },
|
data: { totpSecret: secret, totpEnabled: false },
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export async function characterRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
app.post('/api/characters/:id/revert', { preHandler: requireEditor }, async (request) => {
|
app.post('/api/characters/:id/revert', { preHandler: requireEditor }, async (request) => {
|
||||||
const { id } = parseWith(idParam, request.params);
|
const { id } = parseWith(idParam, request.params);
|
||||||
const body = parseWith(
|
const body = parseWith(
|
||||||
z.object({ reason: z.string().trim().min(1, 'Eine Begruendung ist Pflicht').max(500) }),
|
z.object({ reason: z.string().trim().min(1, 'Eine Begründung ist Pflicht').max(500) }),
|
||||||
request.body,
|
request.body,
|
||||||
);
|
);
|
||||||
return revertCharacter(id, body.reason, contextOf(request));
|
return revertCharacter(id, body.reason, contextOf(request));
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export async function creditRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
z.object({
|
z.object({
|
||||||
kind: z.nativeEnum(CreditKind),
|
kind: z.nativeEnum(CreditKind),
|
||||||
amount: z.number().int().refine((v) => v !== 0, 'Betrag darf nicht 0 sein'),
|
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),
|
note: z.string().trim().min(1, 'Eine Begründung ist Pflicht').max(500),
|
||||||
}),
|
}),
|
||||||
request.body,
|
request.body,
|
||||||
);
|
);
|
||||||
@@ -52,7 +52,7 @@ export async function creditRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
app.post('/api/credits/:id/reverse', { preHandler: requireEditor }, async (request) => {
|
app.post('/api/credits/:id/reverse', { preHandler: requireEditor }, async (request) => {
|
||||||
const { id } = parseWith(idParam, request.params);
|
const { id } = parseWith(idParam, request.params);
|
||||||
const body = parseWith(
|
const body = parseWith(
|
||||||
z.object({ reason: z.string().trim().min(1, 'Eine Begruendung ist Pflicht').max(500) }),
|
z.object({ reason: z.string().trim().min(1, 'Eine Begründung ist Pflicht').max(500) }),
|
||||||
request.body,
|
request.body,
|
||||||
);
|
);
|
||||||
return reverseGroup(id, body.reason, contextOf(request));
|
return reverseGroup(id, body.reason, contextOf(request));
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
|||||||
import { badRequest, unauthorized } from '../errors.js';
|
import { badRequest, unauthorized } from '../errors.js';
|
||||||
import type { ActionContext } from '../services/audit.js';
|
import type { ActionContext } from '../services/audit.js';
|
||||||
|
|
||||||
// z.output statt z.input: nur so zaehlen .default()-Werte als gesetzt.
|
// z.output statt z.input: nur so zählen .default()-Werte als gesetzt.
|
||||||
export function parseWith<S extends z.ZodTypeAny>(schema: S, input: unknown): z.output<S> {
|
export function parseWith<S extends z.ZodTypeAny>(schema: S, input: unknown): z.output<S> {
|
||||||
const result = schema.safeParse(input);
|
const result = schema.safeParse(input);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
|||||||
@@ -14,16 +14,16 @@ import {
|
|||||||
import { contextOf, idParam, parseWith } from './helpers.js';
|
import { contextOf, idParam, parseWith } from './helpers.js';
|
||||||
|
|
||||||
const mappingBody = z.object({
|
const mappingBody = z.object({
|
||||||
playerKey: z.string().min(1, 'Spalte fuer die Spieler-Kennung fehlt'),
|
playerKey: z.string().min(1, 'Spalte für die Spieler-Kennung fehlt'),
|
||||||
playerName: z.string().optional(),
|
playerName: z.string().optional(),
|
||||||
product: z.string().min(1, 'Spalte fuer den Produktnamen fehlt'),
|
product: z.string().min(1, 'Spalte für den Produktnamen fehlt'),
|
||||||
orderId: z.string().optional(),
|
orderId: z.string().optional(),
|
||||||
purchasedAt: z.string().optional(),
|
purchasedAt: z.string().optional(),
|
||||||
createMissingPlayers: z.boolean().default(false),
|
createMissingPlayers: z.boolean().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function importRoutes(app: FastifyInstance): Promise<void> {
|
export async function importRoutes(app: FastifyInstance): Promise<void> {
|
||||||
// Der Import veraendert Guthaben in grosser Zahl und bleibt deshalb Admins vorbehalten.
|
// Der Import verändert Guthaben in großer Zahl und bleibt deshalb Admins vorbehalten.
|
||||||
app.post('/api/imports', { preHandler: requireAdmin }, async (request, reply) => {
|
app.post('/api/imports', { preHandler: requireAdmin }, async (request, reply) => {
|
||||||
const file = await request.file();
|
const file = await request.file();
|
||||||
if (!file) throw badRequest('NO_FILE', 'Es wurde keine Datei hochgeladen');
|
if (!file) throw badRequest('NO_FILE', 'Es wurde keine Datei hochgeladen');
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { contextOf, idParam, parseWith } from './helpers.js';
|
|||||||
export async function scraperRoutes(app: FastifyInstance): Promise<void> {
|
export async function scraperRoutes(app: FastifyInstance): Promise<void> {
|
||||||
/**
|
/**
|
||||||
* Gleicht die Profile bereits erfasster Spieler ab. Bewusst nur die
|
* Gleicht die Profile bereits erfasster Spieler ab. Bewusst nur die
|
||||||
* bekannten: der Abgleich soll vorhandene Konten ergaenzen, nicht fremde
|
* bekannten: der Abgleich soll vorhandene Konten ergänzen, nicht fremde
|
||||||
* Profile auf Vorrat einsammeln.
|
* Profile auf Vorrat einsammeln.
|
||||||
*/
|
*/
|
||||||
app.post('/api/scrape/run', { preHandler: requireAdmin }, async (request) => {
|
app.post('/api/scrape/run', { preHandler: requireAdmin }, async (request) => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { prisma } from '../db.js';
|
|||||||
import { env } from '../env.js';
|
import { env } from '../env.js';
|
||||||
|
|
||||||
export interface ActionContext {
|
export interface ActionContext {
|
||||||
// Beide null fuer Vorgaenge ohne bekanntes Konto, etwa einen fehlgeschlagenen
|
// Beide null für Vorgänge ohne bekanntes Konto, etwa einen fehlgeschlagenen
|
||||||
// Login auf einen Benutzernamen, den es gar nicht gibt.
|
// Login auf einen Benutzernamen, den es gar nicht gibt.
|
||||||
actorId: number | null;
|
actorId: number | null;
|
||||||
actorName: string | null;
|
actorName: string | null;
|
||||||
@@ -38,9 +38,9 @@ export async function recordAudit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entfernt Audit-Eintraege, die aelter sind als die eingestellte Frist. Die
|
* Entfernt Audit-Einträge, die älter sind als die eingestellte Frist. Die
|
||||||
* Guthaben-Historie bleibt unangetastet - sie ist die Quelle der Wahrheit fuer
|
* Guthaben-Historie bleibt unangetastet - sie ist die Quelle der Wahrheit für
|
||||||
* bezahlte Ansprueche und hat eine andere Aufbewahrung als das Protokoll.
|
* bezahlte Ansprüche und hat eine andere Aufbewahrung als das Protokoll.
|
||||||
*/
|
*/
|
||||||
export async function cleanupOldAuditEntries(): Promise<number> {
|
export async function cleanupOldAuditEntries(): Promise<number> {
|
||||||
if (env.AUDIT_RETENTION_DAYS === 0) return 0;
|
if (env.AUDIT_RETENTION_DAYS === 0) return 0;
|
||||||
|
|||||||
@@ -15,13 +15,13 @@ export interface CharacterResult {
|
|||||||
character: Character;
|
character: Character;
|
||||||
balances: Balances;
|
balances: Balances;
|
||||||
// Zeigt an, dass ein Topf nach der Buchung im Minus steht. Die Aktion wird
|
// Zeigt an, dass ein Topf nach der Buchung im Minus steht. Die Aktion wird
|
||||||
// trotzdem ausgefuehrt, das Ergebnis gehoert aber sichtbar gemacht.
|
// trotzdem ausgeführt, das Ergebnis gehört aber sichtbar gemacht.
|
||||||
overdrawn: boolean;
|
overdrawn: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fuer ein Redesign zaehlen nur die beiden Redesign-Toepfe.
|
// Für ein Redesign zählen nur die beiden Redesign-Töpfe.
|
||||||
const REDESIGN_KINDS = [CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ] as const;
|
const REDESIGN_KINDS = [CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ] as const;
|
||||||
// Beim Wiederbeleben eines reworkten CCs darf zusaetzlich CC-Guthaben gezogen werden.
|
// Beim Wiederbeleben eines reworkten CCs darf zusätzlich CC-Guthaben gezogen werden.
|
||||||
const REVIVE_KINDS = [CreditKind.CC, ...REDESIGN_KINDS] as const;
|
const REVIVE_KINDS = [CreditKind.CC, ...REDESIGN_KINDS] as const;
|
||||||
|
|
||||||
export type RedesignKind = (typeof REDESIGN_KINDS)[number];
|
export type RedesignKind = (typeof REDESIGN_KINDS)[number];
|
||||||
@@ -37,7 +37,7 @@ async function loadCharacter(id: number): Promise<Character> {
|
|||||||
return character;
|
return character;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Neuer CC. Kostet ein CC-Guthaben, Kulanz ist dafuer nicht einsetzbar. */
|
/** Neuer CC. Kostet ein CC-Guthaben, Kulanz ist dafür nicht einsetzbar. */
|
||||||
export async function createCharacter(
|
export async function createCharacter(
|
||||||
playerId: number,
|
playerId: number,
|
||||||
input: { name: string; loreText?: string | undefined; note?: string | undefined },
|
input: { name: string; loreText?: string | undefined; note?: string | undefined },
|
||||||
@@ -87,8 +87,8 @@ export async function createCharacter(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Redesign als Lineage: der alte CC geht auf INACTIVE/REWORKED, der neue
|
* Redesign als Lineage: der alte CC geht auf INACTIVE/REWORKED, der neue
|
||||||
* entsteht als eigener Datensatz in derselben Kette. Damit greift spaeter
|
* entsteht als eigener Datensatz in derselben Kette. Damit greift später
|
||||||
* automatisch die Regel, dass ein reworkter CC nur bezahlt zurueckkommt.
|
* automatisch die Regel, dass ein reworkter CC nur bezahlt zurückkommt.
|
||||||
*/
|
*/
|
||||||
export async function redesignCharacter(
|
export async function redesignCharacter(
|
||||||
characterId: number,
|
characterId: number,
|
||||||
@@ -165,7 +165,7 @@ export async function redesignCharacter(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Deaktivierung ohne Guthaben-Effekt, etwa beim Serverputz wegen Inaktivitaet. */
|
/** Deaktivierung ohne Guthaben-Effekt, etwa beim Serverputz wegen Inaktivität. */
|
||||||
export async function deactivateCharacter(
|
export async function deactivateCharacter(
|
||||||
characterId: number,
|
characterId: number,
|
||||||
reason: InactiveReason,
|
reason: InactiveReason,
|
||||||
@@ -179,7 +179,7 @@ export async function deactivateCharacter(
|
|||||||
if (reason === InactiveReason.REWORKED) {
|
if (reason === InactiveReason.REWORKED) {
|
||||||
throw badRequest(
|
throw badRequest(
|
||||||
'USE_REDESIGN',
|
'USE_REDESIGN',
|
||||||
'Ein Rework entsteht ueber die Redesign-Aktion, nicht ueber eine Deaktivierung',
|
'Ein Rework entsteht über die Redesign-Aktion, nicht über eine Deaktivierung',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,9 +208,9 @@ export async function deactivateCharacter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gratis-Reaktivierung. Moeglich, solange der CC nicht redesignt wurde. Der
|
* Gratis-Reaktivierung. Möglich, solange der CC nicht redesignt wurde. Der
|
||||||
* Grund des Verschwindens spielt dabei keine Rolle - ob Spieler-Inaktivitaet
|
* Grund des Verschwindens spielt dabei keine Rolle - ob Spieler-Inaktivität
|
||||||
* oder eine zeitweise entfernte Einheit: der CC existiert unveraendert weiter
|
* oder eine zeitweise entfernte Einheit: der CC existiert unverändert weiter
|
||||||
* und kostet deshalb kein Guthaben.
|
* und kostet deshalb kein Guthaben.
|
||||||
*/
|
*/
|
||||||
export async function reactivateFree(
|
export async function reactivateFree(
|
||||||
@@ -250,7 +250,7 @@ export async function reactivateFree(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bezahlte Reaktivierung, ausschliesslich fuer redesignte CCs: deren Original
|
* Bezahlte Reaktivierung, ausschließlich für redesignte CCs: deren Original
|
||||||
* existiert nicht mehr, es muss also neu erschaffen werden. Kostet ein
|
* existiert nicht mehr, es muss also neu erschaffen werden. Kostet ein
|
||||||
* Redesign- oder ein CC-Guthaben, der Nutzer entscheidet welches.
|
* Redesign- oder ein CC-Guthaben, der Nutzer entscheidet welches.
|
||||||
*/
|
*/
|
||||||
@@ -268,11 +268,11 @@ export async function reactivatePaid(
|
|||||||
if (existing.inactiveReason !== InactiveReason.REWORKED) {
|
if (existing.inactiveReason !== InactiveReason.REWORKED) {
|
||||||
throw conflict(
|
throw conflict(
|
||||||
'IS_FREE',
|
'IS_FREE',
|
||||||
'Dieser CC existiert unveraendert weiter und kommt gratis zurueck. Eine Guthaben-Buchung waere hier falsch.',
|
'Dieser CC existiert unverändert weiter und kommt gratis zurück. Eine Guthaben-Buchung wäre hier falsch.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!REVIVE_KINDS.includes(useKind)) {
|
if (!REVIVE_KINDS.includes(useKind)) {
|
||||||
throw badRequest('INVALID_KIND', 'Ungueltige Guthaben-Art fuer eine Reaktivierung');
|
throw badRequest('INVALID_KIND', 'Ungültige Guthaben-Art für eine Reaktivierung');
|
||||||
}
|
}
|
||||||
|
|
||||||
const character = await prisma.$transaction(async (tx) => {
|
const character = await prisma.$transaction(async (tx) => {
|
||||||
@@ -341,9 +341,9 @@ export async function updateCharacter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nimmt einen irrtuemlich angelegten CC komplett zurueck: Verbrauchsbuchungen
|
* Nimmt einen irrtümlich angelegten CC komplett zurück: Verbrauchsbuchungen
|
||||||
* werden gegengebucht und ein durch das Redesign deaktivierter Vorgaenger
|
* werden gegengebucht und ein durch das Redesign deaktivierter Vorgänger
|
||||||
* kehrt in den aktiven Zustand zurueck.
|
* kehrt in den aktiven Zustand zurück.
|
||||||
*/
|
*/
|
||||||
export async function revertCharacter(
|
export async function revertCharacter(
|
||||||
characterId: number,
|
characterId: number,
|
||||||
@@ -359,7 +359,7 @@ export async function revertCharacter(
|
|||||||
if (existing.successors.length > 0) {
|
if (existing.successors.length > 0) {
|
||||||
throw conflict(
|
throw conflict(
|
||||||
'HAS_SUCCESSORS',
|
'HAS_SUCCESSORS',
|
||||||
'Dieser CC hat Nachfolger in der Redesign-Kette. Bitte zuerst den juengsten CC zuruecknehmen.',
|
'Dieser CC hat Nachfolger in der Redesign-Kette. Bitte zuerst den jüngsten CC zurücknehmen.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,7 +384,7 @@ export async function revertCharacter(
|
|||||||
amount: -entry.amount,
|
amount: -entry.amount,
|
||||||
source: CreditSource.REVERSAL,
|
source: CreditSource.REVERSAL,
|
||||||
groupId: entry.groupId,
|
groupId: entry.groupId,
|
||||||
note: `Ruecknahme "${existing.name}": ${reason}`,
|
note: `Rücknahme "${existing.name}": ${reason}`,
|
||||||
reversalOfId: entry.id,
|
reversalOfId: entry.id,
|
||||||
createdById: ctx.actorId,
|
createdById: ctx.actorId,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export interface Balances {
|
|||||||
CC: number;
|
CC: number;
|
||||||
REDESIGN_PAID: number;
|
REDESIGN_PAID: number;
|
||||||
REDESIGN_KULANZ: number;
|
REDESIGN_KULANZ: number;
|
||||||
// Fuer ein Redesign zaehlen beide Redesign-Toepfe zusammen.
|
// Für ein Redesign zählen beide Redesign-Töpfe zusammen.
|
||||||
redesignTotal: number;
|
redesignTotal: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,8 +72,8 @@ export interface PackageRowParams {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Baut die Ledger-Zeilen eines gekauften Pakets. Die Regel, dass ein CC-Paket
|
* Baut die Ledger-Zeilen eines gekauften Pakets. Die Regel, dass ein CC-Paket
|
||||||
* immer auch ein bezahltes Redesign enthaelt, steht nur hier, damit manuelle
|
* immer auch ein bezahltes Redesign enthält, steht nur hier, damit manuelle
|
||||||
* Eingabe und Import nicht auseinanderlaufen koennen.
|
* Eingabe und Import nicht auseinanderlaufen können.
|
||||||
*/
|
*/
|
||||||
export function buildPackageRows(
|
export function buildPackageRows(
|
||||||
target: 'CC_PACKAGE' | 'REDESIGN_PACKAGE',
|
target: 'CC_PACKAGE' | 'REDESIGN_PACKAGE',
|
||||||
@@ -102,7 +102,7 @@ export function buildPackageRows(
|
|||||||
/**
|
/**
|
||||||
* Ein CC-Paket erzeugt immer beide Guthaben: einen CC und ein bezahltes
|
* Ein CC-Paket erzeugt immer beide Guthaben: einen CC und ein bezahltes
|
||||||
* Redesign. Beide Zeilen teilen sich eine groupId, damit ein Storno sie
|
* Redesign. Beide Zeilen teilen sich eine groupId, damit ein Storno sie
|
||||||
* gemeinsam zurueckholt.
|
* gemeinsam zurückholt.
|
||||||
*/
|
*/
|
||||||
export async function bookCcPackage(
|
export async function bookCcPackage(
|
||||||
playerId: number,
|
playerId: number,
|
||||||
@@ -196,7 +196,7 @@ export async function bookKulanz(
|
|||||||
return { groupId, balances: await getBalances(playerId) };
|
return { groupId, balances: await getBalances(playerId) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Freie Korrekturbuchung. Vorbehalten fuer Administratoren. */
|
/** Freie Korrekturbuchung. Vorbehalten für Administratoren. */
|
||||||
export async function bookAdjustment(
|
export async function bookAdjustment(
|
||||||
playerId: number,
|
playerId: number,
|
||||||
kind: CreditKind,
|
kind: CreditKind,
|
||||||
@@ -236,9 +236,9 @@ export async function bookAdjustment(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Storniert eine Buchung als Gegenbuchung, nicht als Loeschung: die Historie
|
* Storniert eine Buchung als Gegenbuchung, nicht als Löschung: die Historie
|
||||||
* bleibt vollstaendig lesbar. Verbrauchszeilen sind ausgenommen, die werden
|
* bleibt vollständig lesbar. Verbrauchszeilen sind ausgenommen, die werden
|
||||||
* ueber die zugehoerige CC-Aktion zurueckgenommen.
|
* über die zugehörige CC-Aktion zurückgenommen.
|
||||||
*/
|
*/
|
||||||
export async function reverseGroup(
|
export async function reverseGroup(
|
||||||
ledgerId: number,
|
ledgerId: number,
|
||||||
@@ -251,7 +251,7 @@ export async function reverseGroup(
|
|||||||
if (anchor.source === CreditSource.CONSUME) {
|
if (anchor.source === CreditSource.CONSUME) {
|
||||||
throw conflict(
|
throw conflict(
|
||||||
'CONSUME_NOT_REVERSIBLE',
|
'CONSUME_NOT_REVERSIBLE',
|
||||||
'Verbrauchsbuchungen werden ueber die zugehoerige CC-Aktion zurueckgenommen, nicht direkt storniert',
|
'Verbrauchsbuchungen werden über die zugehörige CC-Aktion zurückgenommen, nicht direkt storniert',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (anchor.source === CreditSource.REVERSAL) {
|
if (anchor.source === CreditSource.REVERSAL) {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export interface ParsedFile {
|
|||||||
|
|
||||||
const MAX_ROWS = 20000;
|
const MAX_ROWS = 20000;
|
||||||
|
|
||||||
// Deutsche Tabellen-Exporte kommen haeufig mit Semikolon statt Komma.
|
// Deutsche Tabellen-Exporte kommen häufig mit Semikolon statt Komma.
|
||||||
function detectDelimiter(sample: string): string {
|
function detectDelimiter(sample: string): string {
|
||||||
const firstLine = sample.split(/\r?\n/, 1)[0] ?? '';
|
const firstLine = sample.split(/\r?\n/, 1)[0] ?? '';
|
||||||
const candidates = [',', ';', '\t', '|'];
|
const candidates = [',', ';', '\t', '|'];
|
||||||
@@ -78,7 +78,7 @@ export function parseUpload(filename: string, content: Buffer): ParsedFile {
|
|||||||
} catch {
|
} catch {
|
||||||
throw badRequest('INVALID_JSON', 'Die JSON-Datei konnte nicht gelesen werden');
|
throw badRequest('INVALID_JSON', 'Die JSON-Datei konnte nicht gelesen werden');
|
||||||
}
|
}
|
||||||
// Manche Exporte verpacken die Liste in ein Huellobjekt.
|
// Manche Exporte verpacken die Liste in ein Hüllobjekt.
|
||||||
const list = Array.isArray(data)
|
const list = Array.isArray(data)
|
||||||
? data
|
? data
|
||||||
: typeof data === 'object' && data !== null
|
: typeof data === 'object' && data !== null
|
||||||
@@ -86,7 +86,7 @@ export function parseUpload(filename: string, content: Buffer): ParsedFile {
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (!Array.isArray(list)) {
|
if (!Array.isArray(list)) {
|
||||||
throw badRequest('INVALID_JSON', 'Erwartet wird eine Liste von Eintraegen');
|
throw badRequest('INVALID_JSON', 'Erwartet wird eine Liste von Einträgen');
|
||||||
}
|
}
|
||||||
rows = list
|
rows = list
|
||||||
.filter((entry): entry is Record<string, unknown> => typeof entry === 'object' && entry !== null)
|
.filter((entry): entry is Record<string, unknown> => typeof entry === 'object' && entry !== null)
|
||||||
@@ -107,7 +107,7 @@ export function parseUpload(filename: string, content: Buffer): ParsedFile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rows.length === 0) throw badRequest('NO_ROWS', 'Die Datei enthaelt keine Datenzeilen');
|
if (rows.length === 0) throw badRequest('NO_ROWS', 'Die Datei enthält keine Datenzeilen');
|
||||||
if (rows.length > MAX_ROWS) {
|
if (rows.length > MAX_ROWS) {
|
||||||
throw badRequest('TOO_MANY_ROWS', `Die Datei hat mehr als ${MAX_ROWS} Zeilen`);
|
throw badRequest('TOO_MANY_ROWS', `Die Datei hat mehr als ${MAX_ROWS} Zeilen`);
|
||||||
}
|
}
|
||||||
@@ -165,9 +165,9 @@ function parseDate(value: string | undefined): Date | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dampft die gespeicherten Rohzeilen auf die Spalten ein, die tatsaechlich
|
* Dampft die gespeicherten Rohzeilen auf die Spalten ein, die tatsächlich
|
||||||
* zugeordnet wurden. Ein Shop-Export enthaelt oft deutlich mehr - Mailadresse,
|
* zugeordnet wurden. Ein Shop-Export enthält oft deutlich mehr - Mailadresse,
|
||||||
* Rechnungsanschrift, Zahlungsart -, und davon braucht die App nichts. Laeuft,
|
* Rechnungsanschrift, Zahlungsart -, und davon braucht die App nichts. Läuft,
|
||||||
* sobald ein Import abgeschlossen oder verworfen ist.
|
* sobald ein Import abgeschlossen oder verworfen ist.
|
||||||
*/
|
*/
|
||||||
async function pruneRawRows(
|
async function pruneRawRows(
|
||||||
@@ -204,8 +204,8 @@ async function pruneRawRows(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rechnet fuer jede Zeile aus, was beim Buchen passieren wuerde. Wird vor der
|
* Rechnet für jede Zeile aus, was beim Buchen passieren würde. Wird vor der
|
||||||
* Bestaetigung angezeigt und beim Commit unveraendert ausgefuehrt.
|
* Bestätigung angezeigt und beim Commit unverändert ausgeführt.
|
||||||
*/
|
*/
|
||||||
export async function applyMapping(importId: number, mapping: ColumnMapping, ctx: ActionContext) {
|
export async function applyMapping(importId: number, mapping: ColumnMapping, ctx: ActionContext) {
|
||||||
const record = await prisma.import.findUnique({
|
const record = await prisma.import.findUnique({
|
||||||
@@ -229,7 +229,7 @@ export async function applyMapping(importId: number, mapping: ColumnMapping, ctx
|
|||||||
raw: row.raw as Record<string, string>,
|
raw: row.raw as Record<string, string>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Die Datei kann eine andere Schreibweise liefern als die Datenbank fuehrt.
|
// Die Datei kann eine andere Schreibweise liefern als die Datenbank führt.
|
||||||
const rawKeys = rawRows.map((r) => (r.raw[mapping.playerKey] ?? '').trim()).filter(Boolean);
|
const rawKeys = rawRows.map((r) => (r.raw[mapping.playerKey] ?? '').trim()).filter(Boolean);
|
||||||
const playerKeys = [
|
const playerKeys = [
|
||||||
...new Set(rawKeys.flatMap((key) => {
|
...new Set(rawKeys.flatMap((key) => {
|
||||||
@@ -262,7 +262,7 @@ export async function applyMapping(importId: number, mapping: ColumnMapping, ctx
|
|||||||
: [],
|
: [],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Faengt Dubletten innerhalb derselben Datei ab.
|
// Fängt Dubletten innerhalb derselben Datei ab.
|
||||||
const seenInFile = new Set<string>();
|
const seenInFile = new Set<string>();
|
||||||
const updates: Prisma.PrismaPromise<unknown>[] = [];
|
const updates: Prisma.PrismaPromise<unknown>[] = [];
|
||||||
const summary: Record<Plan, number> = {
|
const summary: Record<Plan, number> = {
|
||||||
@@ -356,7 +356,7 @@ export async function getImport(importId: number, includeRows = true) {
|
|||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fuehrt den zuvor angezeigten Plan aus. Alles oder nichts. */
|
/** Führt den zuvor angezeigten Plan aus. Alles oder nichts. */
|
||||||
export async function commitImport(importId: number, ctx: ActionContext) {
|
export async function commitImport(importId: number, ctx: ActionContext) {
|
||||||
const record = await prisma.import.findUnique({
|
const record = await prisma.import.findUnique({
|
||||||
where: { id: importId },
|
where: { id: importId },
|
||||||
@@ -375,7 +375,7 @@ export async function commitImport(importId: number, ctx: ActionContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await prisma.$transaction(async (tx) => {
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
// Mehrere Zeilen koennen denselben neuen Spieler betreffen.
|
// Mehrere Zeilen können denselben neuen Spieler betreffen.
|
||||||
const createdPlayers = new Map<string, number>();
|
const createdPlayers = new Map<string, number>();
|
||||||
let booked = 0;
|
let booked = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export async function getPlayerDetail(playerId: number) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Bringt die Eingabe auf die kanonische 64-Bit-Form. Ohne das legt derselbe
|
* Bringt die Eingabe auf die kanonische 64-Bit-Form. Ohne das legt derselbe
|
||||||
* Spieler zwei Datensaetze an, sobald eine Quelle STEAM_0:... liefert und die
|
* Spieler zwei Datensätze an, sobald eine Quelle STEAM_0:... liefert und die
|
||||||
* andere die 64-Bit-Zahl.
|
* andere die 64-Bit-Zahl.
|
||||||
*/
|
*/
|
||||||
function canonicalSteamId(input: string | null | undefined): string | null {
|
function canonicalSteamId(input: string | null | undefined): string | null {
|
||||||
@@ -169,10 +169,10 @@ export async function updatePlayer(playerId: number, input: Partial<PlayerInput>
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loescht einen Spieler samt allem, was an ihm haengt. Spuren in den
|
* Löscht einen Spieler samt allem, was an ihm hängt. Spuren in den
|
||||||
* Import-Zeilen und in frueheren Audit-Eintraegen werden dabei mit entfernt,
|
* Import-Zeilen und in früheren Audit-Einträgen werden dabei mit entfernt,
|
||||||
* damit nach der Loeschung wirklich nichts Personenbezogenes uebrig bleibt.
|
* damit nach der Löschung wirklich nichts Personenbezogenes übrig bleibt.
|
||||||
* Die Audit-Eintraege selbst bleiben als Vorgang bestehen, nur ohne Inhalt.
|
* Die Audit-Einträge selbst bleiben als Vorgang bestehen, nur ohne Inhalt.
|
||||||
*/
|
*/
|
||||||
export async function deletePlayer(playerId: number, ctx: ActionContext): Promise<void> {
|
export async function deletePlayer(playerId: number, ctx: ActionContext): Promise<void> {
|
||||||
const existing = await prisma.player.findUnique({
|
const existing = await prisma.player.findUnique({
|
||||||
@@ -198,7 +198,7 @@ export async function deletePlayer(playerId: number, ctx: ActionContext): Promis
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fruehere Audit-Eintraege zu diesem Spieler behalten den Vorgang, nicht den Inhalt.
|
// Frühere Audit-Einträge zu diesem Spieler behalten den Vorgang, nicht den Inhalt.
|
||||||
await tx.auditLog.updateMany({
|
await tx.auditLog.updateMany({
|
||||||
where: { targetType: 'player', targetId: String(playerId) },
|
where: { targetType: 'player', targetId: String(playerId) },
|
||||||
data: { details: { redacted: true } },
|
data: { details: { redacted: true } },
|
||||||
@@ -210,8 +210,8 @@ export async function deletePlayer(playerId: number, ctx: ActionContext): Promis
|
|||||||
action: 'player.delete',
|
action: 'player.delete',
|
||||||
targetType: 'player',
|
targetType: 'player',
|
||||||
targetId: playerId,
|
targetId: playerId,
|
||||||
// Bewusst ohne Name und SteamID: der Eintrag soll die Loeschung belegen,
|
// Bewusst ohne Name und SteamID: der Eintrag soll die Löschung belegen,
|
||||||
// nicht die geloeschten Daten konservieren.
|
// nicht die gelöschten Daten konservieren.
|
||||||
details: {
|
details: {
|
||||||
removedCharacters: existing._count.characters,
|
removedCharacters: existing._count.characters,
|
||||||
removedLedgerRows: existing._count.ledger,
|
removedLedgerRows: existing._count.ledger,
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ export interface ScrapeSummary {
|
|||||||
const externalIdFor = (packageBoughtId: number) => `gext:${packageBoughtId}`;
|
const externalIdFor = (packageBoughtId: number) => `gext:${packageBoughtId}`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Holt die Profile der uebergebenen Spieler und legt daraus einen Import an,
|
* Holt die Profile der übergebenen Spieler und legt daraus einen Import an,
|
||||||
* der wie ein Datei-Import in der Vorschau bestaetigt wird. Es wird nichts
|
* der wie ein Datei-Import in der Vorschau bestätigt wird. Es wird nichts
|
||||||
* automatisch gebucht.
|
* automatisch gebucht.
|
||||||
*/
|
*/
|
||||||
export async function scrapePlayers(
|
export async function scrapePlayers(
|
||||||
@@ -170,7 +170,7 @@ export async function scrapePlayers(
|
|||||||
return summary;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Handpruefung der unklaren Eintraege ---
|
// --- Handprüfung der unklaren Einträge ---
|
||||||
|
|
||||||
export type Resolution = 'CC_PACKAGE' | 'REDESIGN_PACKAGE' | 'REDESIGN_KULANZ' | 'IGNORE';
|
export type Resolution = 'CC_PACKAGE' | 'REDESIGN_PACKAGE' | 'REDESIGN_KULANZ' | 'IGNORE';
|
||||||
|
|
||||||
@@ -182,7 +182,7 @@ export async function listUnmatched(onlyOpen = true) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Spieler mit offenen Eintraegen, fuer den Hinweis auf der Uebersicht. */
|
/** Spieler mit offenen Einträgen, für den Hinweis auf der Uebersicht. */
|
||||||
export async function unmatchedOverview() {
|
export async function unmatchedOverview() {
|
||||||
const open = await prisma.unmatchedPackage.findMany({
|
const open = await prisma.unmatchedPackage.findMany({
|
||||||
where: { resolved: false },
|
where: { resolved: false },
|
||||||
@@ -208,7 +208,7 @@ export async function unmatchedOverview() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loest einen unklaren Eintrag auf. Wird er als Paket eingestuft, entsteht
|
* Löst einen unklaren Eintrag auf. Wird er als Paket eingestuft, entsteht
|
||||||
* sofort die passende Buchung - inklusive der Regel, dass ein CC-Paket auch
|
* sofort die passende Buchung - inklusive der Regel, dass ein CC-Paket auch
|
||||||
* ein Redesign mitbringt.
|
* ein Redesign mitbringt.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export interface TestDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Startet eine Postgres-Instanz im Speicher und stellt sie ueber einen lokalen
|
* Startet eine Postgres-Instanz im Speicher und stellt sie über einen lokalen
|
||||||
* Port bereit, damit Prisma sich wie an eine echte Datenbank verbindet.
|
* Port bereit, damit Prisma sich wie an eine echte Datenbank verbindet.
|
||||||
* So laufen die Tests ohne Docker oder installiertes Postgres.
|
* So laufen die Tests ohne Docker oder installiertes Postgres.
|
||||||
*/
|
*/
|
||||||
@@ -26,7 +26,7 @@ export async function startTestDatabase(port: number): Promise<TestDatabase> {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
// connection_limit=1, weil die Testinstanz immer nur eine Verbindung
|
// connection_limit=1, weil die Testinstanz immer nur eine Verbindung
|
||||||
// gleichzeitig bedient. Echtes Postgres braucht die Einschraenkung nicht.
|
// gleichzeitig bedient. Echtes Postgres braucht die Einschränkung nicht.
|
||||||
url: `postgresql://postgres:postgres@127.0.0.1:${port}/postgres?connection_limit=1`,
|
url: `postgresql://postgres:postgres@127.0.0.1:${port}/postgres?connection_limit=1`,
|
||||||
stop: async () => {
|
stop: async () => {
|
||||||
await server.stop();
|
await server.stop();
|
||||||
|
|||||||
+12
-12
@@ -81,7 +81,7 @@ describe('Anmeldung', () => {
|
|||||||
assert.equal(response.statusCode, 401);
|
assert.equal(response.statusCode, 401);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('verraet nicht, ob es den Benutzernamen gibt', async () => {
|
it('verrät nicht, ob es den Benutzernamen gibt', async () => {
|
||||||
const unknownUser = await app.inject({
|
const unknownUser = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/api/auth/login',
|
url: '/api/auth/login',
|
||||||
@@ -97,7 +97,7 @@ describe('Anmeldung', () => {
|
|||||||
assert.equal(unknownUser.json().message, wrongPassword.json().message);
|
assert.equal(unknownUser.json().message, wrongPassword.json().message);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('laesst ein gesperrtes Konto nicht herein', async () => {
|
it('lässt ein gesperrtes Konto nicht herein', async () => {
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/api/auth/login',
|
url: '/api/auth/login',
|
||||||
@@ -129,7 +129,7 @@ describe('CSRF-Schutz', () => {
|
|||||||
assert.equal(response.json().error, 'FORBIDDEN');
|
assert.equal(response.json().error, 'FORBIDDEN');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('blockt einen gefaelschten Token', async () => {
|
it('blockt einen gefälschten Token', async () => {
|
||||||
const credentials = await login('team');
|
const credentials = await login('team');
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
@@ -142,7 +142,7 @@ describe('CSRF-Schutz', () => {
|
|||||||
assert.equal(response.statusCode, 403);
|
assert.equal(response.statusCode, 403);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('laesst die Anfrage mit passendem Token durch', async () => {
|
it('lässt die Anfrage mit passendem Token durch', async () => {
|
||||||
const credentials = await login('team');
|
const credentials = await login('team');
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
@@ -155,7 +155,7 @@ describe('CSRF-Schutz', () => {
|
|||||||
assert.equal(response.statusCode, 201);
|
assert.equal(response.statusCode, 201);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('laesst lesende Anfragen ohne Token zu', async () => {
|
it('lässt lesende Anfragen ohne Token zu', async () => {
|
||||||
const credentials = await login('gast');
|
const credentials = await login('gast');
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
@@ -227,7 +227,7 @@ describe('Erstpasswort', () => {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/api/players',
|
url: '/api/players',
|
||||||
headers: authHeaders(credentials),
|
headers: authHeaders(credentials),
|
||||||
payload: { displayName: 'Zu frueh' },
|
payload: { displayName: 'Zu früh' },
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(response.statusCode, 403);
|
assert.equal(response.statusCode, 403);
|
||||||
@@ -251,8 +251,8 @@ describe('Erstpasswort', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Guthaben ueber die API', () => {
|
describe('Guthaben über die API', () => {
|
||||||
it('ein CC-Paket schlaegt in beiden Toepfen auf', async () => {
|
it('ein CC-Paket schlägt in beiden Töpfen auf', async () => {
|
||||||
const credentials = await login('team');
|
const credentials = await login('team');
|
||||||
|
|
||||||
const created = await app.inject({
|
const created = await app.inject({
|
||||||
@@ -287,11 +287,11 @@ describe('Guthaben ueber die API', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Auslieferung der Oberflaeche', () => {
|
describe('Auslieferung der Oberfläche', () => {
|
||||||
// Diese Header sind der Grund, warum ein Update beim Nutzer ankommt. Wird die
|
// Diese Header sind der Grund, warum ein Update beim Nutzer ankommt. Wird die
|
||||||
// index.html cachebar ausgeliefert, sieht er nach einem Deploy weiter die
|
// index.html cachebar ausgeliefert, sieht er nach einem Deploy weiter die
|
||||||
// alte Oberflaeche - ohne dass irgendwo ein Fehler auftaucht.
|
// alte Oberfläche - ohne dass irgendwo ein Fehler auftaucht.
|
||||||
it('laesst die index.html nicht zwischenspeichern', async () => {
|
it('lässt die index.html nicht zwischenspeichern', async () => {
|
||||||
const response = await app.inject({ method: 'GET', url: '/' });
|
const response = await app.inject({ method: 'GET', url: '/' });
|
||||||
|
|
||||||
assert.equal(response.statusCode, 200);
|
assert.equal(response.statusCode, 200);
|
||||||
@@ -309,7 +309,7 @@ describe('Auslieferung der Oberflaeche', () => {
|
|||||||
assert.match(response.headers['cache-control'] as string, /no-cache/);
|
assert.match(response.headers['cache-control'] as string, /no-cache/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('laesst gehashte Assets lange liegen', async () => {
|
it('lässt gehashte Assets lange liegen', async () => {
|
||||||
const page = await app.inject({ method: 'GET', url: '/' });
|
const page = await app.inject({ method: 'GET', url: '/' });
|
||||||
const asset = /\/assets\/[^"]+\.js/.exec(page.body)?.[0];
|
const asset = /\/assets\/[^"]+\.js/.exec(page.body)?.[0];
|
||||||
assert.ok(asset, 'die Seite verweist auf ein Asset');
|
assert.ok(asset, 'die Seite verweist auf ein Asset');
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ let ctx: { actorId: number; actorName: string };
|
|||||||
|
|
||||||
const CSV = [
|
const CSV = [
|
||||||
'Bestellnummer;Steam-ID;Spielername;Produkt;Kaufdatum',
|
'Bestellnummer;Steam-ID;Spielername;Produkt;Kaufdatum',
|
||||||
'A-1001;76561198000000001;Rabe;CC-Paket Gross;2025-01-15',
|
'A-1001;76561198000000001;Rabe;CC-Paket Groß;2025-01-15',
|
||||||
'A-1002;76561198000000002;Fuchs;Redesign Gutschein;2025-01-16',
|
'A-1002;76561198000000002;Fuchs;Redesign Gutschein;2025-01-16',
|
||||||
'A-1003;76561198000000001;Rabe;Spielerfarbe Blau;2025-01-17',
|
'A-1003;76561198000000001;Rabe;Spielerfarbe Blau;2025-01-17',
|
||||||
'A-1004;76561198000000003;Dachs;CC-Paket Klein;2025-01-18',
|
'A-1004;76561198000000003;Dachs;CC-Paket Klein;2025-01-18',
|
||||||
@@ -92,7 +92,7 @@ describe('Vorschau und Buchung', () => {
|
|||||||
|
|
||||||
const summary = await imports.applyMapping(importId, MAPPING, 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.CREATE_PLAYER_AND_BOOK, 3, 'drei neue Spieler mit gültigem Produkt');
|
||||||
assert.equal(summary.SKIP_IGNORED, 1, 'die Spielerfarbe wird ignoriert');
|
assert.equal(summary.SKIP_IGNORED, 1, 'die Spielerfarbe wird ignoriert');
|
||||||
assert.equal(summary.SKIP_DUPLICATE, 0);
|
assert.equal(summary.SKIP_DUPLICATE, 0);
|
||||||
});
|
});
|
||||||
@@ -165,7 +165,7 @@ describe('Vorschau und Buchung', () => {
|
|||||||
assert.equal(summary.SKIP_DUPLICATE, 1);
|
assert.equal(summary.SKIP_DUPLICATE, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ueberspringt unbekannte Spieler, wenn sie nicht angelegt werden sollen', async () => {
|
it('überspringt unbekannte Spieler, wenn sie nicht angelegt werden sollen', async () => {
|
||||||
const csv = ['Bestellnummer;Steam-ID;Produkt', 'D-1;76561198000009999;CC-Paket'].join('\n');
|
const csv = ['Bestellnummer;Steam-ID;Produkt', 'D-1;76561198000009999;CC-Paket'].join('\n');
|
||||||
const parsed = imports.parseUpload('fremd.csv', Buffer.from(csv, 'utf8'));
|
const parsed = imports.parseUpload('fremd.csv', Buffer.from(csv, 'utf8'));
|
||||||
const importId = await imports.createImport('fremd.csv', parsed, ctx);
|
const importId = await imports.createImport('fremd.csv', parsed, ctx);
|
||||||
@@ -184,7 +184,7 @@ describe('Vorschau und Buchung', () => {
|
|||||||
assert.equal(summary.SKIP_NO_PLAYER, 1);
|
assert.equal(summary.SKIP_NO_PLAYER, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ein abgeschlossener Import laesst sich nicht erneut buchen', async () => {
|
it('ein abgeschlossener Import lässt sich nicht erneut buchen', async () => {
|
||||||
const csv = ['Bestellnummer;Steam-ID;Produkt', 'E-1;76561198000000001;CC-Paket'].join('\n');
|
const csv = ['Bestellnummer;Steam-ID;Produkt', 'E-1;76561198000000001;CC-Paket'].join('\n');
|
||||||
const parsed = imports.parseUpload('nochmal.csv', Buffer.from(csv, 'utf8'));
|
const parsed = imports.parseUpload('nochmal.csv', Buffer.from(csv, 'utf8'));
|
||||||
const importId = await imports.createImport('nochmal.csv', parsed, ctx);
|
const importId = await imports.createImport('nochmal.csv', parsed, ctx);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ let audit: Audit;
|
|||||||
let db: Db;
|
let db: Db;
|
||||||
let ctx: { actorId: number; actorName: string };
|
let ctx: { actorId: number; actorName: string };
|
||||||
|
|
||||||
// Ein Export, wie ihn ein Shop liefern koennte: mehr Spalten als die App braucht.
|
// Ein Export, wie ihn ein Shop liefern könnte: mehr Spalten als die App braucht.
|
||||||
const CSV = [
|
const CSV = [
|
||||||
'Bestellnummer;Steam-ID;Produkt;Email;Rechnungsadresse;Zahlungsart',
|
'Bestellnummer;Steam-ID;Produkt;Email;Rechnungsadresse;Zahlungsart',
|
||||||
'P-1;76561198000000501;CC-Paket;spieler@example.com;Musterweg 1, 12345 Musterstadt;PayPal',
|
'P-1;76561198000000501;CC-Paket;spieler@example.com;Musterweg 1, 12345 Musterstadt;PayPal',
|
||||||
@@ -45,7 +45,7 @@ before(async () => {
|
|||||||
audit = await import('../src/services/audit.js');
|
audit = await import('../src/services/audit.js');
|
||||||
|
|
||||||
const user = await db.prisma.user.create({
|
const user = await db.prisma.user.create({
|
||||||
data: { username: 'datenschuetzer', passwordHash: 'x', role: 'ADMIN' },
|
data: { username: 'datenschützer', passwordHash: 'x', role: 'ADMIN' },
|
||||||
});
|
});
|
||||||
ctx = { actorId: user.id, actorName: user.username };
|
ctx = { actorId: user.id, actorName: user.username };
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ describe('Datenminimierung beim Import', () => {
|
|||||||
assert.equal(rawAfter['Steam-ID'], '76561198000000501', 'die zugeordnete Spalte bleibt');
|
assert.equal(rawAfter['Steam-ID'], '76561198000000501', 'die zugeordnete Spalte bleibt');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('raeumt auch einen verworfenen Import auf', async () => {
|
it('räumt auch einen verworfenen Import auf', async () => {
|
||||||
const parsed = imports.parseUpload('shop2.csv', Buffer.from(CSV, 'utf8'));
|
const parsed = imports.parseUpload('shop2.csv', Buffer.from(CSV, 'utf8'));
|
||||||
const importId = await imports.createImport('shop2.csv', parsed, ctx);
|
const importId = await imports.createImport('shop2.csv', parsed, ctx);
|
||||||
await imports.applyMapping(importId, MAPPING, ctx);
|
await imports.applyMapping(importId, MAPPING, ctx);
|
||||||
@@ -116,7 +116,7 @@ describe('Fehlgeschlagene Anmeldung', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Loeschung eines Spielers', () => {
|
describe('Löschung eines Spielers', () => {
|
||||||
it('entfernt auch Spuren in Import-Zeilen und Audit-Details', async () => {
|
it('entfernt auch Spuren in Import-Zeilen und Audit-Details', async () => {
|
||||||
// Eigene Bestellnummer, sonst greift zu Recht die Dedup-Logik.
|
// Eigene Bestellnummer, sonst greift zu Recht die Dedup-Logik.
|
||||||
const csv = [
|
const csv = [
|
||||||
@@ -152,7 +152,7 @@ describe('Loeschung eines Spielers', () => {
|
|||||||
const auditEntries = await db.prisma.auditLog.findMany({
|
const auditEntries = await db.prisma.auditLog.findMany({
|
||||||
where: { targetType: 'player', targetId: String(player.id) },
|
where: { targetType: 'player', targetId: String(player.id) },
|
||||||
});
|
});
|
||||||
assert.ok(auditEntries.length > 0, 'die Vorgaenge selbst bleiben nachvollziehbar');
|
assert.ok(auditEntries.length > 0, 'die Vorgänge selbst bleiben nachvollziehbar');
|
||||||
for (const entry of auditEntries) {
|
for (const entry of auditEntries) {
|
||||||
const details = JSON.stringify(entry.details ?? {});
|
const details = JSON.stringify(entry.details ?? {});
|
||||||
assert.ok(
|
assert.ok(
|
||||||
@@ -164,7 +164,7 @@ describe('Loeschung eines Spielers', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Aufbewahrung des Audit-Logs', () => {
|
describe('Aufbewahrung des Audit-Logs', () => {
|
||||||
it('loescht Eintraege jenseits der Frist, neuere bleiben', async () => {
|
it('löscht Einträge jenseits der Frist, neuere bleiben', async () => {
|
||||||
const alt = new Date(Date.now() - 400 * 24 * 3600 * 1000);
|
const alt = new Date(Date.now() - 400 * 24 * 3600 * 1000);
|
||||||
|
|
||||||
const veraltet = await db.prisma.auditLog.create({
|
const veraltet = await db.prisma.auditLog.create({
|
||||||
|
|||||||
+17
-17
@@ -40,7 +40,7 @@ after(async () => {
|
|||||||
await database.stop();
|
await database.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Guthaben-Toepfe', () => {
|
describe('Guthaben-Töpfe', () => {
|
||||||
it('ein CC-Paket erzeugt CC UND bezahltes Redesign', async () => {
|
it('ein CC-Paket erzeugt CC UND bezahltes Redesign', async () => {
|
||||||
const playerId = await freshPlayer('Paket-Spieler');
|
const playerId = await freshPlayer('Paket-Spieler');
|
||||||
const { balances } = await credits.bookCcPackage(playerId, ctx);
|
const { balances } = await credits.bookCcPackage(playerId, ctx);
|
||||||
@@ -58,7 +58,7 @@ describe('Guthaben-Toepfe', () => {
|
|||||||
assert.equal(balances.REDESIGN_PAID, 1);
|
assert.equal(balances.REDESIGN_PAID, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Kulanz landet im eigenen Topf und zaehlt zur Redesign-Summe', async () => {
|
it('Kulanz landet im eigenen Topf und zählt zur Redesign-Summe', async () => {
|
||||||
const playerId = await freshPlayer('Kulanz-Spieler');
|
const playerId = await freshPlayer('Kulanz-Spieler');
|
||||||
const { balances } = await credits.bookKulanz(playerId, ctx);
|
const { balances } = await credits.bookKulanz(playerId, ctx);
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ describe('Guthaben-Toepfe', () => {
|
|||||||
assert.equal(balances.redesignTotal, 1);
|
assert.equal(balances.redesignTotal, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ein ueberzogenes Konto wird zugelassen, nicht blockiert', async () => {
|
it('ein überzogenes Konto wird zugelassen, nicht blockiert', async () => {
|
||||||
const playerId = await freshPlayer('Minus-Spieler');
|
const playerId = await freshPlayer('Minus-Spieler');
|
||||||
const result = await characters.createCharacter(playerId, { name: 'Vorab-CC' }, ctx);
|
const result = await characters.createCharacter(playerId, { name: 'Vorab-CC' }, ctx);
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@ describe('CC-Lebenszyklus', () => {
|
|||||||
assert.equal(
|
assert.equal(
|
||||||
result.character.lineageId,
|
result.character.lineageId,
|
||||||
first.character.lineageId,
|
first.character.lineageId,
|
||||||
'die Kette behaelt ihre lineageId',
|
'die Kette behält ihre lineageId',
|
||||||
);
|
);
|
||||||
assert.equal(result.balances.REDESIGN_PAID, 0);
|
assert.equal(result.balances.REDESIGN_PAID, 0);
|
||||||
});
|
});
|
||||||
@@ -130,7 +130,7 @@ describe('CC-Lebenszyklus', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Reaktivierung', () => {
|
describe('Reaktivierung', () => {
|
||||||
it('ein wegen Inaktivitaet entfernter CC kommt gratis zurueck', async () => {
|
it('ein wegen Inaktivität entfernter CC kommt gratis zurück', async () => {
|
||||||
const playerId = await freshPlayer('Inaktiver');
|
const playerId = await freshPlayer('Inaktiver');
|
||||||
await credits.bookCcPackage(playerId, ctx);
|
await credits.bookCcPackage(playerId, ctx);
|
||||||
const created = await characters.createCharacter(playerId, { name: 'Geputzt' }, ctx);
|
const created = await characters.createCharacter(playerId, { name: 'Geputzt' }, ctx);
|
||||||
@@ -144,7 +144,7 @@ describe('Reaktivierung', () => {
|
|||||||
assert.deepEqual(result.balances, before, 'kein Guthaben wurde bewegt');
|
assert.deepEqual(result.balances, before, 'kein Guthaben wurde bewegt');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ein reworkter CC kommt NICHT gratis zurueck', async () => {
|
it('ein reworkter CC kommt NICHT gratis zurück', async () => {
|
||||||
const playerId = await freshPlayer('Rework-Spieler');
|
const playerId = await freshPlayer('Rework-Spieler');
|
||||||
await credits.bookCcPackage(playerId, ctx);
|
await credits.bookCcPackage(playerId, ctx);
|
||||||
const first = await characters.createCharacter(playerId, { name: 'Alt' }, ctx);
|
const first = await characters.createCharacter(playerId, { name: 'Alt' }, ctx);
|
||||||
@@ -160,7 +160,7 @@ describe('Reaktivierung', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ein reworkter CC kommt gegen CC-Guthaben zurueck', async () => {
|
it('ein reworkter CC kommt gegen CC-Guthaben zurück', async () => {
|
||||||
const playerId = await freshPlayer('Wiederbeleber');
|
const playerId = await freshPlayer('Wiederbeleber');
|
||||||
await credits.bookCcPackage(playerId, ctx);
|
await credits.bookCcPackage(playerId, ctx);
|
||||||
await credits.bookCcPackage(playerId, ctx);
|
await credits.bookCcPackage(playerId, ctx);
|
||||||
@@ -178,7 +178,7 @@ describe('Reaktivierung', () => {
|
|||||||
assert.equal(result.balances.CC, before.CC - 1);
|
assert.equal(result.balances.CC, before.CC - 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('auch ein aus anderem Grund entfernter CC kommt gratis zurueck', async () => {
|
it('auch ein aus anderem Grund entfernter CC kommt gratis zurück', async () => {
|
||||||
const playerId = await freshPlayer('Einheit-weg');
|
const playerId = await freshPlayer('Einheit-weg');
|
||||||
await credits.bookCcPackage(playerId, ctx);
|
await credits.bookCcPackage(playerId, ctx);
|
||||||
const created = await characters.createCharacter(playerId, { name: 'MTF-Operativer' }, ctx);
|
const created = await characters.createCharacter(playerId, { name: 'MTF-Operativer' }, ctx);
|
||||||
@@ -187,19 +187,19 @@ describe('Reaktivierung', () => {
|
|||||||
created.character.id,
|
created.character.id,
|
||||||
'OTHER',
|
'OTHER',
|
||||||
ctx,
|
ctx,
|
||||||
'Einheit bis Maerz entfernt',
|
'Einheit bis März entfernt',
|
||||||
);
|
);
|
||||||
|
|
||||||
const deactivated = await db.prisma.character.findUniqueOrThrow({
|
const deactivated = await db.prisma.character.findUniqueOrThrow({
|
||||||
where: { id: created.character.id },
|
where: { id: created.character.id },
|
||||||
});
|
});
|
||||||
assert.equal(deactivated.inactiveNote, 'Einheit bis Maerz entfernt', 'der Grund bleibt lesbar');
|
assert.equal(deactivated.inactiveNote, 'Einheit bis März entfernt', 'der Grund bleibt lesbar');
|
||||||
|
|
||||||
const before = await credits.getBalances(playerId);
|
const before = await credits.getBalances(playerId);
|
||||||
const result = await characters.reactivateFree(created.character.id, ctx);
|
const result = await characters.reactivateFree(created.character.id, ctx);
|
||||||
|
|
||||||
assert.equal(result.character.status, 'ACTIVE');
|
assert.equal(result.character.status, 'ACTIVE');
|
||||||
assert.equal(result.character.inactiveNote, null, 'die Notiz wird beim Zurueckholen geloescht');
|
assert.equal(result.character.inactiveNote, null, 'die Notiz wird beim Zurückholen gelöscht');
|
||||||
assert.deepEqual(result.balances, before, 'kein Guthaben wurde bewegt');
|
assert.deepEqual(result.balances, before, 'kein Guthaben wurde bewegt');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -222,7 +222,7 @@ describe('Reaktivierung', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Korrekturen', () => {
|
describe('Korrekturen', () => {
|
||||||
it('ein Paket-Storno nimmt beide Zeilen gemeinsam zurueck', async () => {
|
it('ein Paket-Storno nimmt beide Zeilen gemeinsam zurück', async () => {
|
||||||
const playerId = await freshPlayer('Storno-Spieler');
|
const playerId = await freshPlayer('Storno-Spieler');
|
||||||
const booked = await credits.bookCcPackage(playerId, ctx);
|
const booked = await credits.bookCcPackage(playerId, ctx);
|
||||||
|
|
||||||
@@ -236,7 +236,7 @@ describe('Korrekturen', () => {
|
|||||||
assert.equal(result.balances.REDESIGN_PAID, 0);
|
assert.equal(result.balances.REDESIGN_PAID, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('dieselbe Buchung laesst sich kein zweites Mal stornieren', async () => {
|
it('dieselbe Buchung lässt sich kein zweites Mal stornieren', async () => {
|
||||||
const playerId = await freshPlayer('Doppel-Storno');
|
const playerId = await freshPlayer('Doppel-Storno');
|
||||||
const booked = await credits.bookKulanz(playerId, ctx);
|
const booked = await credits.bookKulanz(playerId, ctx);
|
||||||
const entry = await db.prisma.creditLedger.findFirstOrThrow({
|
const entry = await db.prisma.creditLedger.findFirstOrThrow({
|
||||||
@@ -266,8 +266,8 @@ describe('Korrekturen', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('eine Ruecknahme bucht das Guthaben zurueck und reaktiviert den Vorgaenger', async () => {
|
it('eine Rücknahme bucht das Guthaben zurück und reaktiviert den Vorgänger', async () => {
|
||||||
const playerId = await freshPlayer('Ruecknehmer');
|
const playerId = await freshPlayer('Rücknehmer');
|
||||||
await credits.bookCcPackage(playerId, ctx);
|
await credits.bookCcPackage(playerId, ctx);
|
||||||
const first = await characters.createCharacter(playerId, { name: 'Original' }, ctx);
|
const first = await characters.createCharacter(playerId, { name: 'Original' }, ctx);
|
||||||
const redesigned = await characters.redesignCharacter(
|
const redesigned = await characters.redesignCharacter(
|
||||||
@@ -292,7 +292,7 @@ describe('Korrekturen', () => {
|
|||||||
assert.equal(restored.inactiveReason, null);
|
assert.equal(restored.inactiveReason, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('eine Ruecknahme mitten in der Kette wird abgelehnt', async () => {
|
it('eine Rücknahme mitten in der Kette wird abgelehnt', async () => {
|
||||||
const playerId = await freshPlayer('Ketten-Spieler');
|
const playerId = await freshPlayer('Ketten-Spieler');
|
||||||
await credits.bookCcPackage(playerId, ctx);
|
await credits.bookCcPackage(playerId, ctx);
|
||||||
await credits.bookRedesignPackage(playerId, ctx);
|
await credits.bookRedesignPackage(playerId, ctx);
|
||||||
@@ -309,7 +309,7 @@ describe('Korrekturen', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() => characters.revertCharacter(second.character.id, 'Zu spaet', ctx),
|
() => characters.revertCharacter(second.character.id, 'Zu spät', ctx),
|
||||||
(error: Error & { code?: string }) => error.code === 'HAS_SUCCESSORS',
|
(error: Error & { code?: string }) => error.code === 'HAS_SUCCESSORS',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ describe('Profil auslesen', () => {
|
|||||||
assert.equal(cc.purchasedAt?.getDate(), 6);
|
assert.equal(cc.purchasedAt?.getDate(), 6);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('erkennt stornierte Kaeufe', () => {
|
it('erkennt stornierte Käufe', () => {
|
||||||
const revoked = profile.packages.find((p) => p.packageBoughtId === 9007);
|
const revoked = profile.packages.find((p) => p.packageBoughtId === 9007);
|
||||||
assert.equal(revoked?.revoked, true);
|
assert.equal(revoked?.revoked, true);
|
||||||
});
|
});
|
||||||
@@ -54,7 +54,7 @@ describe('Profil auslesen', () => {
|
|||||||
'donations_data[9004] = {kaputt"id"',
|
'donations_data[9004] = {kaputt"id"',
|
||||||
);
|
);
|
||||||
const result = parseProfile(broken, '76561198000000999');
|
const result = parseProfile(broken, '76561198000000999');
|
||||||
assert.equal(result.packages.length, 7, 'nur die kaputte Zeile faellt weg');
|
assert.equal(result.packages.length, 7, 'nur die kaputte Zeile fällt weg');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -73,21 +73,21 @@ describe('Pakete zuordnen', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('zaehlt "Custom Character Redesign" als Redesign, nicht als CC', () => {
|
it('zählt "Custom Character Redesign" als Redesign, nicht als CC', () => {
|
||||||
assert.deepEqual(classifyPackage(pkg({ packageName: 'Custom Character Redesign' })), {
|
assert.deepEqual(classifyPackage(pkg({ packageName: 'Custom Character Redesign' })), {
|
||||||
kind: 'package',
|
kind: 'package',
|
||||||
target: 'REDESIGN_PACKAGE',
|
target: 'REDESIGN_PACKAGE',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('liest den Verbrauchszaehler samt Anzahl', () => {
|
it('liest den Verbrauchszähler samt Anzahl', () => {
|
||||||
const result = classifyPackage(
|
const result = classifyPackage(
|
||||||
pkg({ packageName: 'CC-Paket oder Redesign genutzt | 3 Mal' }),
|
pkg({ packageName: 'CC-Paket oder Redesign genutzt | 3 Mal' }),
|
||||||
);
|
);
|
||||||
assert.deepEqual(result, { kind: 'usage_counter', times: 3 });
|
assert.deepEqual(result, { kind: 'usage_counter', times: 3 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignoriert Merch und Raenge trotz SCP-RP-Tag', () => {
|
it('ignoriert Merch und Ränge trotz SCP-RP-Tag', () => {
|
||||||
assert.equal(classifyPackage(pkg({ packageName: 'FG-Tasse | Merch' })).kind, 'ignore');
|
assert.equal(classifyPackage(pkg({ packageName: 'FG-Tasse | Merch' })).kind, 'ignore');
|
||||||
assert.equal(classifyPackage(pkg({ packageName: '25 Ingame-Level' })).kind, 'ignore');
|
assert.equal(classifyPackage(pkg({ packageName: '25 Ingame-Level' })).kind, 'ignore');
|
||||||
assert.equal(classifyPackage(pkg({ packageName: 'VIP (12 Monate)' })).kind, 'ignore');
|
assert.equal(classifyPackage(pkg({ packageName: 'VIP (12 Monate)' })).kind, 'ignore');
|
||||||
@@ -105,7 +105,7 @@ describe('Pakete zuordnen', () => {
|
|||||||
assert.equal(result.kind, 'ignore');
|
assert.equal(result.kind, 'ignore');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('schiebt unklare CC-Titel in die Handpruefung statt zu raten', () => {
|
it('schiebt unklare CC-Titel in die Handprüfung statt zu raten', () => {
|
||||||
assert.equal(classifyPackage(pkg({ packageName: 'CC Sonderaktion Winter' })).kind, 'unclear');
|
assert.equal(classifyPackage(pkg({ packageName: 'CC Sonderaktion Winter' })).kind, 'unclear');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -116,13 +116,13 @@ describe('SteamID-Schreibweisen', () => {
|
|||||||
const modern = '[U:1:39735271]';
|
const modern = '[U:1:39735271]';
|
||||||
const sixtyFour = '76561198000000999';
|
const sixtyFour = '76561198000000999';
|
||||||
|
|
||||||
it('fuehrt alle drei Formate auf dieselbe ID zusammen', () => {
|
it('führt alle drei Formate auf dieselbe ID zusammen', () => {
|
||||||
assert.equal(normalizeSteamId(legacy), sixtyFour);
|
assert.equal(normalizeSteamId(legacy), sixtyFour);
|
||||||
assert.equal(normalizeSteamId(modern), sixtyFour);
|
assert.equal(normalizeSteamId(modern), sixtyFour);
|
||||||
assert.equal(normalizeSteamId(sixtyFour), sixtyFour);
|
assert.equal(normalizeSteamId(sixtyFour), sixtyFour);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rechnet zurueck in die Legacy-Form', () => {
|
it('rechnet zurück in die Legacy-Form', () => {
|
||||||
assert.equal(toLegacySteamId(sixtyFour), legacy);
|
assert.equal(toLegacySteamId(sixtyFour), legacy);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ export default function BalanceCards({ balances }: { balances: Balances }) {
|
|||||||
|
|
||||||
<div className="eligibility">
|
<div className="eligibility">
|
||||||
<span className={canCreate ? 'ok' : 'no'}>
|
<span className={canCreate ? 'ok' : 'no'}>
|
||||||
{canCreate ? '✓' : '✕'} Neuer CC {canCreate ? 'moeglich' : 'nicht moeglich'}
|
{canCreate ? '✓' : '✕'} Neuer CC {canCreate ? 'möglich' : 'nicht möglich'}
|
||||||
</span>
|
</span>
|
||||||
<span className={canRedesign ? 'ok' : 'no'}>
|
<span className={canRedesign ? 'ok' : 'no'}>
|
||||||
{canRedesign ? '✓' : '✕'} Redesign {canRedesign ? 'moeglich' : 'nicht moeglich'}
|
{canRedesign ? '✓' : '✕'} Redesign {canRedesign ? 'möglich' : 'nicht möglich'}
|
||||||
{canRedesign && ` (${balances.redesignTotal} verfuegbar)`}
|
{canRedesign && ` (${balances.redesignTotal} verfügbar)`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export default function Modal({ title, onClose, children, footer }: ModalProps)
|
|||||||
>
|
>
|
||||||
<div className="modal-head">
|
<div className="modal-head">
|
||||||
<h2>{title}</h2>
|
<h2>{title}</h2>
|
||||||
<button type="button" className="btn btn-ghost" onClick={onClose} aria-label="Schliessen">
|
<button type="button" className="btn btn-ghost" onClick={onClose} aria-label="Schließen">
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -47,10 +47,10 @@ export default function UnmatchedBox({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="block block-flag">
|
<section className="block block-flag">
|
||||||
<h2>Ungeklaerte Eintraege ({items.length})</h2>
|
<h2>Ungeklärte Einträge ({items.length})</h2>
|
||||||
<Notice kind="warn">
|
<Notice kind="warn">
|
||||||
Diese Pakete stammen aus dem GExtension-Abgleich und klingen nach CC oder Redesign,
|
Diese Pakete stammen aus dem GExtension-Abgleich und klingen nach CC oder Redesign,
|
||||||
liessen sich aber keiner Guthaben-Art sicher zuordnen. Sie werden erst gebucht, wenn du
|
ließen sich aber keiner Guthaben-Art sicher zuordnen. Sie werden erst gebucht, wenn du
|
||||||
sie hier einordnest.
|
sie hier einordnest.
|
||||||
</Notice>
|
</Notice>
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -62,7 +62,7 @@ export async function api<T>(path: string, options: RequestOptions = {}): Promis
|
|||||||
return payload as T;
|
return payload as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Datei-Upload laeuft ohne JSON-Header, aber mit demselben CSRF-Schutz. */
|
/** Datei-Upload läuft ohne JSON-Header, aber mit demselben CSRF-Schutz. */
|
||||||
export async function uploadFile<T>(path: string, file: File): Promise<T> {
|
export async function uploadFile<T>(path: string, file: File): Promise<T> {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append('file', file);
|
form.append('file', file);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export const SOURCE_LABEL: Record<CreditSource, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const REASON_LABEL: Record<InactiveReason, string> = {
|
export const REASON_LABEL: Record<InactiveReason, string> = {
|
||||||
PLAYER_INACTIVITY: 'Spieler-Inaktivitaet',
|
PLAYER_INACTIVITY: 'Spieler-Inaktivität',
|
||||||
REWORKED: 'Redesignt',
|
REWORKED: 'Redesignt',
|
||||||
OTHER: 'Anderer Grund',
|
OTHER: 'Anderer Grund',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -58,6 +58,6 @@ export function SessionProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
export function useSession(): SessionState {
|
export function useSession(): SessionState {
|
||||||
const context = useContext(SessionContext);
|
const context = useContext(SessionContext);
|
||||||
if (!context) throw new Error('useSession benoetigt einen SessionProvider');
|
if (!context) throw new Error('useSession benötigt einen SessionProvider');
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-11
@@ -23,7 +23,7 @@ export default function Account() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
if (next !== repeat) {
|
if (next !== repeat) {
|
||||||
setError('Die beiden neuen Passwoerter stimmen nicht ueberein');
|
setError('Die beiden neuen Passwörter stimmen nicht überein');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,10 +37,10 @@ export default function Account() {
|
|||||||
setNext('');
|
setNext('');
|
||||||
setRepeat('');
|
setRepeat('');
|
||||||
await refresh();
|
await refresh();
|
||||||
setFlash('Passwort geaendert. Andere Sitzungen wurden beendet.');
|
setFlash('Passwort geändert. Andere Sitzungen wurden beendet.');
|
||||||
if (user?.mustChangePassword) navigate('/', { replace: true });
|
if (user?.mustChangePassword) navigate('/', { replace: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ApiError ? err.message : 'Aenderung fehlgeschlagen');
|
setError(err instanceof ApiError ? err.message : 'Änderung fehlgeschlagen');
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -97,8 +97,8 @@ export default function Account() {
|
|||||||
|
|
||||||
{user?.mustChangePassword && (
|
{user?.mustChangePassword && (
|
||||||
<Notice kind="warn">
|
<Notice kind="warn">
|
||||||
Dieses Konto verwendet noch ein vergebenes Erstpasswort. Bitte jetzt aendern — die
|
Dieses Konto verwendet noch ein vergebenes Erstpasswort. Bitte jetzt ändern — die
|
||||||
uebrigen Seiten sind bis dahin gesperrt.
|
übrigen Seiten sind bis dahin gesperrt.
|
||||||
</Notice>
|
</Notice>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ export default function Account() {
|
|||||||
{flash && <Notice kind="success" onDismiss={() => setFlash(null)}>{flash}</Notice>}
|
{flash && <Notice kind="success" onDismiss={() => setFlash(null)}>{flash}</Notice>}
|
||||||
|
|
||||||
<section className="block">
|
<section className="block">
|
||||||
<h2>Passwort aendern</h2>
|
<h2>Passwort ändern</h2>
|
||||||
<form onSubmit={changePassword}>
|
<form onSubmit={changePassword}>
|
||||||
<label htmlFor="current">Aktuelles Passwort</label>
|
<label htmlFor="current">Aktuelles Passwort</label>
|
||||||
<input
|
<input
|
||||||
@@ -128,7 +128,7 @@ export default function Account() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<p className="muted small">
|
<p className="muted small">
|
||||||
Mindestens 12 Zeichen, Gross- und Kleinbuchstaben sowie eine Ziffer.
|
Mindestens 12 Zeichen, Groß- und Kleinbuchstaben sowie eine Ziffer.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<label htmlFor="repeat">Neues Passwort wiederholen</label>
|
<label htmlFor="repeat">Neues Passwort wiederholen</label>
|
||||||
@@ -142,7 +142,7 @@ export default function Account() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||||
{busy ? 'Speichere …' : 'Passwort aendern'}
|
{busy ? 'Speichere …' : 'Passwort ändern'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
@@ -152,7 +152,7 @@ export default function Account() {
|
|||||||
|
|
||||||
{user?.totpEnabled ? (
|
{user?.totpEnabled ? (
|
||||||
<>
|
<>
|
||||||
<Notice kind="success">Zwei-Faktor-Anmeldung ist fuer dieses Konto aktiv.</Notice>
|
<Notice kind="success">Zwei-Faktor-Anmeldung ist für dieses Konto aktiv.</Notice>
|
||||||
<button type="button" className="btn btn-danger" onClick={() => void disableTotp()}>
|
<button type="button" className="btn btn-danger" onClick={() => void disableTotp()}>
|
||||||
Deaktivieren
|
Deaktivieren
|
||||||
</button>
|
</button>
|
||||||
@@ -164,7 +164,7 @@ export default function Account() {
|
|||||||
</p>
|
</p>
|
||||||
<img className="qr" src={totpSetup.qr} alt="QR-Code zur Einrichtung" />
|
<img className="qr" src={totpSetup.qr} alt="QR-Code zur Einrichtung" />
|
||||||
<p className="muted small">
|
<p className="muted small">
|
||||||
Falls Scannen nicht geht, Schluessel manuell eintragen:{' '}
|
Falls Scannen nicht geht, Schlüssel manuell eintragen:{' '}
|
||||||
<code className="mono">{totpSetup.secret}</code>
|
<code className="mono">{totpSetup.secret}</code>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -190,7 +190,7 @@ export default function Account() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
Schuetzt das Konto zusaetzlich mit einem Einmalcode. Fuer Administratoren empfohlen.
|
Schützt das Konto zusätzlich mit einem Einmalcode. Für Administratoren empfohlen.
|
||||||
</p>
|
</p>
|
||||||
<button type="button" className="btn" onClick={() => void startTotp()}>
|
<button type="button" className="btn" onClick={() => void startTotp()}>
|
||||||
Einrichten
|
Einrichten
|
||||||
|
|||||||
+19
-19
@@ -99,7 +99,7 @@ function UsersTab({ onError, onDone }: TabProps) {
|
|||||||
|
|
||||||
const resetPassword = (target: AdminUser) => {
|
const resetPassword = (target: AdminUser) => {
|
||||||
const password = window.prompt(
|
const password = window.prompt(
|
||||||
`Neues Passwort fuer "${target.username}" (min. 12 Zeichen, Gross/Klein, Ziffer):`,
|
`Neues Passwort für "${target.username}" (min. 12 Zeichen, Groß/Klein, Ziffer):`,
|
||||||
);
|
);
|
||||||
if (!password) return;
|
if (!password) return;
|
||||||
void act(
|
void act(
|
||||||
@@ -108,7 +108,7 @@ function UsersTab({ onError, onDone }: TabProps) {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { newPassword: password },
|
body: { newPassword: password },
|
||||||
}),
|
}),
|
||||||
`Passwort fuer "${target.username}" gesetzt. Muss beim naechsten Login geaendert werden.`,
|
`Passwort für "${target.username}" gesetzt. Muss beim nächsten Login geändert werden.`,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -122,7 +122,7 @@ function UsersTab({ onError, onDone }: TabProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Notice kind="info">
|
<Notice kind="info">
|
||||||
Es gibt keine oeffentliche Registrierung. Konten werden ausschliesslich hier angelegt.
|
Es gibt keine öffentliche Registrierung. Konten werden ausschließlich hier angelegt.
|
||||||
</Notice>
|
</Notice>
|
||||||
|
|
||||||
<div className="table-wrap">
|
<div className="table-wrap">
|
||||||
@@ -156,7 +156,7 @@ function UsersTab({ onError, onDone }: TabProps) {
|
|||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: { role: event.target.value as Role },
|
body: { role: event.target.value as Role },
|
||||||
}),
|
}),
|
||||||
`Rolle von "${row.username}" geaendert.`,
|
`Rolle von "${row.username}" geändert.`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -200,14 +200,14 @@ function UsersTab({ onError, onDone }: TabProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
className="btn btn-tiny btn-danger"
|
className="btn btn-tiny btn-danger"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!window.confirm(`Konto "${row.username}" wirklich loeschen?`)) return;
|
if (!window.confirm(`Konto "${row.username}" wirklich löschen?`)) return;
|
||||||
void act(
|
void act(
|
||||||
() => api(`/api/admin/users/${row.id}`, { method: 'DELETE' }),
|
() => api(`/api/admin/users/${row.id}`, { method: 'DELETE' }),
|
||||||
`"${row.username}" geloescht.`,
|
`"${row.username}" gelöscht.`,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Loeschen
|
Löschen
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -280,8 +280,8 @@ function CreateUserModal({
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<p className="muted small">
|
<p className="muted small">
|
||||||
Min. 12 Zeichen, Gross- und Kleinbuchstaben, eine Ziffer. Muss beim ersten Login
|
Min. 12 Zeichen, Groß- und Kleinbuchstaben, eine Ziffer. Muss beim ersten Login
|
||||||
geaendert werden.
|
geändert werden.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<label htmlFor="new-role">Rolle</label>
|
<label htmlFor="new-role">Rolle</label>
|
||||||
@@ -342,13 +342,13 @@ function MappingsTab({ onError, onDone }: TabProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const remove = async (row: ProductMapping) => {
|
const remove = async (row: ProductMapping) => {
|
||||||
if (!window.confirm(`Mapping "${row.matchPattern}" loeschen?`)) return;
|
if (!window.confirm(`Mapping "${row.matchPattern}" löschen?`)) return;
|
||||||
try {
|
try {
|
||||||
await api(`/api/admin/product-mappings/${row.id}`, { method: 'DELETE' });
|
await api(`/api/admin/product-mappings/${row.id}`, { method: 'DELETE' });
|
||||||
await load();
|
await load();
|
||||||
onDone('Mapping geloescht.');
|
onDone('Mapping gelöscht.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
onError(err instanceof ApiError ? err.message : 'Loeschen fehlgeschlagen');
|
onError(err instanceof ApiError ? err.message : 'Löschen fehlgeschlagen');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -356,8 +356,8 @@ function MappingsTab({ onError, onDone }: TabProps) {
|
|||||||
<section className="block">
|
<section className="block">
|
||||||
<h2>Produkt-Mappings</h2>
|
<h2>Produkt-Mappings</h2>
|
||||||
<Notice kind="info">
|
<Notice kind="info">
|
||||||
Beim Import wird der Produktname gegen diese Muster geprueft (Teiltreffer, Gross- und
|
Beim Import wird der Produktname gegen diese Muster geprüft (Teiltreffer, Groß- und
|
||||||
Kleinschreibung egal). Das erste passende Muster mit der niedrigsten Prioritaet gewinnt.
|
Kleinschreibung egal). Das erste passende Muster mit der niedrigsten Priorität gewinnt.
|
||||||
</Notice>
|
</Notice>
|
||||||
|
|
||||||
<form className="inline-form" onSubmit={add}>
|
<form className="inline-form" onSubmit={add}>
|
||||||
@@ -373,7 +373,7 @@ function MappingsTab({ onError, onDone }: TabProps) {
|
|||||||
<option value="IGNORE">Ignorieren</option>
|
<option value="IGNORE">Ignorieren</option>
|
||||||
</select>
|
</select>
|
||||||
<button type="submit" className="btn btn-primary">
|
<button type="submit" className="btn btn-primary">
|
||||||
Hinzufuegen
|
Hinzufügen
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -383,7 +383,7 @@ function MappingsTab({ onError, onDone }: TabProps) {
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Muster</th>
|
<th>Muster</th>
|
||||||
<th>Bedeutung</th>
|
<th>Bedeutung</th>
|
||||||
<th className="num">Prioritaet</th>
|
<th className="num">Priorität</th>
|
||||||
<th>Aktiv</th>
|
<th>Aktiv</th>
|
||||||
<th />
|
<th />
|
||||||
</tr>
|
</tr>
|
||||||
@@ -401,7 +401,7 @@ function MappingsTab({ onError, onDone }: TabProps) {
|
|||||||
className="btn btn-tiny btn-danger"
|
className="btn btn-tiny btn-danger"
|
||||||
onClick={() => void remove(row)}
|
onClick={() => void remove(row)}
|
||||||
>
|
>
|
||||||
Loeschen
|
Löschen
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -489,7 +489,7 @@ function AuditTab({ onError }: { onError: (message: string) => void }) {
|
|||||||
{items.length === 0 && (
|
{items.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="empty">
|
<td colSpan={5} className="empty">
|
||||||
Keine Eintraege.
|
Keine Einträge.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
@@ -499,7 +499,7 @@ function AuditTab({ onError }: { onError: (message: string) => void }) {
|
|||||||
|
|
||||||
{total > items.length && (
|
{total > items.length && (
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
{items.length} von {total} Eintraegen angezeigt.
|
{items.length} von {total} Einträgen angezeigt.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export default function Dashboard() {
|
|||||||
try {
|
try {
|
||||||
setFlagged(await api<UnmatchedOverview>('/api/scrape/unmatched/overview'));
|
setFlagged(await api<UnmatchedOverview>('/api/scrape/unmatched/overview'));
|
||||||
} catch {
|
} catch {
|
||||||
// Der Hinweis ist Beiwerk - ein Fehler hier darf die Liste nicht stoeren.
|
// Der Hinweis ist Beiwerk - ein Fehler hier darf die Liste nicht stören.
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -62,9 +62,9 @@ export default function Dashboard() {
|
|||||||
setSyncResult(null);
|
setSyncResult(null);
|
||||||
try {
|
try {
|
||||||
const result = await api<ScrapeSummary>('/api/scrape/run', { method: 'POST', body: {} });
|
const result = await api<ScrapeSummary>('/api/scrape/run', { method: 'POST', body: {} });
|
||||||
const parts = [`${result.scannedPlayers} Profile geprueft`];
|
const parts = [`${result.scannedPlayers} Profile geprüft`];
|
||||||
if (result.newRows > 0) parts.push(`${result.newRows} neue Pakete zur Bestaetigung`);
|
if (result.newRows > 0) parts.push(`${result.newRows} neue Pakete zur Bestätigung`);
|
||||||
if (result.unclear > 0) parts.push(`${result.unclear} ungeklaert`);
|
if (result.unclear > 0) parts.push(`${result.unclear} ungeklärt`);
|
||||||
if (result.duplicates > 0) parts.push(`${result.duplicates} schon gebucht`);
|
if (result.duplicates > 0) parts.push(`${result.duplicates} schon gebucht`);
|
||||||
if (result.failedPlayers.length > 0) parts.push(`${result.failedPlayers.length} nicht erreichbar`);
|
if (result.failedPlayers.length > 0) parts.push(`${result.failedPlayers.length} nicht erreichbar`);
|
||||||
setSyncResult(parts.join(', ') + '.');
|
setSyncResult(parts.join(', ') + '.');
|
||||||
@@ -76,7 +76,7 @@ export default function Dashboard() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Tippen entprellen, damit nicht jeder Anschlag eine Anfrage ausloest.
|
// Tippen entprellen, damit nicht jeder Anschlag eine Anfrage auslöst.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timer = setTimeout(() => void load(query, controller.signal), 250);
|
const timer = setTimeout(() => void load(query, controller.signal), 250);
|
||||||
@@ -119,7 +119,7 @@ export default function Dashboard() {
|
|||||||
{flagged.total > 0 && (
|
{flagged.total > 0 && (
|
||||||
<Notice kind="warn">
|
<Notice kind="warn">
|
||||||
<strong>
|
<strong>
|
||||||
{flagged.total} ungeklaerte {flagged.total === 1 ? 'Eintrag' : 'Eintraege'} bei{' '}
|
{flagged.total} ungeklärte {flagged.total === 1 ? 'Eintrag' : 'Einträge'} bei{' '}
|
||||||
{flagged.players.length} {flagged.players.length === 1 ? 'Spieler' : 'Spielern'}:
|
{flagged.players.length} {flagged.players.length === 1 ? 'Spieler' : 'Spielern'}:
|
||||||
</strong>{' '}
|
</strong>{' '}
|
||||||
{flagged.players.map((entry, index) => (
|
{flagged.players.map((entry, index) => (
|
||||||
@@ -251,7 +251,7 @@ function CreatePlayerModal({ onClose, onCreated }: { onClose: () => void; onCrea
|
|||||||
placeholder="76561198... oder STEAM_0:1:..."
|
placeholder="76561198... oder STEAM_0:1:..."
|
||||||
/>
|
/>
|
||||||
<p className="muted small">
|
<p className="muted small">
|
||||||
Beide Schreibweisen sind moeglich, auch <code className="mono">[U:1:...]</code>.
|
Beide Schreibweisen sind möglich, auch <code className="mono">[U:1:...]</code>.
|
||||||
Gespeichert wird immer die 64-Bit-Form, damit derselbe Spieler nicht doppelt anlegt.
|
Gespeichert wird immer die 64-Bit-Form, damit derselbe Spieler nicht doppelt anlegt.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const EMPTY_MAPPING: MappingForm = {
|
|||||||
createMissingPlayers: false,
|
createMissingPlayers: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Trifft die ueblichen Spaltennamen, damit das Formular meist schon passt.
|
// Trifft die üblichen Spaltennamen, damit das Formular meist schon passt.
|
||||||
const GUESSES: Record<keyof Omit<MappingForm, 'createMissingPlayers'>, string[]> = {
|
const GUESSES: Record<keyof Omit<MappingForm, 'createMissingPlayers'>, string[]> = {
|
||||||
playerKey: ['steamid', 'steam_id', 'steam', 'identifier', 'user', 'kunde'],
|
playerKey: ['steamid', 'steam_id', 'steam', 'identifier', 'user', 'kunde'],
|
||||||
playerName: ['name', 'username', 'displayname', 'spieler'],
|
playerName: ['name', 'username', 'displayname', 'spieler'],
|
||||||
@@ -158,8 +158,8 @@ export default function Import() {
|
|||||||
<section className="block">
|
<section className="block">
|
||||||
<Notice kind="info">
|
<Notice kind="info">
|
||||||
CSV oder JSON hochladen. Danach werden die Spalten zugeordnet und eine Vorschau
|
CSV oder JSON hochladen. Danach werden die Spalten zugeordnet und eine Vorschau
|
||||||
gezeigt — gebucht wird erst nach Bestaetigung. Bereits importierte Bestell-IDs
|
gezeigt — gebucht wird erst nach Bestätigung. Bereits importierte Bestell-IDs
|
||||||
werden automatisch uebersprungen.
|
werden automatisch übersprungen.
|
||||||
</Notice>
|
</Notice>
|
||||||
|
|
||||||
<label className="file-drop">
|
<label className="file-drop">
|
||||||
@@ -172,7 +172,7 @@ export default function Import() {
|
|||||||
}}
|
}}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
/>
|
/>
|
||||||
<span>{busy ? 'Lade hoch …' : 'Datei auswaehlen'}</span>
|
<span>{busy ? 'Lade hoch …' : 'Datei auswählen'}</span>
|
||||||
</label>
|
</label>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
@@ -207,7 +207,7 @@ export default function Import() {
|
|||||||
onChange={(value) => setMapping({ ...mapping, playerName: value })}
|
onChange={(value) => setMapping({ ...mapping, playerName: value })}
|
||||||
/>
|
/>
|
||||||
<MappingSelect
|
<MappingSelect
|
||||||
label="Bestell-ID (fuer Dedup)"
|
label="Bestell-ID (für Dedup)"
|
||||||
value={mapping.orderId}
|
value={mapping.orderId}
|
||||||
columns={upload.columns}
|
columns={upload.columns}
|
||||||
onChange={(value) => setMapping({ ...mapping, orderId: value })}
|
onChange={(value) => setMapping({ ...mapping, orderId: value })}
|
||||||
@@ -233,7 +233,7 @@ export default function Import() {
|
|||||||
|
|
||||||
{!mapping.orderId && (
|
{!mapping.orderId && (
|
||||||
<Notice kind="warn">
|
<Notice kind="warn">
|
||||||
Ohne Bestell-ID kann ein zweiter Import dieselben Kaeufe erneut buchen.
|
Ohne Bestell-ID kann ein zweiter Import dieselben Käufe erneut buchen.
|
||||||
</Notice>
|
</Notice>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -293,7 +293,7 @@ export default function Import() {
|
|||||||
|
|
||||||
{bookable === 0 ? (
|
{bookable === 0 ? (
|
||||||
<Notice kind="warn">
|
<Notice kind="warn">
|
||||||
Keine Zeile wuerde gebucht. Pruefe die Spaltenzuordnung oder die Produkt-Mappings
|
Keine Zeile würde gebucht. Prüfe die Spaltenzuordnung oder die Produkt-Mappings
|
||||||
in der Verwaltung.
|
in der Verwaltung.
|
||||||
</Notice>
|
</Notice>
|
||||||
) : (
|
) : (
|
||||||
@@ -342,7 +342,7 @@ export default function Import() {
|
|||||||
Verwerfen
|
Verwerfen
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn btn-ghost" onClick={() => setPreview(null)}>
|
<button type="button" className="btn btn-ghost" onClick={() => setPreview(null)}>
|
||||||
Zuordnung aendern
|
Zuordnung ändern
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export default function Login() {
|
|||||||
<div className="login-card">
|
<div className="login-card">
|
||||||
<h1>CC-Manager</h1>
|
<h1>CC-Manager</h1>
|
||||||
<p className="login-sub">
|
<p className="login-sub">
|
||||||
{challenge ? 'Bestaetigungscode eingeben' : 'Bitte anmelden'}
|
{challenge ? 'Bestätigungscode eingeben' : 'Bitte anmelden'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{error && <Notice kind="error">{error}</Notice>}
|
{error && <Notice kind="error">{error}</Notice>}
|
||||||
@@ -94,7 +94,7 @@ export default function Login() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<button type="submit" className="btn btn-primary btn-block" disabled={busy}>
|
<button type="submit" className="btn btn-primary btn-block" disabled={busy}>
|
||||||
{busy ? 'Pruefe …' : 'Bestaetigen'}
|
{busy ? 'Prüfe …' : 'Bestätigen'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -105,7 +105,7 @@ export default function Login() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Zurueck
|
Zurück
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ export default function PlayerDetail() {
|
|||||||
className="btn btn-small btn-danger"
|
className="btn btn-small btn-danger"
|
||||||
onClick={() => setDialog({ type: 'revert', character })}
|
onClick={() => setDialog({ type: 'revert', character })}
|
||||||
>
|
>
|
||||||
Zuruecknehmen
|
Zurücknehmen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -254,7 +254,7 @@ export default function PlayerDetail() {
|
|||||||
)}
|
)}
|
||||||
<p className="muted small">
|
<p className="muted small">
|
||||||
{free
|
{free
|
||||||
? 'Kommt gratis zurueck — kostet kein Guthaben.'
|
? 'Kommt gratis zurück — kostet kein Guthaben.'
|
||||||
: 'Reaktivierung kostet ein Redesign- oder CC-Guthaben.'}
|
: 'Reaktivierung kostet ein Redesign- oder CC-Guthaben.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -363,7 +363,7 @@ export default function PlayerDetail() {
|
|||||||
<CharacterFormModal
|
<CharacterFormModal
|
||||||
title="Neuen CC anlegen"
|
title="Neuen CC anlegen"
|
||||||
submitLabel="Anlegen"
|
submitLabel="Anlegen"
|
||||||
hint="Kostet 1 CC-Guthaben. Kulanz-Guthaben ist dafuer nicht einsetzbar."
|
hint="Kostet 1 CC-Guthaben. Kulanz-Guthaben ist dafür nicht einsetzbar."
|
||||||
busy={busy}
|
busy={busy}
|
||||||
onClose={() => setDialog({ type: 'none' })}
|
onClose={() => setDialog({ type: 'none' })}
|
||||||
onSubmit={(values) =>
|
onSubmit={(values) =>
|
||||||
@@ -382,7 +382,7 @@ export default function PlayerDetail() {
|
|||||||
{dialog.type === 'redesign' && (
|
{dialog.type === 'redesign' && (
|
||||||
<CharacterFormModal
|
<CharacterFormModal
|
||||||
title={`Redesign von "${dialog.character.name}"`}
|
title={`Redesign von "${dialog.character.name}"`}
|
||||||
submitLabel="Redesign durchfuehren"
|
submitLabel="Redesign durchführen"
|
||||||
hint={`"${dialog.character.name}" wird inaktiv (redesignt), der neue CC tritt an seine Stelle.`}
|
hint={`"${dialog.character.name}" wird inaktiv (redesignt), der neue CC tritt an seine Stelle.`}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
withKind={['REDESIGN_KULANZ', 'REDESIGN_PAID']}
|
withKind={['REDESIGN_KULANZ', 'REDESIGN_PAID']}
|
||||||
@@ -442,9 +442,9 @@ export default function PlayerDetail() {
|
|||||||
|
|
||||||
{dialog.type === 'revert' && (
|
{dialog.type === 'revert' && (
|
||||||
<ReasonModal
|
<ReasonModal
|
||||||
title={`"${dialog.character.name}" zuruecknehmen`}
|
title={`"${dialog.character.name}" zurücknehmen`}
|
||||||
hint="Der CC wird geloescht, verbrauchte Guthaben werden zurueckgebucht. Fuer Fehleintraege gedacht."
|
hint="Der CC wird gelöscht, verbrauchte Guthaben werden zurückgebucht. Für Fehleinträge gedacht."
|
||||||
confirmLabel="Zuruecknehmen"
|
confirmLabel="Zurücknehmen"
|
||||||
danger
|
danger
|
||||||
busy={busy}
|
busy={busy}
|
||||||
onClose={() => setDialog({ type: 'none' })}
|
onClose={() => setDialog({ type: 'none' })}
|
||||||
@@ -455,7 +455,7 @@ export default function PlayerDetail() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { reason },
|
body: { reason },
|
||||||
}),
|
}),
|
||||||
`"${dialog.character.name}" wurde zurueckgenommen.`,
|
`"${dialog.character.name}" wurde zurückgenommen.`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -562,7 +562,7 @@ function CharacterFormModal({
|
|||||||
{KIND_LABEL[kind]}
|
{KIND_LABEL[kind]}
|
||||||
<span className={balances[kind] < 1 ? 'neg' : 'muted'}>
|
<span className={balances[kind] < 1 ? 'neg' : 'muted'}>
|
||||||
{' '}
|
{' '}
|
||||||
({balances[kind]} verfuegbar)
|
({balances[kind]} verfügbar)
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -627,7 +627,7 @@ function KindChoiceModal({
|
|||||||
{KIND_LABEL[kind]}
|
{KIND_LABEL[kind]}
|
||||||
<span className={balances[kind] < 1 ? 'neg' : 'muted'}>
|
<span className={balances[kind] < 1 ? 'neg' : 'muted'}>
|
||||||
{' '}
|
{' '}
|
||||||
({balances[kind]} verfuegbar)
|
({balances[kind]} verfügbar)
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -639,7 +639,7 @@ function KindChoiceModal({
|
|||||||
Abbrechen
|
Abbrechen
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||||
{busy ? 'Speichere …' : 'Bestaetigen'}
|
{busy ? 'Speichere …' : 'Bestätigen'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -670,8 +670,8 @@ function DeactivateModal({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Notice kind="info">
|
<Notice kind="info">
|
||||||
Der CC bleibt unveraendert und kommt spaeter in beiden Faellen gratis zurueck. Nur ein
|
Der CC bleibt unverändert und kommt später in beiden Fällen gratis zurück. Nur ein
|
||||||
Redesign macht ihn kostenpflichtig — das laeuft aber ueber die Redesign-Aktion.
|
Redesign macht ihn kostenpflichtig — das läuft aber über die Redesign-Aktion.
|
||||||
</Notice>
|
</Notice>
|
||||||
|
|
||||||
<div className="radio-group">
|
<div className="radio-group">
|
||||||
@@ -683,8 +683,8 @@ function DeactivateModal({
|
|||||||
onChange={() => setReason('PLAYER_INACTIVITY')}
|
onChange={() => setReason('PLAYER_INACTIVITY')}
|
||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
<strong>Spieler-Inaktivitaet</strong>
|
<strong>Spieler-Inaktivität</strong>
|
||||||
<span className="muted"> — vom Server geputzt, weil der Spieler laenger weg war</span>
|
<span className="muted"> — vom Server geputzt, weil der Spieler länger weg war</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="radio">
|
<label className="radio">
|
||||||
@@ -696,7 +696,7 @@ function DeactivateModal({
|
|||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
<strong>Anderer Grund</strong>
|
<strong>Anderer Grund</strong>
|
||||||
<span className="muted"> — etwa eine voruebergehend entfernte Einheit</span>
|
<span className="muted"> — etwa eine vorübergehend entfernte Einheit</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -708,7 +708,7 @@ function DeactivateModal({
|
|||||||
id="deactivate-note"
|
id="deactivate-note"
|
||||||
value={note}
|
value={note}
|
||||||
onChange={(event) => setNote(event.target.value)}
|
onChange={(event) => setNote(event.target.value)}
|
||||||
placeholder="z. B. Einheit bis Maerz entfernt"
|
placeholder="z. B. Einheit bis März entfernt"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="form-actions">
|
<div className="form-actions">
|
||||||
@@ -753,7 +753,7 @@ function ReasonModal({
|
|||||||
>
|
>
|
||||||
<Notice kind={danger ? 'warn' : 'info'}>{hint}</Notice>
|
<Notice kind={danger ? 'warn' : 'info'}>{hint}</Notice>
|
||||||
|
|
||||||
<label htmlFor="reason">Begruendung</label>
|
<label htmlFor="reason">Begründung</label>
|
||||||
<input
|
<input
|
||||||
id="reason"
|
id="reason"
|
||||||
value={reason}
|
value={reason}
|
||||||
|
|||||||
Reference in New Issue
Block a user