tja
This commit is contained in:
@@ -17,14 +17,14 @@ Jeder Spieler hat drei Guthaben-Töpfe:
|
|||||||
Zwei Regeln sind fest verdrahtet:
|
Zwei Regeln sind fest verdrahtet:
|
||||||
|
|
||||||
1. **Ein CC-Paket erzeugt beide Guthaben.** Der inklusive Redesign wird automatisch mitgebucht,
|
1. **Ein CC-Paket erzeugt beide Guthaben.** Der inklusive Redesign wird automatisch mitgebucht,
|
||||||
in derselben Transaktion und mit gemeinsamer Gruppen-ID — ein Storno nimmt beide zurück.
|
in derselben Transaktion und mit gemeinsamer Gruppen-ID - ein Storno nimmt beide zurück.
|
||||||
2. **Nur redesignte CCs kosten bei der Rückkehr.** Solange ein CC unverändert existiert, kommt er
|
2. **Nur redesignte CCs kosten bei der Rückkehr.** Solange ein CC unverändert existiert, kommt er
|
||||||
gratis zurück — gleich ob der Spieler inaktiv war oder die Einheit zeitweise entfernt wurde.
|
gratis zurück - gleich ob der Spieler inaktiv war oder die Einheit zeitweise entfernt wurde.
|
||||||
Wurde er dagegen redesignt, existiert das Original nicht mehr und die Reaktivierung kostet ein
|
Wurde er dagegen redesignt, existiert das Original nicht mehr und die Reaktivierung kostet ein
|
||||||
Redesign- oder CC-Guthaben.
|
Redesign- oder CC-Guthaben.
|
||||||
|
|
||||||
Guthaben werden als **Kontobuch** geführt: jede Bewegung ist eine eigene Zeile, der Stand eines
|
Guthaben werden als **Kontobuch** geführt: jede Bewegung ist eine eigene Zeile, der Stand eines
|
||||||
Topfes ist deren Summe. Das macht jeden Stand nachvollziehbar und Korrekturen sauber — storniert
|
Topfes ist deren Summe. Das macht jeden Stand nachvollziehbar und Korrekturen sauber - storniert
|
||||||
wird per Gegenbuchung, nie durch Löschen. Ein überzogenes Konto ist erlaubt (kommt vor, wenn ein
|
wird per Gegenbuchung, nie durch Löschen. Ein überzogenes Konto ist erlaubt (kommt vor, wenn ein
|
||||||
Redesign vor dem zugehörigen Paket erfasst wird), wird aber rot markiert.
|
Redesign vor dem zugehörigen Paket erfasst wird), wird aber rot markiert.
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ Kette „A → B → C" sichtbar und die Reaktivierungsregel greift automatisch
|
|||||||
A zurück, ist A ein redesignter CC und kostet entsprechend.
|
A zurück, ist A ein redesignter CC und kostet entsprechend.
|
||||||
|
|
||||||
Warum ein CC inaktiv wurde, lässt sich beim Deaktivieren als Klartext festhalten („Einheit bis
|
Warum ein CC inaktiv wurde, lässt sich beim Deaktivieren als Klartext festhalten („Einheit bis
|
||||||
März entfernt") — der Grund steht dann an der Karte, statt nur als Kategorie.
|
März entfernt") - der Grund steht dann an der Karte, statt nur als Kategorie.
|
||||||
|
|
||||||
## Schnellstart
|
## Schnellstart
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ docker compose logs -f app
|
|||||||
```
|
```
|
||||||
|
|
||||||
Beim ersten Start legt die App einen Administrator an. Ist `BOOTSTRAP_ADMIN_PASSWORD` leer,
|
Beim ersten Start legt die App einen Administrator an. Ist `BOOTSTRAP_ADMIN_PASSWORD` leer,
|
||||||
erzeugt sie ein Einmal-Passwort und schreibt es **einmalig** ins Log — dort steht es in der
|
erzeugt sie ein Einmal-Passwort und schreibt es **einmalig** ins Log - dort steht es in der
|
||||||
Ausgabe oben. Beim ersten Login ist ein Passwortwechsel Pflicht; bis dahin sind alle anderen
|
Ausgabe oben. Beim ersten Login ist ein Passwortwechsel Pflicht; bis dahin sind alle anderen
|
||||||
Seiten gesperrt.
|
Seiten gesperrt.
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ Der App-Port wird bewusst **nicht** nach außen gemappt. Davor gehört ein Rever
|
|||||||
weiterleitet. So ist nur Port 443 offen und die Zertifikate laufen über Let's Encrypt.
|
weiterleitet. So ist nur Port 443 offen und die Zertifikate laufen über Let's Encrypt.
|
||||||
|
|
||||||
Damit der Proxy die App erreicht, hängt der `app`-Service zusätzlich im Netzwerk des Proxys.
|
Damit der Proxy die App erreicht, hängt der `app`-Service zusätzlich im Netzwerk des Proxys.
|
||||||
Die `compose.yaml` erwartet es unter dem Namen `nginx_default` — so heißt es bei Nginx Proxy
|
Die `compose.yaml` erwartet es unter dem Namen `nginx_default` - so heißt es bei Nginx Proxy
|
||||||
Manager. Heißt es bei dir anders, setz `PROXY_NETWORK` in der `.env`:
|
Manager. Heißt es bei dir anders, setz `PROXY_NETWORK` in der `.env`:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -82,15 +82,15 @@ PROXY_NETWORK=mein-proxy-netz
|
|||||||
|
|
||||||
Das Netzwerk muss existieren, bevor der Stack startet; angelegt wird es vom Proxy. Betreibst du
|
Das Netzwerk muss existieren, bevor der Stack startet; angelegt wird es vom Proxy. Betreibst du
|
||||||
gar keinen Proxy in Docker, entferne den `networks`-Block am Ende der `compose.yaml` samt der
|
gar keinen Proxy in Docker, entferne den `networks`-Block am Ende der `compose.yaml` samt der
|
||||||
`networks`-Liste beim `app`-Service und mappe stattdessen einen Port — dann aber bitte HTTPS
|
`networks`-Liste beim `app`-Service und mappe stattdessen einen Port - dann aber bitte HTTPS
|
||||||
anderweitig davorsetzen.
|
anderweitig davorsetzen.
|
||||||
|
|
||||||
> **Zu `$` in der `.env`:** Docker Compose deutet `$name` als Variable und ersetzt es durch
|
> **Zu `$` in der `.env`:** Docker Compose deutet `$name` als Variable und ersetzt es durch
|
||||||
> nichts, wenn es die Variable nicht gibt. Ein Passwort mit `$` kommt dadurch gekürzt in der App
|
> nichts, wenn es die Variable nicht gibt. Ein Passwort mit `$` kommt dadurch gekürzt in der App
|
||||||
> an — ohne Fehler, nur mit einer leicht zu übersehenden Warnung. Entweder `$` vermeiden oder
|
> an - ohne Fehler, nur mit einer leicht zu übersehenden Warnung. Entweder `$` vermeiden oder
|
||||||
> als `$$` schreiben.
|
> als `$$` schreiben.
|
||||||
|
|
||||||
Läuft die App hinter einem Proxy, muss `TRUST_PROXY=true` gesetzt bleiben — sonst landen im
|
Läuft die App hinter einem Proxy, muss `TRUST_PROXY=true` gesetzt bleiben - sonst landen im
|
||||||
Audit-Log die Proxy-IPs statt der echten. In Produktion werden Cookies automatisch auf `Secure`
|
Audit-Log die Proxy-IPs statt der echten. In Produktion werden Cookies automatisch auf `Secure`
|
||||||
gesetzt; nur zum Testen ohne TLS lässt sich das über `COOKIE_SECURE=false` abschalten.
|
gesetzt; nur zum Testen ohne TLS lässt sich das über `COOKIE_SECURE=false` abschalten.
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ gesetzt; nur zum Testen ohne TLS lässt sich das über `COOKIE_SECURE=false` abs
|
|||||||
|
|
||||||
Der `backup`-Service im Stack schreibt beim Start und danach alle 24 Stunden einen
|
Der `backup`-Service im Stack schreibt beim Start und danach alle 24 Stunden einen
|
||||||
`pg_dump` nach `./backups/` und räumt Dumps älter als 14 Tage weg. Die Daten bilden bezahlte
|
`pg_dump` nach `./backups/` und räumt Dumps älter als 14 Tage weg. Die Daten bilden bezahlte
|
||||||
Ansprüche ab — das Verzeichnis gehört auf eine Sicherung außerhalb des Hosts.
|
Ansprüche ab - das Verzeichnis gehört auf eine Sicherung außerhalb des Hosts.
|
||||||
|
|
||||||
Wiederherstellen:
|
Wiederherstellen:
|
||||||
|
|
||||||
@@ -111,7 +111,7 @@ gunzip -c backups/ccmanager-2025-01-15-0300.sql.gz | \
|
|||||||
|
|
||||||
Kaufdaten lassen sich als CSV oder JSON hochladen; das Format ist nicht festgelegt. Der Ablauf:
|
Kaufdaten lassen sich als CSV oder JSON hochladen; das Format ist nicht festgelegt. Der Ablauf:
|
||||||
|
|
||||||
1. Datei hochladen — Spalten und Beispielzeilen werden angezeigt.
|
1. Datei hochladen - Spalten und Beispielzeilen werden angezeigt.
|
||||||
2. Spalten zuordnen (Spieler-Kennung, Produktname, optional Bestell-ID und Datum). Die üblichen
|
2. Spalten zuordnen (Spieler-Kennung, Produktname, optional Bestell-ID und Datum). Die üblichen
|
||||||
Spaltennamen werden vorausgewählt.
|
Spaltennamen werden vorausgewählt.
|
||||||
3. **Vorschau** prüfen: für jede Zeile steht da, was passieren würde.
|
3. **Vorschau** prüfen: für jede Zeile steht da, was passieren würde.
|
||||||
@@ -121,7 +121,7 @@ Welcher Produktname welches Guthaben ergibt, steht in der Mapping-Tabelle unter
|
|||||||
Produkt-Mappings* (Teiltreffer, Groß-/Kleinschreibung egal). Neue Produkte lassen sich dort
|
Produkt-Mappings* (Teiltreffer, Groß-/Kleinschreibung egal). Neue Produkte lassen sich dort
|
||||||
jederzeit ergänzen, ohne den Code anzufassen.
|
jederzeit ergänzen, ohne den Code anzufassen.
|
||||||
|
|
||||||
Wird eine Bestell-ID zugeordnet, bucht ein zweiter Import dieselben Käufe **nicht** erneut —
|
Wird eine Bestell-ID zugeordnet, bucht ein zweiter Import dieselben Käufe **nicht** erneut -
|
||||||
Dubletten werden sowohl gegen bereits gebuchte Bestellungen als auch innerhalb der Datei erkannt.
|
Dubletten werden sowohl gegen bereits gebuchte Bestellungen als auch innerhalb der Datei erkannt.
|
||||||
Ohne Bestell-ID entfällt dieser Schutz; darauf weist das Formular hin.
|
Ohne Bestell-ID entfällt dieser Schutz; darauf weist das Formular hin.
|
||||||
|
|
||||||
@@ -132,7 +132,7 @@ Kulanz-Redesigns tauchen in keinem Shop-Export auf und werden immer von Hand ein
|
|||||||
> **Auf eine bestimmte Instanz zugeschnitten.** Dieser Teil liest Profile von
|
> **Auf eine bestimmte Instanz zugeschnitten.** Dieser Teil liest Profile von
|
||||||
> `gextension.senseigaming.de` und ist auf deren Seitenaufbau gebaut. Läuft bei euch eine
|
> `gextension.senseigaming.de` und ist auf deren Seitenaufbau gebaut. Läuft bei euch eine
|
||||||
> andere GExtension-Instanz, genügt `GEXTENSION_BASE_URL` in der `.env`. Nutzt ihr ein
|
> andere GExtension-Instanz, genügt `GEXTENSION_BASE_URL` in der `.env`. Nutzt ihr ein
|
||||||
> *anderes* Shop- oder Spendensystem, funktioniert der Abgleich **nicht** — dafür müsste
|
> *anderes* Shop- oder Spendensystem, funktioniert der Abgleich **nicht** - dafür müsste
|
||||||
> `server/src/lib/gextension.ts` angepasst werden. Der Rest der App ist davon unabhängig und
|
> `server/src/lib/gextension.ts` angepasst werden. Der Rest der App ist davon unabhängig und
|
||||||
> läuft auch ohne diesen Teil; Pakete lassen sich immer von Hand oder per Datei-Import
|
> läuft auch ohne diesen Teil; Pakete lassen sich immer von Hand oder per Datei-Import
|
||||||
> eintragen.
|
> eintragen.
|
||||||
@@ -142,12 +142,12 @@ damit sie nicht abgetippt werden müssen.
|
|||||||
|
|
||||||
**Was er tut:** Für jeden Spieler, der im CC-Manager erfasst ist *und* eine SteamID hinterlegt
|
**Was er tut:** Für jeden Spieler, der im CC-Manager erfasst ist *und* eine SteamID hinterlegt
|
||||||
hat, ruft er die öffentliche Profilseite ab und liest die Paket-Liste. Auslösen lässt sich das
|
hat, ruft er die öffentliche Profilseite ab und liest die Paket-Liste. Auslösen lässt sich das
|
||||||
auf der Übersicht (alle Spieler) oder auf einer Spielerseite (nur dieser eine) — jeweils als
|
auf der Übersicht (alle Spieler) oder auf einer Spielerseite (nur dieser eine) - jeweils als
|
||||||
Admin.
|
Admin.
|
||||||
|
|
||||||
**Was er nicht tut:** Er sucht keine neuen Spieler. Wer nicht in der Datenbank steht, wird nie
|
**Was er nicht tut:** Er sucht keine neuen Spieler. Wer nicht in der Datenbank steht, wird nie
|
||||||
abgefragt, auch wenn er bei GExtension gekauft hat. Neue Käufer legst du von Hand an, danach
|
abgefragt, auch wenn er bei GExtension gekauft hat. Neue Käufer legst du von Hand an, danach
|
||||||
findet der Abgleich ihre Pakete. Gebucht wird ebenfalls nichts von allein — eindeutige Treffer
|
findet der Abgleich ihre Pakete. Gebucht wird ebenfalls nichts von allein - eindeutige Treffer
|
||||||
landen in derselben Vorschau wie der Datei-Import und warten auf Bestätigung.
|
landen in derselben Vorschau wie der Datei-Import und warten auf Bestätigung.
|
||||||
|
|
||||||
Erkannt wird anhand von Titel *und* Bereich: Nur der `SCP-RP`-Bereich führt CC-Pakete, und ein
|
Erkannt wird anhand von Titel *und* Bereich: Nur der `SCP-RP`-Bereich führt CC-Pakete, und ein
|
||||||
@@ -161,11 +161,11 @@ CC-Paket, gekauftes Redesign, Kulanz oder „nichts davon" einordnet. Bei CC-Pak
|
|||||||
automatisch auch das inklusive Redesign-Guthaben.
|
automatisch auch das inklusive Redesign-Guthaben.
|
||||||
|
|
||||||
**Verbrauchszähler:** Meldet GExtension „CC-Paket oder Redesign genutzt | N Mal", wird das beim
|
**Verbrauchszähler:** Meldet GExtension „CC-Paket oder Redesign genutzt | N Mal", wird das beim
|
||||||
Spieler angezeigt, aber **nie** automatisch verrechnet — es ist eine Lesehilfe zum Abgleich mit
|
Spieler angezeigt, aber **nie** automatisch verrechnet - es ist eine Lesehilfe zum Abgleich mit
|
||||||
dem eigenen Ledger, mehr nicht.
|
dem eigenen Ledger, mehr nicht.
|
||||||
|
|
||||||
Der Abgleich hält eine Sekunde Abstand zwischen den Anfragen und weist sich per User-Agent aus.
|
Der Abgleich hält eine Sekunde Abstand zwischen den Anfragen und weist sich per User-Agent aus.
|
||||||
Die Seite gehört dem Serverbetreiber, nicht dieser App — ein offizieller Datenzugang wäre der
|
Die Seite gehört dem Serverbetreiber, nicht dieser App - ein offizieller Datenzugang wäre der
|
||||||
sauberere Weg, falls er zu haben ist.
|
sauberere Weg, falls er zu haben ist.
|
||||||
|
|
||||||
## Forums-Anträge
|
## Forums-Anträge
|
||||||
@@ -178,32 +178,32 @@ Während der GExtension-Abgleich die **Käufe** liefert, steht der **Verbrauch**
|
|||||||
ein Antrag angenommen, abgelehnt, war es ein neuer CC oder ein Redesign? Unter *Anträge* liest
|
ein Antrag angenommen, abgelehnt, war es ein neuer CC oder ein Redesign? Unter *Anträge* liest
|
||||||
die App die drei CC-Boards aus und sammelt die Threads.
|
die App die drei CC-Boards aus und sammelt die Threads.
|
||||||
|
|
||||||
**Entschieden wird ausschließlich von Hand.** Titel und Board werden bewusst nicht ausgewertet —
|
**Entschieden wird ausschließlich von Hand.** Titel und Board werden bewusst nicht ausgewertet -
|
||||||
Threads werden nicht immer zuverlässig verschoben, ein längst genehmigter Antrag kann noch im
|
Threads werden nicht immer zuverlässig verschoben, ein längst genehmigter Antrag kann noch im
|
||||||
Antrags-Board liegen. Das aktuelle Board steht deshalb nur als Hinweis an der Zeile.
|
Antrags-Board liegen. Das aktuelle Board steht deshalb nur als Hinweis an der Zeile.
|
||||||
|
|
||||||
Pro Antrag wählt ein Admin: *angenommen als neuer CC*, *angenommen als Redesign* (mit Auswahl,
|
Pro Antrag wählt ein Admin: *angenommen als neuer CC*, *angenommen als Redesign* (mit Auswahl,
|
||||||
welcher CC ersetzt wurde und woraus bezahlt wird), *angenommen als Reaktivierung* (ein inaktiver
|
welcher CC ersetzt wurde und woraus bezahlt wird), *angenommen als Reaktivierung* (ein inaktiver
|
||||||
CC kehrt zurück — gratis, sofern er nicht redesignt wurde), *abgelehnt* oder *kein Antrag*. Die beiden
|
CC kehrt zurück - gratis, sofern er nicht redesignt wurde), *abgelehnt* oder *kein Antrag*. Die beiden
|
||||||
angenommenen Fälle lösen exakt dieselben Buchungen aus wie die Knöpfe auf der Spielerseite —
|
angenommenen Fälle lösen exakt dieselben Buchungen aus wie die Knöpfe auf der Spielerseite -
|
||||||
es gibt keinen zweiten Buchungsweg mit eigenen Regeln.
|
es gibt keinen zweiten Buchungsweg mit eigenen Regeln.
|
||||||
|
|
||||||
Zugeordnet wird über den Forumsnamen des Erstellers gegen das Feld *Forum-Name* beim Spieler —
|
Zugeordnet wird über den Forumsnamen des Erstellers gegen das Feld *Forum-Name* beim Spieler -
|
||||||
und über die **früheren Konten**, die auf der Spielerseite nachgetragen werden können. Wer seinen
|
und über die **früheren Konten**, die auf der Spielerseite nachgetragen werden können. Wer seinen
|
||||||
Steam- oder Forumsaccount gewechselt hat, behält so seine Ansprüche.
|
Steam- oder Forumsaccount gewechselt hat, behält so seine Ansprüche.
|
||||||
**Threads ohne zuordenbaren Spieler werden gar nicht erst erfasst** — das Archiv reicht Jahre
|
**Threads ohne zuordenbaren Spieler werden gar nicht erst erfasst** - das Archiv reicht Jahre
|
||||||
zurück und enthält vieles, was kein CC-Antrag ist. Fehlt jemand, trag seinen Forumsnamen beim
|
zurück und enthält vieles, was kein CC-Antrag ist. Fehlt jemand, trag seinen Forumsnamen beim
|
||||||
Spieler nach, dann nimmt ihn der nächste Abgleich auf.
|
Spieler nach, dann nimmt ihn der nächste Abgleich auf.
|
||||||
|
|
||||||
Angepinnte Ankündigungen (Changelogs, Regelwerke) liegen mit in den Boards. Sie werden über ihre
|
Angepinnte Ankündigungen (Changelogs, Regelwerke) liegen mit in den Boards. Sie werden über ihre
|
||||||
Labels *Wichtig* und *Information* aussortiert. Der Titel wird dagegen **nie** ausgewertet: CC-Anträge
|
Labels *Wichtig* und *Information* aussortiert. Der Titel wird dagegen **nie** ausgewertet: CC-Anträge
|
||||||
heißen mal „CC Antrag | Name", mal „\"The Duke\" Rang Update" — daran ist nichts zu erkennen.
|
heißen mal „CC Antrag | Name", mal „\"The Duke\" Rang Update" - daran ist nichts zu erkennen.
|
||||||
|
|
||||||
Trägst du einen Antrag von Hand ein — über *Neuer CC* oder *Redesign* auf der Spielerseite —
|
Trägst du einen Antrag von Hand ein - über *Neuer CC* oder *Redesign* auf der Spielerseite -
|
||||||
kannst du dort den Thread-Link angeben. Der Antrag gilt damit als erledigt und taucht beim
|
kannst du dort den Thread-Link angeben. Der Antrag gilt damit als erledigt und taucht beim
|
||||||
nächsten Abgleich nicht erneut auf.
|
nächsten Abgleich nicht erneut auf.
|
||||||
|
|
||||||
Ein Thread behält seine ID, auch wenn er zwischen den Boards verschoben wird — daran hängt die
|
Ein Thread behält seine ID, auch wenn er zwischen den Boards verschoben wird - daran hängt die
|
||||||
Wiedererkennung. Wandert ein **bereits entschiedener** Antrag später in ein anderes Board, etwa
|
Wiedererkennung. Wandert ein **bereits entschiedener** Antrag später in ein anderes Board, etwa
|
||||||
ins Archiv, erscheint das oben als Hinweis. Es ändert nie automatisch den CC-Status: dafür ist
|
ins Archiv, erscheint das oben als Hinweis. Es ändert nie automatisch den CC-Status: dafür ist
|
||||||
das Signal zu unzuverlässig.
|
das Signal zu unzuverlässig.
|
||||||
@@ -212,16 +212,16 @@ das Signal zu unzuverlässig.
|
|||||||
|
|
||||||
Es gibt **keine öffentliche Registrierung**. Konten legt ausschließlich ein Administrator an.
|
Es gibt **keine öffentliche Registrierung**. Konten legt ausschließlich ein Administrator an.
|
||||||
|
|
||||||
- **Viewer** — darf nur lesen.
|
- **Viewer** - darf nur lesen.
|
||||||
- **Editor** — Spieler anlegen und bearbeiten, gekaufte Pakete eintragen, CCs erstellen,
|
- **Editor** - Spieler anlegen und bearbeiten, gekaufte Pakete eintragen, CCs erstellen,
|
||||||
redesignen und reaktivieren, Datei-Import, GExtension- und Forums-Abgleich samt Entscheidung
|
redesignen und reaktivieren, Datei-Import, GExtension- und Forums-Abgleich samt Entscheidung
|
||||||
über Anträge.
|
über Anträge.
|
||||||
- **Admin** — zusätzlich Kulanz vergeben, Korrekturbuchungen, unklare Pakete auflösen, Spieler
|
- **Admin** - zusätzlich Kulanz vergeben, Korrekturbuchungen, unklare Pakete auflösen, Spieler
|
||||||
löschen, Nutzerverwaltung, Produkt-Mappings und Audit-Log.
|
löschen, Nutzerverwaltung, Produkt-Mappings und Audit-Log.
|
||||||
|
|
||||||
Die Grenze verläuft zwischen *erfassen* und *gewähren*: Wer einen Kauf einträgt, hält fest, was
|
Die Grenze verläuft zwischen *erfassen* und *gewähren*: Wer einen Kauf einträgt, hält fest, was
|
||||||
ohnehin passiert ist. Kulanz dagegen ist eine Entscheidung des Teams und bleibt Administratoren
|
ohnehin passiert ist. Kulanz dagegen ist eine Entscheidung des Teams und bleibt Administratoren
|
||||||
vorbehalten — ebenso das Auflösen unklarer Pakete, weil dabei Kulanz vergeben werden kann.
|
vorbehalten - ebenso das Auflösen unklarer Pakete, weil dabei Kulanz vergeben werden kann.
|
||||||
|
|
||||||
Jede ändernde Aktion landet im Audit-Log. Für Administratoren lässt sich unter *Mein Konto*
|
Jede ändernde Aktion landet im Audit-Log. Für Administratoren lässt sich unter *Mein Konto*
|
||||||
eine Zwei-Faktor-Anmeldung per TOTP einrichten.
|
eine Zwei-Faktor-Anmeldung per TOTP einrichten.
|
||||||
@@ -240,7 +240,7 @@ npm run dev # API auf :3000, Frontend auf :5173
|
|||||||
Das Frontend leitet `/api` im Entwicklungsbetrieb an den API-Port weiter, Anmeldung und Cookies
|
Das Frontend leitet `/api` im Entwicklungsbetrieb an den API-Port weiter, Anmeldung und Cookies
|
||||||
funktionieren also wie in Produktion.
|
funktionieren also wie in Produktion.
|
||||||
|
|
||||||
Die Tests brauchen **keine** laufende Datenbank — sie starten eine Postgres-Instanz im Speicher:
|
Die Tests brauchen **keine** laufende Datenbank - sie starten eine Postgres-Instanz im Speicher:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm test
|
npm test
|
||||||
@@ -273,7 +273,7 @@ Nach Änderungen am Datenmodell:
|
|||||||
npm run migrate:dev -- --name kurze-beschreibung
|
npm run migrate:dev -- --name kurze-beschreibung
|
||||||
```
|
```
|
||||||
|
|
||||||
Der Name wird zum Verzeichnisnamen der Migration — dort also besser bei ASCII bleiben.
|
Der Name wird zum Verzeichnisnamen der Migration - dort also besser bei ASCII bleiben.
|
||||||
|
|
||||||
## Sicherheit
|
## Sicherheit
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export default function AliasBox({
|
|||||||
{aliases.length === 0 ? (
|
{aliases.length === 0 ? (
|
||||||
<p className="muted small">
|
<p className="muted small">
|
||||||
Keine hinterlegt. Hat der Spieler früher einen anderen Steam- oder Forumsaccount
|
Keine hinterlegt. Hat der Spieler früher einen anderen Steam- oder Forumsaccount
|
||||||
genutzt, trag ihn hier nach — Abgleich und Import finden ihn dann mit.
|
genutzt, trag ihn hier nach - Abgleich und Import finden ihn dann mit.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="alias-groups">
|
<div className="alias-groups">
|
||||||
|
|||||||
@@ -39,9 +39,9 @@ const dateFormat = new Intl.DateTimeFormat('de-DE', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export function formatDate(value: string | null | undefined): string {
|
export function formatDate(value: string | null | undefined): string {
|
||||||
if (!value) return '—';
|
if (!value) return '-';
|
||||||
const parsed = new Date(value);
|
const parsed = new Date(value);
|
||||||
return Number.isNaN(parsed.getTime()) ? '—' : dateFormat.format(parsed);
|
return Number.isNaN(parsed.getTime()) ? '-' : dateFormat.format(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatAmount(amount: number): string {
|
export function formatAmount(amount: number): string {
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export default function Account() {
|
|||||||
|
|
||||||
{user?.mustChangePassword && (
|
{user?.mustChangePassword && (
|
||||||
<Notice kind="warn">
|
<Notice kind="warn">
|
||||||
Dieses Konto verwendet noch ein vergebenes Erstpasswort. Bitte jetzt ändern — die
|
Dieses Konto verwendet noch ein vergebenes Erstpasswort. Bitte jetzt ändern - die
|
||||||
übrigen Seiten sind bis dahin gesperrt.
|
übrigen Seiten sind bis dahin gesperrt.
|
||||||
</Notice>
|
</Notice>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -290,9 +290,9 @@ function CreateUserModal({
|
|||||||
value={role}
|
value={role}
|
||||||
onChange={(event) => setRole(event.target.value as Role)}
|
onChange={(event) => setRole(event.target.value as Role)}
|
||||||
>
|
>
|
||||||
<option value="VIEWER">Viewer — nur lesen</option>
|
<option value="VIEWER">Viewer - nur lesen</option>
|
||||||
<option value="EDITOR">Editor — Spieler, Guthaben, CCs</option>
|
<option value="EDITOR">Editor - Spieler, Guthaben, CCs</option>
|
||||||
<option value="ADMIN">Admin — alles inkl. Import und Nutzer</option>
|
<option value="ADMIN">Admin - alles inkl. Import und Nutzer</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<div className="form-actions">
|
<div className="form-actions">
|
||||||
@@ -475,14 +475,14 @@ function AuditTab({ onError }: { onError: (message: string) => void }) {
|
|||||||
{items.map((entry) => (
|
{items.map((entry) => (
|
||||||
<tr key={entry.id}>
|
<tr key={entry.id}>
|
||||||
<td className="muted small">{formatDate(entry.createdAt)}</td>
|
<td className="muted small">{formatDate(entry.createdAt)}</td>
|
||||||
<td>{entry.actor?.username ?? entry.actorName ?? '—'}</td>
|
<td>{entry.actor?.username ?? entry.actorName ?? '-'}</td>
|
||||||
<td className="mono">{entry.action}</td>
|
<td className="mono">{entry.action}</td>
|
||||||
<td className="muted">
|
<td className="muted">
|
||||||
{entry.targetType}
|
{entry.targetType}
|
||||||
{entry.targetId ? ` #${entry.targetId}` : ''}
|
{entry.targetId ? ` #${entry.targetId}` : ''}
|
||||||
</td>
|
</td>
|
||||||
<td className="muted small details">
|
<td className="muted small details">
|
||||||
{entry.details ? JSON.stringify(entry.details) : '—'}
|
{entry.details ? JSON.stringify(entry.details) : '-'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ export default function Dashboard() {
|
|||||||
</Link>
|
</Link>
|
||||||
{player.forumName && <span className="muted"> · {player.forumName}</span>}
|
{player.forumName && <span className="muted"> · {player.forumName}</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="mono muted">{player.steamId ?? '—'}</td>
|
<td className="mono muted">{player.steamId ?? '-'}</td>
|
||||||
<td className="num">
|
<td className="num">
|
||||||
<Counter value={player.balances.CC} title="CC-Guthaben" />
|
<Counter value={player.balances.CC} title="CC-Guthaben" />
|
||||||
</td>
|
</td>
|
||||||
@@ -194,7 +194,7 @@ export default function Dashboard() {
|
|||||||
{loading && <p className="muted">Lade …</p>}
|
{loading && <p className="muted">Lade …</p>}
|
||||||
{!loading && result.total > result.items.length && (
|
{!loading && result.total > result.items.length && (
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
{result.items.length} von {result.total} angezeigt — Suche eingrenzen.
|
{result.items.length} von {result.total} angezeigt - Suche eingrenzen.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export default function Forum() {
|
|||||||
<div>
|
<div>
|
||||||
<h1>Forums-Anträge</h1>
|
<h1>Forums-Anträge</h1>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
Jeder Antrag wird von Hand entschieden — Titel und Board sind kein verlässliches
|
Jeder Antrag wird von Hand entschieden - Titel und Board sind kein verlässliches
|
||||||
Signal und lösen nie eine Buchung aus. Aufgenommen werden nur Threads von Spielern,
|
Signal und lösen nie eine Buchung aus. Aufgenommen werden nur Threads von Spielern,
|
||||||
die hier mit passendem Forum-Namen erfasst sind.
|
die hier mit passendem Forum-Namen erfasst sind.
|
||||||
</p>
|
</p>
|
||||||
@@ -103,7 +103,7 @@ export default function Forum() {
|
|||||||
<h2>Verschoben nach der Entscheidung ({notices.length})</h2>
|
<h2>Verschoben nach der Entscheidung ({notices.length})</h2>
|
||||||
<Notice kind="warn">
|
<Notice kind="warn">
|
||||||
Diese Anträge wurden bereits entschieden, ihr Thread liegt inzwischen aber in einem
|
Diese Anträge wurden bereits entschieden, ihr Thread liegt inzwischen aber in einem
|
||||||
anderen Board. Das ist nur ein Hinweis — geprüft und geklickt wird von Hand.
|
anderen Board. Das ist nur ein Hinweis - geprüft und geklickt wird von Hand.
|
||||||
</Notice>
|
</Notice>
|
||||||
|
|
||||||
<ul className="cc-list">
|
<ul className="cc-list">
|
||||||
@@ -312,7 +312,7 @@ function ClassifyModal({
|
|||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
<strong>{label}</strong>
|
<strong>{label}</strong>
|
||||||
<span className="muted"> — {hint}</span>
|
<span className="muted"> - {hint}</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
@@ -333,7 +333,7 @@ function ClassifyModal({
|
|||||||
onChange={(event) => setName(event.target.value)}
|
onChange={(event) => setName(event.target.value)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<p className="muted small">Vorbelegt mit dem Thread-Titel — bitte anpassen.</p>
|
<p className="muted small">Vorbelegt mit dem Thread-Titel - bitte anpassen.</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -346,11 +346,11 @@ function ClassifyModal({
|
|||||||
onChange={(event) => setReviveId(Number(event.target.value) || null)}
|
onChange={(event) => setReviveId(Number(event.target.value) || null)}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<option value="">— bitte wählen —</option>
|
<option value="">- bitte wählen -</option>
|
||||||
{inactive.map((character) => (
|
{inactive.map((character) => (
|
||||||
<option key={character.id} value={character.id}>
|
<option key={character.id} value={character.id}>
|
||||||
{character.name}
|
{character.name}
|
||||||
{character.inactiveReason === 'REWORKED' ? ' (redesignt — kostet)' : ' (gratis)'}
|
{character.inactiveReason === 'REWORKED' ? ' (redesignt - kostet)' : ' (gratis)'}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -360,7 +360,7 @@ function ClassifyModal({
|
|||||||
|
|
||||||
{gewaehlterCC && !reaktivierungKostet && (
|
{gewaehlterCC && !reaktivierungKostet && (
|
||||||
<Notice kind="success">
|
<Notice kind="success">
|
||||||
Kommt gratis zurück — der CC existiert unverändert weiter.
|
Kommt gratis zurück - der CC existiert unverändert weiter.
|
||||||
</Notice>
|
</Notice>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -405,7 +405,7 @@ function ClassifyModal({
|
|||||||
onChange={(event) => setPredecessorId(Number(event.target.value) || null)}
|
onChange={(event) => setPredecessorId(Number(event.target.value) || null)}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<option value="">— bitte wählen —</option>
|
<option value="">- bitte wählen -</option>
|
||||||
{characters.map((character) => (
|
{characters.map((character) => (
|
||||||
<option key={character.id} value={character.id}>
|
<option key={character.id} value={character.id}>
|
||||||
{character.name}
|
{character.name}
|
||||||
@@ -500,7 +500,7 @@ function AssignModal({
|
|||||||
<Modal title="Spieler zuordnen" onClose={onClose}>
|
<Modal title="Spieler zuordnen" onClose={onClose}>
|
||||||
<Notice kind="info">
|
<Notice kind="info">
|
||||||
Forumsname des Antragstellers: <strong>{thread.authorName ?? 'unbekannt'}</strong>. Passt
|
Forumsname des Antragstellers: <strong>{thread.authorName ?? 'unbekannt'}</strong>. Passt
|
||||||
kein Eintrag, trag den Forumsnamen beim Spieler nach — dann findet der nächste Abgleich
|
kein Eintrag, trag den Forumsnamen beim Spieler nach - dann findet der nächste Abgleich
|
||||||
ihn von allein.
|
ihn von allein.
|
||||||
</Notice>
|
</Notice>
|
||||||
|
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ export default function Import() {
|
|||||||
<h2>Wartet auf Bestätigung ({open.length})</h2>
|
<h2>Wartet auf Bestätigung ({open.length})</h2>
|
||||||
<Notice kind="warn">
|
<Notice kind="warn">
|
||||||
Diese Importe sind angelegt, aber noch nicht gebucht. Der GExtension-Abgleich landet
|
Diese Importe sind angelegt, aber noch nicht gebucht. Der GExtension-Abgleich landet
|
||||||
ebenfalls hier — erst nach dem Bestätigen entstehen Guthaben.
|
ebenfalls hier - erst nach dem Bestätigen entstehen Guthaben.
|
||||||
</Notice>
|
</Notice>
|
||||||
|
|
||||||
<ul className="cc-list">
|
<ul className="cc-list">
|
||||||
@@ -234,7 +234,7 @@ 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 Bestätigung. Bereits importierte Bestell-IDs
|
gezeigt - gebucht wird erst nach Bestätigung. Bereits importierte Bestell-IDs
|
||||||
werden automatisch übersprungen.
|
werden automatisch übersprungen.
|
||||||
</Notice>
|
</Notice>
|
||||||
|
|
||||||
@@ -396,13 +396,13 @@ export default function Import() {
|
|||||||
<tr key={row.id} className={row.plan.startsWith('SKIP') ? 'is-skipped' : undefined}>
|
<tr key={row.id} className={row.plan.startsWith('SKIP') ? 'is-skipped' : undefined}>
|
||||||
<td className="muted">{row.lineNo}</td>
|
<td className="muted">{row.lineNo}</td>
|
||||||
<td>
|
<td>
|
||||||
{row.playerName || row.playerKey || '—'}
|
{row.playerName || row.playerKey || '-'}
|
||||||
{row.resolvedPlayerId === null && row.plan === 'CREATE_PLAYER_AND_BOOK' && (
|
{row.resolvedPlayerId === null && row.plan === 'CREATE_PLAYER_AND_BOOK' && (
|
||||||
<span className="badge badge-new">neu</span>
|
<span className="badge badge-new">neu</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="muted">{row.productName ?? '—'}</td>
|
<td className="muted">{row.productName ?? '-'}</td>
|
||||||
<td className="mono muted">{row.externalId ?? '—'}</td>
|
<td className="mono muted">{row.externalId ?? '-'}</td>
|
||||||
<td className="muted">{formatDate(row.purchasedAt)}</td>
|
<td className="muted">{formatDate(row.purchasedAt)}</td>
|
||||||
<td className={row.plan.startsWith('SKIP') ? 'muted' : 'pos'}>
|
<td className={row.plan.startsWith('SKIP') ? 'muted' : 'pos'}>
|
||||||
{PLAN_LABEL[row.plan]}
|
{PLAN_LABEL[row.plan]}
|
||||||
@@ -452,7 +452,7 @@ function MappingSelect({
|
|||||||
<div className="field">
|
<div className="field">
|
||||||
<label>{label}</label>
|
<label>{label}</label>
|
||||||
<select value={value} onChange={(event) => onChange(event.target.value)} required={required}>
|
<select value={value} onChange={(event) => onChange(event.target.value)} required={required}>
|
||||||
<option value="">— nicht zuordnen —</option>
|
<option value="">- nicht zuordnen -</option>
|
||||||
{columns.map((column) => (
|
{columns.map((column) => (
|
||||||
<option key={column} value={column}>
|
<option key={column} value={column}>
|
||||||
{column}
|
{column}
|
||||||
|
|||||||
@@ -95,9 +95,9 @@ export default function PlayerDetail() {
|
|||||||
'bereits unter Import auf Bestätigung.',
|
'bereits unter Import auf Bestätigung.',
|
||||||
);
|
);
|
||||||
} else if (result.unclear > 0) {
|
} else if (result.unclear > 0) {
|
||||||
setFlash(`${result.unclear} unklare Einträge — siehe Kasten oben.`);
|
setFlash(`${result.unclear} unklare Einträge - siehe Kasten oben.`);
|
||||||
} else if (result.duplicates > 0) {
|
} else if (result.duplicates > 0) {
|
||||||
setFlash('Nichts Neues — alle Pakete sind bereits gebucht.');
|
setFlash('Nichts Neues - alle Pakete sind bereits gebucht.');
|
||||||
} else if (result.failedPlayers.length > 0) {
|
} else if (result.failedPlayers.length > 0) {
|
||||||
setError(`Profil nicht abrufbar: ${result.failedPlayers[0]?.reason ?? 'unbekannt'}`);
|
setError(`Profil nicht abrufbar: ${result.failedPlayers[0]?.reason ?? 'unbekannt'}`);
|
||||||
} else {
|
} else {
|
||||||
@@ -165,7 +165,7 @@ export default function PlayerDetail() {
|
|||||||
GExtension meldet <strong>{detail.player.gextensionUsage}</strong> Mal verbraucht
|
GExtension meldet <strong>{detail.player.gextensionUsage}</strong> Mal verbraucht
|
||||||
{detail.player.gextensionSyncedAt &&
|
{detail.player.gextensionSyncedAt &&
|
||||||
` (Stand ${formatDate(detail.player.gextensionSyncedAt)})`}
|
` (Stand ${formatDate(detail.player.gextensionSyncedAt)})`}
|
||||||
. Nur zum Abgleich — wird nicht automatisch verrechnet.
|
. Nur zum Abgleich - wird nicht automatisch verrechnet.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -302,7 +302,7 @@ export default function PlayerDetail() {
|
|||||||
)}
|
)}
|
||||||
<p className="muted small">
|
<p className="muted small">
|
||||||
{free
|
{free
|
||||||
? 'Kommt gratis zurück — 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>
|
||||||
@@ -320,7 +320,7 @@ export default function PlayerDetail() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {},
|
body: {},
|
||||||
}),
|
}),
|
||||||
`"${character.name}" ist wieder aktiv — kostenlos.`,
|
`"${character.name}" ist wieder aktiv - kostenlos.`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -376,7 +376,7 @@ export default function PlayerDetail() {
|
|||||||
</td>
|
</td>
|
||||||
<td>{SOURCE_LABEL[entry.source]}</td>
|
<td>{SOURCE_LABEL[entry.source]}</td>
|
||||||
<td className="muted small">
|
<td className="muted small">
|
||||||
{entry.note ?? '—'}
|
{entry.note ?? '-'}
|
||||||
{entry.character && ` (${entry.character.name})`}
|
{entry.character && ` (${entry.character.name})`}
|
||||||
</td>
|
</td>
|
||||||
<td className="muted small">{entry.createdBy?.username ?? 'System'}</td>
|
<td className="muted small">{entry.createdBy?.username ?? 'System'}</td>
|
||||||
@@ -428,7 +428,7 @@ export default function PlayerDetail() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: values,
|
body: values,
|
||||||
}),
|
}),
|
||||||
`CC "${values.name}" angelegt — 1 CC-Guthaben verbraucht.`,
|
`CC "${values.name}" angelegt - 1 CC-Guthaben verbraucht.`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -870,7 +870,7 @@ function DeactivateModal({
|
|||||||
>
|
>
|
||||||
<Notice kind="info">
|
<Notice kind="info">
|
||||||
Der CC bleibt unverändert und kommt später in beiden Fällen gratis zurück. 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 läuft aber über 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">
|
||||||
@@ -883,7 +883,7 @@ function DeactivateModal({
|
|||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
<strong>Spieler-Inaktivität</strong>
|
<strong>Spieler-Inaktivität</strong>
|
||||||
<span className="muted"> — vom Server geputzt, weil der Spieler länger 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">
|
||||||
@@ -895,7 +895,7 @@ function DeactivateModal({
|
|||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
<strong>Anderer Grund</strong>
|
<strong>Anderer Grund</strong>
|
||||||
<span className="muted"> — etwa eine vorübergehend entfernte Einheit</span>
|
<span className="muted"> - etwa eine vorübergehend entfernte Einheit</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -1083,7 +1083,7 @@ function DeletePlayerModal({
|
|||||||
{characterCount === 1 ? 'CC' : 'CCs'} und <strong>{ledgerCount}</strong>{' '}
|
{characterCount === 1 ? 'CC' : 'CCs'} und <strong>{ledgerCount}</strong>{' '}
|
||||||
{ledgerCount === 1 ? 'Buchung' : 'Buchungen'} mit. Auch Spuren in Importen und in den
|
{ledgerCount === 1 ? 'Buchung' : 'Buchungen'} mit. Auch Spuren in Importen und in den
|
||||||
Details früherer Protokolleinträge werden entfernt. Das lässt sich nicht rückgängig
|
Details früherer Protokolleinträge werden entfernt. Das lässt sich nicht rückgängig
|
||||||
machen — für eine Fehleingabe ist eher das Bearbeiten gedacht.
|
machen - für eine Fehleingabe ist eher das Bearbeiten gedacht.
|
||||||
</Notice>
|
</Notice>
|
||||||
|
|
||||||
<label htmlFor="confirm-name">
|
<label htmlFor="confirm-name">
|
||||||
|
|||||||
Reference in New Issue
Block a user