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:
2026-09-18 16:57:39 +02:00
parent b9364151b0
commit 724f68644b
49 changed files with 256 additions and 254 deletions
+8 -8
View File
@@ -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
View File
@@ -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/*
+3 -1
View File
@@ -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
View File
@@ -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}
+1 -1
View File
@@ -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
View File
@@ -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.
+6 -6
View File
@@ -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
View File
@@ -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');
} }
}, },
+2 -2
View File
@@ -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');
} }
} }
}; };
+2 -2
View File
@@ -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';
+1 -1
View File
@@ -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) } },
+1 -1
View File
@@ -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 -4
View File
@@ -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
View File
@@ -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);
} }
+1 -1
View File
@@ -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
View File
@@ -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();
+12 -12
View File
@@ -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 {
+3 -3
View File
@@ -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;
+1 -1
View File
@@ -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 } });
+3 -3
View File
@@ -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 },
+1 -1
View File
@@ -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));
+2 -2
View File
@@ -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));
+1 -1
View File
@@ -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) {
+3 -3
View File
@@ -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');
+1 -1
View File
@@ -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) => {
+4 -4
View File
@@ -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;
+19 -19
View File
@@ -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,
}, },
+9 -9
View File
@@ -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) {
+13 -13
View File
@@ -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;
+8 -8
View File
@@ -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,
+5 -5
View File
@@ -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.
*/ */
+2 -2
View File
@@ -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
View File
@@ -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');
+4 -4
View File
@@ -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);
+6 -6
View File
@@ -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
View File
@@ -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',
); );
}); });
+8 -8
View File
@@ -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);
}); });
+3 -3
View File
@@ -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>
+1 -1
View File
@@ -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>
+2 -2
View File
@@ -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
View File
@@ -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);
+1 -1
View 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',
}; };
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+7 -7
View File
@@ -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>
+8 -8
View File
@@ -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"
+3 -3
View File
@@ -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>
) : ( ) : (
+18 -18
View File
@@ -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}