Compare commits

..

43 Commits

Author SHA1 Message Date
duffyduck ea3f3c6d29 chore: Build-Artefakte und node_modules aus Tracking entfernen
- backend/dist, backend/node_modules aus Git-Tracking entfernt (waren bereits in .gitignore)
- frontend/dist, frontend/node_modules ebenfalls entfernt
- Neue frontend/.gitignore erstellt (fehlte komplett)

Die Dateien bleiben lokal erhalten und werden durch npm install + build regeneriert.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 11:55:09 +02:00
duffyduck d1e78b4b8e todo: Geburtstagskalender als erledigt markiert
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 11:51:48 +02:00
duffyduck 9e55e25dc8 Geburtstagskalender + Geburtstagsgruß-Modal im Kundenportal
Admin (Vertrags-Cockpit):
- Neue Section "Geburtstage" zeigt Kunden mit Geburtstag
- Fenster: -7 bis +30 Tage um heute
- Farbcodierung: heute (pink), vergangen (amber), bevorstehend (grau)
- Anzeige: Name, Kundennummer, Geburtsdatum, Alter, "Heute!" / "In X Tagen" / "Vor X Tagen"

Portal (Kundenportal):
- Modal mit Geburtstagsgruß wenn Geburtstag heute oder in den letzten 7 Tagen war
- Unterscheidet zwischen aktuellem Geburtstag und nachträglichen Glückwünschen
- Schönes Gradient-Design mit Konfetti-Emojis
- Wird pro Jahr nur einmal angezeigt (Customer.lastBirthdayGreetingYear)
- Bestätigung speichert das aktuelle Jahr

Backend:
- Neues Feld Customer.lastBirthdayGreetingYear (Int?)
- Service birthday.service.ts mit Fenster-Logik + Alter-Berechnung
- Endpoints /api/birthdays/upcoming (Admin),
  /api/birthdays/my-birthday (Portal GET + POST /acknowledge)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 11:51:20 +02:00
duffyduck a47dfcd841 todo: Vertragslisten-Erweiterung als erledigt markiert
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 10:20:01 +02:00
duffyduck f17adb6095 Typspezifische Zusatzinfos in Vertragslisten
Jede Vertragszeile zeigt jetzt eine kontextspezifische Zusatzinfo an:
- Strom/Gas: "Lieferadresse: Musterstr. 12, 12345 Berlin"
- DSL/Glasfaser/Kabel: "Anschlussadresse: ..."
- Mobilfunk: "Rufnummer: 0171 1234567" (Hauptkarte bevorzugt)
- KFZ: "Kennzeichen: HB-AB 123"

Sichtbar in:
- Admin-Vertragsliste (/contracts)
- Portal-Vertragsliste (Baumansicht)
- Kunden-Detail → Verträge-Tab

Backend: getAllContracts + getContractTreeForCustomer liefern
mobileDetails (mit simCards), carInsuranceDetails und address mit.

Frontend: Neuer Helper utils/contractInfo.ts mit getContractTypeInfo,
aus dem sowohl Label als auch Wert pro Typ kommt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 10:19:04 +02:00
duffyduck 50b0e56a84 update todo.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 19:18:33 +02:00
duffyduck 29eceef26b PDF-Auftragsvorlagen-System, Objekttyp/Lage-Felder, Eigentümer-Fallback bei Bankverbindung
- PDF-Template-Editor in Einstellungen: Vorlagen hochladen, Formularfelder automatisch auslesen, CRM-Felder zuordnen
- PDF-Vorschau mit annotierten Feldnamen, seitenweise Sortierung der Felder
- Auftrag generieren aus Vertragsdaten (Button im Vertrags-Detail)
- Dynamische Rufnummern-Felder mit Vorwahl-Extraktion und konfigurierbarer Maximalanzahl
- Nicht zugeordnete Felder bleiben editierbar im generierten PDF
- Eigentümer-Felder mit Namens-Kombinationen (Firma+Name etc.) und Fallback auf Kundendaten
- Stressfrei-E-Mail als Feld-Option im Template-Editor
- Objekttyp, Lage und Lage des Anschlusses als neue Felder bei Festnetz-Verträgen (DSL, Glasfaser, Kabel)
- Bankverbindung-Fallback: wenn keine am Vertrag verknüpft, wird automatisch die neueste aktive des Kunden genommen

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 19:16:47 +02:00
duffyduck 5e9e553882 delete test pdf 2026-03-27 12:12:22 +01:00
duffyduck 5b85bea4eb email datenschztz erst alles bestätigt bei allen hebeln 2026-03-27 12:03:20 +01:00
duffyduck 3dd4f7b656 added place to telecommunication, added contract documents, added invoice to other contracts 2026-03-25 16:55:48 +01:00
duffyduck eaa94e766a impressum datenschutz added 2026-03-25 15:25:34 +01:00
duffyduck 219e1930f7 complete new audit system 2026-03-21 18:23:54 +01:00
duffyduck 4f359df161 Datenschutz vollmacht fixed, two time counter added 2026-03-21 16:42:31 +01:00
duffyduck 0121c82412 fixed back button with source, and customer in customer lsit clickable 2026-03-21 12:16:04 +01:00
duffyduck a9643206bb fixed all back buttons 2026-03-21 12:03:32 +01:00
duffyduck f2876f877e gdpr audit implemented, email log, vollmachten, pdf delete cancel data privacy and vollmachten, removed message no id card in engergy car, and other contracts that are not telecom contracts, added insert counter for engery 2026-03-21 11:59:53 +01:00
duffyduck 89cf92eaf5 added docker setup 2026-02-08 19:59:49 +01:00
duffyduck dd4d57fa1b added recovery entires, changed recovery icon 2026-02-08 19:43:46 +01:00
duffyduck e348e86c60 added contract history 2026-02-08 19:24:37 +01:00
duffyduck ee4f1aacdd added date at support ticket, new order support tickets, delete edit support ticktes only from enploye and admins 2026-02-08 18:26:34 +01:00
duffyduck 06489299d5 contractnumber provider added, old provider number field only if no previous contact exist 2026-02-08 14:34:56 +01:00
duffyduck 4442ab08b3 readme updated 2026-02-08 13:14:24 +01:00
duffyduck efe8ac25cb snooze vor expired, contracts, display snoozed contracts if an item is missing, un snooze implemented, fixed invoice upload bug 2026-02-08 13:08:58 +01:00
duffyduck 3a9fcc5ec9 fixed issue stressfrei adress as username not filled oin cockpit 2026-02-08 09:09:00 +01:00
duffyduck d400c90e6a updated readme.md 2026-02-08 01:21:00 +01:00
duffyduck aee48a8ccb added invoices and status in cockpit, created info button for contract status types 2026-02-08 01:18:12 +01:00
duffyduck 1ad4fe0819 addes cost and usage calculation 2026-02-06 00:14:38 +01:00
duffyduck b281801cdb added extra field kwh at m3, expand cost field to 10 komma, added maloid,counter add dialog, auto set unit 2026-02-05 20:34:45 +01:00
duffyduck af2f444a24 contractmodaldetail date format and before contract and next contract question to add 2026-02-04 21:17:13 +01:00
duffyduck 2d052c76d9 save email as pdf likae attachment version 2 2026-02-04 19:49:09 +01:00
duffyduck d98c97a81f remove uploads from repo but keep empty folder 2026-02-04 19:19:18 +01:00
duffyduck 06d45734ce save email as pdf like an attachment 2026-02-04 19:18:32 +01:00
duffyduck b968e6b46d added tree view to customer portal, in employe its uses still list 2026-02-04 16:30:49 +01:00
duffyduck 312e879221 seperate delivery and billig adresses in contract added 2026-02-04 08:48:25 +01:00
duffyduck fdef6d1d3b fixed, bankcard, adresses, id card, tarif name dropdown menu in edit mode 2026-02-04 08:37:46 +01:00
duffyduck 2b23ed64c4 added new view in contracts customer and contracts 2026-02-04 00:52:04 +01:00
duffyduck 97b4670643 save attachment from email in customer data and - or contracts 2026-02-03 23:58:00 +01:00
duffyduck 9a014c100b update readme.md 2026-02-03 23:08:08 +01:00
duffyduck 6f3ab288ed all email views the same 2026-02-03 23:04:42 +01:00
duffyduck ee8bd7a8f7 optimize password view in stressfrei addresses 2026-02-03 15:43:00 +01:00
duffyduck e4fdfbc95f added backup and email client 2026-02-01 00:02:35 +01:00
duffyduck ff857be01a imapclient feature plan 2026-01-29 01:34:43 +01:00
Stefan Hacker 31f807fbd0 first commit 2026-01-29 01:16:54 +01:00
91 changed files with 851 additions and 8429 deletions
+2 -280
View File
@@ -2,8 +2,6 @@
Web-basiertes CRM-System für Kundenverwaltung mit Verträgen (Energie, Telekommunikation, KFZ-Versicherung).
**Version: 1.1.0** ([Changelog](#changelog))
## Features
- **Kundenverwaltung**: Privat- und Geschäftskunden mit Stammdaten
@@ -13,9 +11,6 @@ Web-basiertes CRM-System für Kundenverwaltung mit Verträgen (Energie, Telekomm
- **Zähler**: Strom-/Gaszähler mit Zählerstandhistorie
- **Rechnungen**: Rechnungsverwaltung für Energieverträge mit Dokumenten-Upload
- **Vertrags-Cockpit**: Dashboard zur Überwachung offener Aufgaben (fehlende Dokumente, Rechnungen)
- **Auto-Vertragsstatus**: Lieferbestätigung-Upload setzt `DRAFT``ACTIVE` (mit Vertragsbeginn),
Kündigungsbestätigung-Upload setzt `ACTIVE``CANCELLED` (mit Datum),
nightly-Cron setzt `ACTIVE`-Verträge mit abgelaufenem `endDate` auf `EXPIRED`
- **Verträge**:
- Energie (Strom, Gas)
- Telekommunikation (DSL, Glasfaser, Mobilfunk, TV)
@@ -25,14 +20,7 @@ Web-basiertes CRM-System für Kundenverwaltung mit Verträgen (Energie, Telekomm
- **Email-Provisionierung**: Automatische E-Mail-Weiterleitung bei Plesk/cPanel/DirectAdmin
- **Berechtigungssystem**: Admin, Mitarbeiter, Nur-Lesen, Kundenportal
- **Verschlüsselte Zugangsdaten**: Portal-Passwörter AES-256-GCM verschlüsselt
- **DSGVO-Compliance**: Audit-Logging mit Hash-Chain-Integritätsprüfung,
Einwilligungsverwaltung, Datenexport, Löschanfragen
- **Sicherheits-Monitoring**: Realtime-Logging von Login-Fehlversuchen, IDOR-Abwehr,
SSRF-Blocks, JWT-Manipulation; Threshold-Detection (Brute-Force, IDOR-Probing) mit
Sofort-E-Mail-Alerts und stündlichem Digest siehe Einstellungen → Monitoring
- **Production-Hardening**: 10 dokumentierte Hardening-Runden inkl. CORS, Helmet,
IDOR-Schutz, Rate-Limiting, SSRF/DNS-Rebinding-Block, Per-File-Ownership-Check, mehr
in [docs/SECURITY-HARDENING.md](docs/SECURITY-HARDENING.md)
- **DSGVO-Compliance**: Audit-Logging, Einwilligungsverwaltung, Datenexport, Löschanfragen
- **Developer-Tools**: Datenbank-Browser und interaktives ER-Diagramm
## Tech Stack
@@ -152,39 +140,6 @@ Nach dem Seed sind folgende Zugangsdaten verfügbar:
- **E-Mail:** admin@admin.com
- **Passwort:** admin
> **Wichtig:** Vor dem ersten Production-Deployment das Default-Passwort sofort
> ändern und Secrets rotieren siehe [Production-Deployment](#production-deployment).
## Production-Deployment
Vor dem öffentlichen Schalten der Instanz muss in der Production-`.env`:
```env
NODE_ENV=production
# Pflicht-Rotation per `openssl rand` neu generieren!
JWT_SECRET=$(openssl rand -hex 64) # min. 32 Zeichen
ENCRYPTION_KEY=$(openssl rand -hex 32) # genau 64 Hex-Zeichen
# Backend nur lokal lauschen lassen, public-Verkehr läuft über Reverse-Proxy
LISTEN_ADDR=127.0.0.1
# Bei separatem Frontend-Host: erlaubte Origins
CORS_ORIGINS=https://crm.deine-domain.de
```
Plus:
- **Reverse-Proxy** (Nginx/Plesk) so konfigurieren, dass `X-Forwarded-For` hart auf
die echte Client-IP gesetzt wird (nicht nur angefügt) sonst Rate-Limit-Bypass möglich.
- **Default-Admin-Passwort ändern** (admin@admin.com / admin).
- **Manuelle Test-Checkliste** aus [docs/TESTING.md](docs/TESTING.md) einmal komplett
durchklicken.
- **Monitoring konfigurieren**: Einstellungen → Sicherheits-Monitoring → Alert-E-Mail
hinterlegen, Test-Alert senden, Digest aktivieren.
- Vollständige Hardening-Story + restliche Trade-offs:
[docs/SECURITY-HARDENING.md](docs/SECURITY-HARDENING.md)
## Developer-Tools aktivieren
Die Developer-Tools (Datenbankstruktur, ER-Diagramm) sind standardmäßig für Admins verfügbar. Falls der Menüpunkt nicht erscheint:
@@ -215,84 +170,12 @@ Das System unterstützt die automatische Erstellung von E-Mail-Weiterleitungen a
- **Name**: Bezeichnung (z.B. "Plesk Hauptserver")
- **Typ**: Plesk/cPanel/DirectAdmin
- **API-URL**: Server-URL (z.B. `https://server.de:8443`)
- **API-Key** _(empfohlen bei Plesk)_: Key aus Plesk (siehe unten), alternativ Benutzername/Passwort
- **Benutzername/Passwort**: Nur wenn kein API-Key vorhanden
- **Benutzername/Passwort**: API-Zugangsdaten
- **Domain**: E-Mail-Domain (z.B. `stressfrei-wechseln.de`)
- **Standard-Weiterleitung**: Zusätzliche Weiterleitungsadresse (optional)
3. Provider als "Standard" und "Aktiv" markieren
4. Verbindung testen
### Plesk: API-Key anlegen
Der API-Key ist die empfohlene Authentifizierungsmethode (sicherer als Passwort, kann pro
Anwendung vergeben und widerrufen werden).
**Variante 1: Über die Plesk-Oberfläche (einfachster Weg)**
1. In Plesk als Admin einloggen
2. Oben rechts auf den **eigenen Namen** → **"Mein Profil"** (oder direkt URL `/admin/my-profile/`)
3. Tab **"API-Token"** oder **"API-Schlüssel"** öffnen
4. **"API-Schlüssel erstellen"** (bzw. "Add API Key")
5. Beschreibung vergeben (z.B. "OpenCRM")
6. Den angezeigten Schlüssel **sofort kopieren** er wird nur einmal angezeigt!
7. Im CRM bei "API-Key" einfügen
> **Hinweis:** Bei manchen Plesk-Versionen ist die Option unter
> **Tools & Einstellungen** → **API-Schlüssel** oder **Werkzeuge & Einstellungen** →
> **API-Tokens** zu finden. Wenn der Menüpunkt fehlt, muss ggf. die **REST API**
> Extension installiert werden (siehe Variante 2).
**Variante 2: Über die Kommandozeile (SSH als root)**
Falls der API-Key-Button in Plesk nicht vorhanden ist, lässt er sich auch per SSH erstellen:
```bash
# API-Key generieren (läuft nicht ab)
# WICHTIG: -ip-address weglassen, wenn der Key von beliebigen IPs genutzt werden soll!
plesk bin secret_key --create -description "OpenCRM"
# Alternativ mit IP-Einschränkung (nur Zugriffe von dieser IP sind erlaubt):
plesk bin secret_key --create -ip-address <IP-DES-CRM-SERVERS> -description "OpenCRM"
```
> **Achtung:** `-ip-address 0.0.0.0` funktioniert **nicht** wie bei anderen Tools!
> Plesk prüft exakt gegen die eingetragene IP. Für "alle IPs erlauben" muss der
> `-ip-address`-Parameter komplett weggelassen werden.
Der Befehl gibt den Key direkt zurück. Diesen kopieren und im CRM eintragen.
**Alle API-Keys anzeigen:**
```bash
plesk bin secret_key --list
```
**API-Key löschen:**
```bash
plesk bin secret_key --delete <KEY>
```
### Plesk: REST API aktivieren (falls nicht vorhanden)
Bei älteren Plesk-Versionen oder Custom-Installationen kann es sein, dass die
REST API fehlt. Dann:
1. **Tools & Einstellungen** → **Updates** → **Erweiterungen hinzufügen/entfernen**
2. Nach **"REST API"** suchen und installieren
3. Plesk-Neustart (meist nicht nötig, aber zur Sicherheit)
### Plesk: Firewall-Hinweis
Der CRM-Server muss den **Plesk-Port 8443** (Standard) erreichen können. Bei Plesk-Firewall:
1. **Tools & Einstellungen** → **Firewall**
2. **"Plesk-Dienst Panel"** (Port 8443) für die IP des CRM-Servers erlauben
Bei reiner Linux-Firewall (ufw/firewalld):
```bash
# Beispiel ufw
ufw allow from <CRM-SERVER-IP> to any port 8443
```
### Verwendung
Beim Anlegen einer Stressfrei-Wechseln Adresse im Kundenbereich erscheint die Checkbox **"Beim E-Mail-Provider anlegen"**, wenn:
@@ -1033,167 +916,6 @@ Folgende Felder werden in Audit-Logs gefiltert:
- API-Response wird nicht blockiert
- Before/After-Werte über Prisma Middleware
## Factory-Defaults: Stammdaten-Kataloge teilen
Das **Factory-Defaults**-System erlaubt den Export und Import von
Stammdaten-Katalogen (Anbieter, Tarife, PDF-Vorlagen usw.) zwischen verschiedenen
OpenCRM-Installationen. Es ist bewusst **streng abgegrenzt** zu Datenbank-Backups:
### Abgrenzung
| | Factory-Defaults | Datenbank-Backup |
|---|---|---|
| Anbieter, Tarife, Kündigungsfristen, Laufzeiten, Kategorien | ✅ | ✅ |
| PDF-Auftragsvorlagen (inkl. Dateien + Feldzuordnungen) | ✅ | ✅ |
| **Kundendaten, Verträge, Dokumente** | ❌ | ✅ |
| **Emails, SMTP-/IMAP-Zugangsdaten** | ❌ | ✅ |
| **System-Einstellungen, Datenschutzerklärungen, Impressum** | ❌ | ✅ |
| Zwischen verschiedenen Installationen teilbar | ✅ | ❌ (zu firmen-spezifisch) |
> **Kurz:** Factory-Defaults = reine Kataloge, Backup = alles.
### Export (Installation A → ZIP)
1. **Einstellungen** → **Factory-Defaults** öffnen
2. Übersicht prüfen (Anzahl pro Kategorie)
3. Button **„Factory-Defaults exportieren"** klicken
4. ZIP wird als `factory-defaults-YYYY-MM-DD.zip` heruntergeladen
**ZIP-Struktur:**
```
factory-defaults-2026-04-23.zip
├── manifest.json # Version + Datum + Counts
├── providers/
│ └── providers.json # Anbieter inkl. zugehöriger Tarife
├── contract-meta/
│ ├── cancellation-periods.json # Kündigungsfristen (Code + Beschreibung)
│ ├── contract-durations.json # Laufzeiten (Code + Beschreibung)
│ └── contract-categories.json # Kategorien (Strom, Gas, DSL, ...)
└── pdf-templates/
├── pdf-templates.json # Vorlagen-Metadaten + Feldzuordnungen
└── *.pdf # Die eigentlichen PDF-Dateien
```
Die ZIP kann an andere Installationen weitergegeben werden
(Partner, Test-System, neue Installation).
### Import (ZIP → Installation B)
1. ZIP herunterladen bzw. erhalten
2. Inhalt nach `backend/factory-defaults/` entpacken (Unterordnerstruktur beibehalten)
3. Im Backend-Verzeichnis ausführen:
```bash
npm run seed:defaults
```
**Beispiel-Output:**
```
📦 Factory-Defaults werden eingespielt...
✓ Anbieter: 7, Tarife: 12
✓ Kündigungsfristen: 5
✓ Laufzeiten: 4
✓ Vertragskategorien: 8
✓ PDF-Vorlagen: 3
✅ Factory-Defaults erfolgreich eingespielt.
```
### Mehrere ZIPs kombinieren
Du kannst mehrere Exporte in `backend/factory-defaults/` übereinanderlegen
JSON-Dateien werden automatisch gemerged:
```
backend/factory-defaults/
providers/
verivox.json # 40 Anbieter aus Verivox-Paket
check24.json # 30 Anbieter aus Check24-Paket
eigene.json # 5 eigene Anbieter
```
Das Import-Script liest **alle** `*.json` im jeweiligen Unterordner und merged per
unique Key (letzter Eintrag gewinnt). Duplikate sind also unproblematisch.
### Idempotenz
Das Script nutzt ausschließlich Prisma `upsert`:
- **Neue Einträge** werden angelegt
- **Bestehende Einträge** (per unique Key: `name`, `code`) werden aktualisiert
- Nichts wird gelöscht
Du kannst `npm run seed:defaults` also beliebig oft ausführen, ohne Datenverlust
oder Duplikate.
### PDF-Dateien beim Import
Beim Import werden PDF-Vorlagen aus `factory-defaults/pdf-templates/*.pdf` nach
`uploads/pdf-templates/` kopiert und die Pfade in der DB entsprechend gesetzt.
Beim Re-Import wird die alte Datei in `uploads/` entsorgt und durch die neue
ersetzt.
### Berechtigungen
| Aktion | Berechtigung |
|--------|--------------|
| Factory-Defaults Vorschau | `settings:read` |
| Factory-Defaults Export | `settings:update` |
| Factory-Defaults Import (CLI) | Server-Zugang (SSH/Shell) |
### Typischer Einsatzzweck
- **Neue Installation aufsetzen**: Eine Kollegen-ZIP importieren und sofort mit
gepflegtem Anbieter- und Vorlagenkatalog loslegen
- **Vorlagen-Paket teilen**: Eine ZIP mit nur PDF-Vorlagen weitergeben
(die anderen Ordner einfach aus der ZIP entfernen vor dem Entpacken)
- **Anbieter-Paket teilen**: ZIP mit nur `providers/` weitergeben
- **Versionskontrolle**: Die entpackten JSON-Dateien unter Versionskontrolle
stellen (außerhalb von `backend/factory-defaults/`, da der Ordner gitignored ist)
## Changelog
### 1.1.0 (2026-05-01)
**Production-readiness** die Version, die wirklich öffentlich gehen darf.
- 🛡 **Security-Hardening**: 10 Runden statisches + dynamisches Audit, vollständig
dokumentiert in [docs/SECURITY-HARDENING.md](docs/SECURITY-HARDENING.md)
(CORS/Helmet/JWT, IDOR-Schutz an 30+ Endpoints, Mass-Assignment-Whitelists,
Zip-Slip, Path-Traversal, Login-Timing-Side-Channel, XFF-Rate-Limit-Bypass,
Customer-Liste-Leak, SSRF + DNS-Rebinding, Per-File-Ownership statt
freiem `/api/uploads`, JWT-Logout, Audit-Log-Hash-Chain).
- 🚨 **Sicherheits-Monitoring**: neue `SecurityEvent`-Tabelle + Hooks an Login,
Logout, Rate-Limit-Hit, IDOR-Abwehr, SSRF-Block, Password-Reset, JWT-Reject.
Threshold-Detection (Brute-Force, IDOR-Probing, SSRF-Probing) erzeugt
CRITICAL-Events. **Sofort-E-Mail-Alerts** für CRITICAL + **stündlicher Digest**
für HIGH/MEDIUM. UI in Einstellungen → Monitoring mit Filter, Pagination,
Log-leeren (mit optionalem Tage-Filter) und Test-Alert-Button.
- 🔄 **Auto-Vertragsstatus**:
- Lieferbestätigung-Upload → `DRAFT` → `ACTIVE` + `startDate`
- Kündigungsbestätigung-Upload → `ACTIVE` → `CANCELLED` + `cancellationConfirmationDate`
(mit Datums-Modal beim Upload)
- Nightly-Cron 02:00: alle `ACTIVE`-Verträge mit `endDate < heute` → `EXPIRED`
- 🔐 **Lazy bcrypt-Rehash**: Bestandshashes mit Cost 10 werden beim nächsten
Login transparent auf Cost 12 geupgradet.
- 🚪 **Logout-Endpoint** `POST /api/auth/logout`: invalidiert JWTs serverseitig
über `tokenInvalidatedAt`.
- 📦 **`npm audit fix`**: 8 transitive Vulnerabilities gefixt (lodash,
path-to-regexp, undici, minimatch).
### 1.0.0
Erste Release-Version.
- Kunden-, Vertrags-, Adress-, Bankkarten-, Ausweis- und Zählerverwaltung
- Energie-/Telekommunikations-/KFZ-Verträge mit typspezifischen Details
- Vertrags-Cockpit mit Rechnungsprüfung
- E-Mail-Client mit Anhang-Verwaltung
- DSGVO-Compliance: Audit-Log, Einwilligungen, Datenexport, Löschanfragen
- PDF-Auftragsvorlagen-System mit visueller Feldzuordnung
- Factory-Defaults für Stammdaten-Kataloge
- Mandantenfähigkeit über `customerEmailLabel` pro Provider
- Passwort-Reset-Flow + Rate-Limiting + Auto-Geburtstagsgrüße
## Lizenz
MIT
+13
View File
@@ -0,0 +1,13 @@
# Database - Root für Migrationen, opencrm-User für Runtime
DATABASE_URL="mysql://root:rootpassword@localhost:3306/opencrm"
# JWT
JWT_SECRET="change-this-to-a-very-long-random-secret-in-production"
JWT_EXPIRES_IN="7d"
# Encryption (for portal credentials) - generate with: openssl rand -hex 32
ENCRYPTION_KEY="0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
# Server
PORT=3001
NODE_ENV=development
-5
View File
@@ -17,11 +17,6 @@ prisma/backups/*
uploads/*
!uploads/.gitkeep
# Factory Defaults (firmen-spezifische Kataloge, bleiben lokal)
factory-defaults/*
!factory-defaults/.gitkeep
!factory-defaults/README.md
# Logs
*.log
npm-debug.log*
View File
-256
View File
@@ -1,256 +0,0 @@
# Factory Defaults
Dieser Ordner enthält **Stammdaten-Kataloge**, die beim Initialisieren einer neuen
OpenCRM-Installation automatisch eingespielt werden können.
Siehe auch den Abschnitt „Factory-Defaults" in der Haupt-[README.md](../../README.md)
für einen Gesamtüberblick und die Abgrenzung zum Datenbank-Backup.
---
## Inhalt
```
backend/factory-defaults/
├── providers/
│ └── providers.json # Anbieter inkl. Tarife
├── contract-meta/
│ ├── cancellation-periods.json # Kündigungsfristen
│ ├── contract-durations.json # Vertragslaufzeiten
│ └── contract-categories.json # Vertragskategorien (Strom/Gas/DSL/...)
└── pdf-templates/
├── pdf-templates.json # Metadaten + Feldzuordnungen
└── *.pdf # PDF-Vorlagen-Dateien
```
**Was NICHT enthalten ist:** Kundendaten, Verträge, Dokumente, E-Mails, SMTP-Einstellungen,
Datenschutzerklärungen oder andere AppSettings. Dafür gibt es den separaten
**Datenbank-Backup-Export** (Einstellungen → Datenbank & Zurücksetzen).
---
## Export (aus einer bestehenden Installation)
1. Im CRM als Admin einloggen
2. **Einstellungen****Factory-Defaults**
3. Auf **„Factory-Defaults exportieren"** klicken
4. Die heruntergeladene ZIP (`factory-defaults-YYYY-MM-DD.zip`) speichern
### Inhalt der ZIP
```
factory-defaults-2026-04-23.zip
├── manifest.json # Version, Datum, Einträge pro Kategorie
├── providers/providers.json
├── contract-meta/cancellation-periods.json
├── contract-meta/contract-durations.json
├── contract-meta/contract-categories.json
├── pdf-templates/pdf-templates.json
└── pdf-templates/*.pdf
```
Die ZIP kann an andere Installationen weitergegeben werden z.B. für Test-Systeme,
neue Installationen oder Partner-Setups.
---
## Import (in eine andere Installation)
### Schritt-für-Schritt
1. **ZIP herunterladen** (aus einer Export-Installation oder von einer Vorlage)
2. **Inhalt entpacken** in diesen Ordner (`backend/factory-defaults/`),
Unterordnerstruktur beibehalten
3. **Script ausführen:**
```bash
cd backend
npm run seed:defaults
```
4. **Ausgabe prüfen** bei Erfolg:
```
📦 Factory-Defaults werden eingespielt...
✓ Anbieter: 7, Tarife: 12
✓ Kündigungsfristen: 5
✓ Laufzeiten: 4
✓ Vertragskategorien: 8
✓ PDF-Vorlagen: 3
✅ Factory-Defaults erfolgreich eingespielt.
```
### Idempotenz
Das Script nutzt ausschließlich `upsert`:
- **Neue Einträge** werden angelegt
- **Bestehende Einträge** (match per unique key: `name` / `code`) werden aktualisiert
- Nichts wird gelöscht
Du kannst `npm run seed:defaults` **beliebig oft ausführen** kein Datenverlust,
keine Duplikate.
### Was passiert mit den PDF-Dateien?
Die PDFs aus `pdf-templates/*.pdf` werden beim Import nach
`backend/uploads/pdf-templates/` kopiert (mit eindeutigem Zeitstempel-Suffix).
Die Pfade in der DB werden automatisch auf die neue Kopie gesetzt.
Beim Re-Import einer bereits existierenden Vorlage wird die alte Datei in `uploads/`
entsorgt und durch die neue ersetzt.
---
## Mehrere Exporte mergen
Wenn du mehrere ZIPs hast (z.B. "Verivox-Paket", "Check24-Paket", "eigene"), kannst
du die JSON-Dateien frei benennen und in einen Ordner legen. Das Script liest
alle `*.json` im jeweiligen Unterordner und merged den Inhalt zusammen.
**Beispiel:**
```
backend/factory-defaults/
providers/
verivox.json # 40 Anbieter aus dem Verivox-Paket
check24.json # 30 Anbieter aus dem Check24-Paket
eigene.json # 5 eigene, firmenspezifische Anbieter
contract-meta/
standard.json # Standard-Kündigungsfristen + Laufzeiten + Kategorien
pdf-templates/
ewe-paket.json # EWE-Vorlage
moon-paket.json # Moon-Vorlage
ewe-auftrag.pdf
moon-formular.pdf
```
Bei gleichem Unique-Key (z.B. `providers.name: "EWE"` in mehreren Dateien) gewinnt
der zuletzt gelesene Eintrag.
---
## Teil-Import (nur Kategorien auswählen)
Falls du nur einen Teil importieren willst (z.B. nur PDF-Vorlagen ohne Anbieter),
lösche oder verschiebe einfach die nicht gewünschten JSON-Dateien, bevor du das
Script ausführst. Das Script überspringt Kategorien ohne Dateien ohne Fehler.
**Beispiel: nur PDF-Vorlagen:**
```
backend/factory-defaults/
pdf-templates/ # nur diesen Ordner behalten
pdf-templates.json
*.pdf
```
---
## Struktur-Referenz (für manuelle Pflege)
### `providers/providers.json`
Array von Providern, jeweils inkl. zugehöriger Tarife:
```json
[
{
"name": "EWE",
"portalUrl": "https://www.ewe.de/privatkunden/meine-ewe/login",
"usernameFieldName": "username",
"passwordFieldName": "password",
"isActive": true,
"tariffs": [
{ "name": "EWE Zuhause Strom", "isActive": true },
{ "name": "EWE Zuhause Gas", "isActive": true }
]
}
]
```
**Unique Key:** `name`
### `contract-meta/cancellation-periods.json`
```json
[
{ "code": "14T", "description": "14 Tage", "isActive": true },
{ "code": "1M", "description": "1 Monat", "isActive": true },
{ "code": "3M", "description": "3 Monate", "isActive": true }
]
```
**Unique Key:** `code`
### `contract-meta/contract-durations.json`
```json
[
{ "code": "12M", "description": "12 Monate", "isActive": true },
{ "code": "24M", "description": "24 Monate", "isActive": true }
]
```
**Unique Key:** `code`
### `contract-meta/contract-categories.json`
```json
[
{
"code": "ELECTRICITY",
"name": "Strom",
"icon": "Zap",
"color": "#FFC107",
"sortOrder": 1,
"isActive": true
}
]
```
**Unique Key:** `code`
### `pdf-templates/pdf-templates.json`
```json
[
{
"name": "EWE Auftragsformular",
"description": "Auftrag für Glasfaser-Anschluss",
"providerName": "EWE",
"originalName": "EWE-Auftrag-Privat.pdf",
"fieldMapping": {
"Vorname": "customer.firstName",
"Nachname": "customer.lastName",
"Strasse": "address.streetFull",
"PLZ": "address.postalCode",
"Ort": "address.city"
},
"phoneFieldPrefix": "Rufnummer",
"maxPhoneFields": 8,
"isActive": true,
"pdfFilename": "EWE_Auftragsformular.pdf"
}
]
```
**Unique Key:** `name`
**Wichtig:** Die `pdfFilename` muss zu einer PDF-Datei im selben Ordner passen.
---
## Berechtigungen
| Aktion | Berechtigung |
|--------|--------------|
| Factory-Defaults Vorschau | `settings:read` |
| Factory-Defaults Export (UI) | `settings:update` |
| Factory-Defaults Import (CLI) | Server-Zugang (SSH/Shell) |
---
## Git & Versionierung
Dieser Ordner ist in `.gitignore` eingetragen (außer `.gitkeep` und `README.md`),
damit firmen-spezifische Exporte nicht versehentlich ins Repo kommen.
Wenn du **öffentlich teilbare Katalog-Pakete** versionieren willst, lege sie
außerhalb dieses Ordners ab (z.B. in einem eigenen Repository) und kopiere sie
bei Bedarf hierher.
+49 -182
View File
@@ -15,14 +15,11 @@
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.1",
"express-rate-limit": "^8.4.0",
"express-validator": "^7.2.0",
"helmet": "^8.1.0",
"imapflow": "^1.2.8",
"jsonwebtoken": "^9.0.2",
"mailparser": "^3.9.3",
"multer": "^1.4.5-lts.1",
"node-cron": "^4.2.1",
"nodemailer": "^7.0.13",
"pdf-lib": "^1.17.1",
"pdfkit": "^0.17.2",
@@ -38,7 +35,6 @@
"@types/mailparser": "^3.4.6",
"@types/multer": "^1.4.12",
"@types/node": "^22.9.0",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^7.0.9",
"@types/pdfkit": "^0.17.4",
"prisma": "^5.22.0",
@@ -511,8 +507,7 @@
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
@@ -749,13 +744,6 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/node-cron": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz",
"integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/nodemailer": {
"version": "7.0.9",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.9.tgz",
@@ -987,7 +975,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
@@ -1071,10 +1058,9 @@
}
},
"node_modules/brace-expansion": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"license": "MIT",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dependencies": {
"balanced-match": "^1.0.0"
}
@@ -1676,24 +1662,6 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.4.0.tgz",
"integrity": "sha512-gDK8yiqKxrGta+3WtON59arrrw6GLmadA1qoFgYXzdcch8fmKDID2XqO8itsi3f1wufXYPT51387dN6cvVBS3Q==",
"license": "MIT",
"dependencies": {
"ip-address": "10.1.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/express-validator": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.1.tgz",
@@ -1914,15 +1882,6 @@
"he": "bin/he"
}
},
"node_modules/helmet": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/html-to-text": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
@@ -2006,31 +1965,19 @@
]
},
"node_modules/imapflow": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.3.3.tgz",
"integrity": "sha512-lx7nWcUDfNgITEKYYfunUDqJ3LT6ImuiA1ReqJepVEA2nqBQNUqa3ppF7Yz5CNjuDYG95pmzsCcNqRjMrwh/Vg==",
"license": "MIT",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.2.8.tgz",
"integrity": "sha512-ym7FF2tKOlOzfRvxehs4eLkhjP8Mme3sSp2tcxEbyoeJuJwtEWxaVDv12+DnaMG2LXm0zuQGWZiClq31FLPUNg==",
"dependencies": {
"@zone-eu/mailsplit": "5.4.9",
"@zone-eu/mailsplit": "5.4.8",
"encoding-japanese": "2.2.0",
"iconv-lite": "0.7.2",
"libbase64": "1.3.0",
"libmime": "5.3.8",
"libmime": "5.3.7",
"libqp": "2.1.1",
"nodemailer": "8.0.7",
"pino": "10.3.1",
"socks": "2.8.8"
}
},
"node_modules/imapflow/node_modules/@zone-eu/mailsplit": {
"version": "5.4.9",
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.9.tgz",
"integrity": "sha512-Qq7k6FzA5SmGf5HFPcr17gE7M+O1gttlmWn7tlGUlhGsbbjUaBL/4cEWIwExeCzqu5+kyZJ91mcBZbQ9zEwwYA==",
"license": "(MIT OR EUPL-1.1+)",
"dependencies": {
"libbase64": "1.3.0",
"libmime": "5.3.8",
"libqp": "2.1.1"
"nodemailer": "7.0.13",
"pino": "10.3.0",
"socks": "2.8.7"
}
},
"node_modules/imapflow/node_modules/iconv-lite": {
@@ -2048,27 +1995,6 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/imapflow/node_modules/libmime": {
"version": "5.3.8",
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.8.tgz",
"integrity": "sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==",
"license": "MIT",
"dependencies": {
"encoding-japanese": "2.2.0",
"iconv-lite": "0.7.2",
"libbase64": "1.3.0",
"libqp": "2.1.1"
}
},
"node_modules/imapflow/node_modules/nodemailer": {
"version": "8.0.7",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz",
"integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -2261,10 +2187,9 @@
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"node_modules/lodash.includes": {
"version": "4.3.0",
@@ -2307,19 +2232,18 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
},
"node_modules/mailparser": {
"version": "3.9.8",
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.8.tgz",
"integrity": "sha512-7jSlFGXiianVnhnb6wdutJFloD34488nrHY7r6FNqwXAhZ7YiJDYrKKTxZJ0oSrXcAPHm8YoYnh97xyGtrBQ3w==",
"license": "MIT",
"version": "3.9.3",
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.3.tgz",
"integrity": "sha512-AnB0a3zROum6fLaa52L+/K2SoRJVyFDk78Ea6q1D0ofcZLxWEWDtsS1+OrVqKbV7r5dulKL/AwYQccFGAPpuYQ==",
"dependencies": {
"@zone-eu/mailsplit": "5.4.8",
"encoding-japanese": "2.2.0",
"he": "1.2.0",
"html-to-text": "9.0.5",
"iconv-lite": "0.7.2",
"libmime": "5.3.8",
"libmime": "5.3.7",
"linkify-it": "5.0.0",
"nodemailer": "8.0.5",
"nodemailer": "7.0.13",
"punycode.js": "2.3.1",
"tlds": "1.261.0"
}
@@ -2339,27 +2263,6 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/mailparser/node_modules/libmime": {
"version": "5.3.8",
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.8.tgz",
"integrity": "sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==",
"license": "MIT",
"dependencies": {
"encoding-japanese": "2.2.0",
"iconv-lite": "0.7.2",
"libbase64": "1.3.0",
"libqp": "2.1.1"
}
},
"node_modules/mailparser/node_modules/nodemailer": {
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz",
"integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -2423,12 +2326,11 @@
}
},
"node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"license": "ISC",
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dependencies": {
"brace-expansion": "^2.0.2"
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -2495,15 +2397,6 @@
"node": ">= 0.6"
}
},
"node_modules/node-cron": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
"license": "ISC",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nodemailer": {
"version": "7.0.13",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
@@ -2543,7 +2436,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
@@ -2613,10 +2505,9 @@
}
},
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="
},
"node_modules/pdf-lib": {
"version": "1.17.1",
@@ -2663,10 +2554,9 @@
}
},
"node_modules/pino": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
"integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
"license": "MIT",
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.0.tgz",
"integrity": "sha512-0GNPNzHXBKw6U/InGe79A3Crzyk9bcSyObF9/Gfo9DLEf5qj5RF50RSjsu0W1rZ6ZqRGdzDFCRBQvi9/rSGPtA==",
"dependencies": {
"@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0",
@@ -2688,7 +2578,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
"license": "MIT",
"dependencies": {
"split2": "^4.0.0"
}
@@ -2696,8 +2585,7 @@
"node_modules/pino-std-serializers": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT"
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="
},
"node_modules/png-js": {
"version": "1.0.0",
@@ -2749,8 +2637,7 @@
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
]
},
"node_modules/proxy-addr": {
"version": "2.0.7",
@@ -2773,10 +2660,9 @@
}
},
"node_modules/qs": {
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
"license": "BSD-3-Clause",
"version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"dependencies": {
"side-channel": "^1.1.0"
},
@@ -2790,8 +2676,7 @@
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="
},
"node_modules/range-parser": {
"version": "1.2.1",
@@ -2843,10 +2728,9 @@
}
},
"node_modules/readdir-glob/node_modules/minimatch": {
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"license": "ISC",
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"dependencies": {
"brace-expansion": "^2.0.1"
},
@@ -2858,7 +2742,6 @@
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
"license": "MIT",
"engines": {
"node": ">= 12.13.0"
}
@@ -2900,7 +2783,6 @@
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
@@ -3081,19 +2963,17 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks": {
"version": "2.8.8",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.8.tgz",
"integrity": "sha512-NlGELfPrgX2f1TAAcz0WawlLn+0r3FyhhCRpFFK2CemXenPYvzMWWZINv3eDNo9ucdwme7oCHRY0Jnbs4aIkog==",
"license": "MIT",
"version": "2.8.7",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
"integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
"dependencies": {
"ip-address": "^10.1.1",
"ip-address": "^10.0.1",
"smart-buffer": "^4.2.0"
},
"engines": {
@@ -3101,20 +2981,10 @@
"npm": ">= 3.0.0"
}
},
"node_modules/socks/node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
"license": "MIT",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz",
"integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==",
"dependencies": {
"atomic-sleep": "^1.0.0"
}
@@ -3123,7 +2993,6 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
@@ -3277,7 +3146,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
"integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==",
"license": "MIT",
"dependencies": {
"real-require": "^0.2.0"
},
@@ -3366,10 +3234,9 @@
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="
},
"node_modules/undici": {
"version": "6.25.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz",
"integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==",
"license": "MIT",
"version": "6.23.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
"engines": {
"node": ">=18.17"
}
+2 -7
View File
@@ -1,6 +1,6 @@
{
"name": "opencrm-backend",
"version": "1.1.0",
"version": "1.0.0",
"description": "OpenCRM Backend API",
"main": "dist/index.js",
"prisma": {
@@ -15,8 +15,7 @@
"db:seed": "tsx prisma/seed.ts",
"db:studio": "prisma studio",
"db:backup": "tsx prisma/backup-data.ts",
"db:restore": "tsx prisma/restore-data.ts",
"seed:defaults": "tsx scripts/seed-factory-defaults.ts"
"db:restore": "tsx prisma/restore-data.ts"
},
"dependencies": {
"@prisma/client": "^5.22.0",
@@ -26,14 +25,11 @@
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.1",
"express-rate-limit": "^8.4.0",
"express-validator": "^7.2.0",
"helmet": "^8.1.0",
"imapflow": "^1.2.8",
"jsonwebtoken": "^9.0.2",
"mailparser": "^3.9.3",
"multer": "^1.4.5-lts.1",
"node-cron": "^4.2.1",
"nodemailer": "^7.0.13",
"pdf-lib": "^1.17.1",
"pdfkit": "^0.17.2",
@@ -49,7 +45,6 @@
"@types/mailparser": "^3.4.6",
"@types/multer": "^1.4.12",
"@types/node": "^22.9.0",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^7.0.9",
"@types/pdfkit": "^0.17.4",
"prisma": "^5.22.0",
+25 -52
View File
@@ -1,15 +1,12 @@
/**
* Datenbank-Backup Script
*
* Exportiert ALLE Daten als JSON-Dateien für die Wiederherstellung nach Migrationen.
* Exportiert alle Daten als JSON-Dateien für die Wiederherstellung nach Migrationen.
*
* Verwendung:
* npm run db:backup
* npx ts-node prisma/backup-data.ts
*
* Erstellt einen Ordner 'prisma/backups/YYYY-MM-DDTHH-mm-ss/' mit JSON-Dateien pro Tabelle.
*
* Die Tabellen sind nach Abhängigkeitsreihenfolge sortiert (Level 0 = keine FKs, dann aufsteigend).
* Damit kann das Restore-Script sie in der gleichen Reihenfolge einspielen, ohne FK-Verletzungen.
* Erstellt einen Ordner 'backups/YYYY-MM-DD_HH-mm-ss/' mit JSON-Dateien pro Tabelle.
*/
import { PrismaClient } from '@prisma/client';
@@ -31,7 +28,7 @@ async function main() {
// Tabellen in Abhängigkeitsreihenfolge (unabhängige zuerst)
const tables = [
// ============ Level 0: Reine Stammdaten/Kataloge ============
// Level 0: Keine Abhängigkeiten
{ name: 'Permission', query: () => prisma.permission.findMany() },
{ name: 'Role', query: () => prisma.role.findMany() },
{ name: 'SalesPlatform', query: () => prisma.salesPlatform.findMany() },
@@ -40,58 +37,40 @@ async function main() {
{ name: 'ContractDuration', query: () => prisma.contractDuration.findMany() },
{ name: 'AppSetting', query: () => prisma.appSetting.findMany() },
{ name: 'EmailProviderConfig', query: () => prisma.emailProviderConfig.findMany() },
{ name: 'Provider', query: () => prisma.provider.findMany() },
{ name: 'PdfTemplate', query: () => prisma.pdfTemplate.findMany() },
{ name: 'AuditRetentionPolicy', query: () => prisma.auditRetentionPolicy.findMany() },
{ name: 'EnergyProvider', query: () => prisma.energyProvider.findMany() },
{ name: 'TelecomProvider', query: () => prisma.telecomProvider.findMany() },
// ============ Level 1: Abhängig von Level 0 ============
// Level 1: Abhängig von Level 0
{ name: 'RolePermission', query: () => prisma.rolePermission.findMany() },
{ name: 'User', query: () => prisma.user.findMany() },
{ name: 'Customer', query: () => prisma.customer.findMany() },
{ name: 'Tariff', query: () => prisma.tariff.findMany() },
// ============ Level 2: Abhängig von Customer ============
// Level 2: Abhängig von Level 1
{ name: 'UserRole', query: () => prisma.userRole.findMany() },
{ name: 'Address', query: () => prisma.address.findMany() },
{ name: 'BankCard', query: () => prisma.bankCard.findMany() },
{ name: 'IdentityDocument', query: () => prisma.identityDocument.findMany() },
{ name: 'Meter', query: () => prisma.meter.findMany() },
{ name: 'StressfreiEmail', query: () => prisma.stressfreiEmail.findMany() },
{ name: 'CustomerRepresentative', query: () => prisma.customerRepresentative.findMany() },
{ name: 'CustomerConsent', query: () => prisma.customerConsent.findMany() },
{ name: 'DataDeletionRequest', query: () => prisma.dataDeletionRequest.findMany() },
// ============ Level 3: Contracts + abhängige ============
{ name: 'StressfreiEmail', query: () => prisma.stressfreiEmail.findMany() },
{ name: 'Contract', query: () => prisma.contract.findMany() },
{ name: 'RepresentativeAuthorization', query: () => prisma.representativeAuthorization.findMany() },
{ name: 'Meter', query: () => prisma.meter.findMany() },
// ============ Level 4: Vertragstyp-Details + Sub-Tabellen ============
{ name: 'EnergyContractDetails', query: () => prisma.energyContractDetails.findMany() },
{ name: 'InternetContractDetails', query: () => prisma.internetContractDetails.findMany() },
{ name: 'MobileContractDetails', query: () => prisma.mobileContractDetails.findMany() },
{ name: 'TvContractDetails', query: () => prisma.tvContractDetails.findMany() },
{ name: 'CarInsuranceDetails', query: () => prisma.carInsuranceDetails.findMany() },
{ name: 'ContractMeter', query: () => prisma.contractMeter.findMany() },
{ name: 'ContractDocument', query: () => prisma.contractDocument.findMany() },
{ name: 'ContractHistoryEntry', query: () => prisma.contractHistoryEntry.findMany() },
{ name: 'ContractTask', query: () => prisma.contractTask.findMany() },
{ name: 'Invoice', query: () => prisma.invoice.findMany() },
{ name: 'MeterReading', query: () => prisma.meterReading.findMany() },
// ============ Level 5: Sub-Tabellen der Sub-Tabellen ============
{ name: 'ContractTaskSubtask', query: () => prisma.contractTaskSubtask.findMany() },
{ name: 'PhoneNumber', query: () => prisma.phoneNumber.findMany() },
{ name: 'SimCard', query: () => prisma.simCard.findMany() },
// ============ Level 6: Logs & Emails (wachsende Tabellen) ============
// Level 3: Abhängig von Level 2
{ name: 'CachedEmail', query: () => prisma.cachedEmail.findMany() },
{ name: 'EmailLog', query: () => prisma.emailLog.findMany() },
{ name: 'AuditLog', query: () => prisma.auditLog.findMany() },
{ name: 'ContractTask', query: () => prisma.contractTask.findMany() },
{ name: 'MeterReading', query: () => prisma.meterReading.findMany() },
{ name: 'ContractNote', query: () => prisma.contractNote.findMany() },
{ name: 'ContractDocument', query: () => prisma.contractDocument.findMany() },
// Level 4: Abhängig von Level 3
{ name: 'ContractTaskSubtask', query: () => prisma.contractTaskSubtask.findMany() },
// Vertragstyp-spezifische Details
{ name: 'EnergyContractDetails', query: () => prisma.energyContractDetails.findMany() },
{ name: 'TelecomContractDetails', query: () => prisma.telecomContractDetails.findMany() },
{ name: 'CarInsuranceDetails', query: () => prisma.carInsuranceDetails.findMany() },
];
let totalRecords = 0;
const stats: { table: string; count: number }[] = [];
const skipped: string[] = [];
for (const table of tables) {
try {
@@ -100,7 +79,7 @@ async function main() {
totalRecords += count;
stats.push({ table: table.name, count });
// JSON-Datei schreiben (Date-Felder als ISO-String)
// JSON-Datei schreiben
const filePath = path.join(backupDir, `${table.name}.json`);
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
@@ -108,26 +87,20 @@ async function main() {
console.log(`${status} ${table.name}: ${count} Einträge`);
} catch (error: any) {
// Tabelle existiert möglicherweise nicht (bei älteren Schema-Versionen)
skipped.push(table.name);
console.log(`⚠️ ${table.name}: Übersprungen (${error.message?.slice(0, 80)}...)`);
console.log(`⚠️ ${table.name}: Übersprungen (${error.message?.slice(0, 50)}...)`);
}
}
// Backup-Info speichern
const backupInfo = {
timestamp: new Date().toISOString(),
schemaVersion: 'current',
totalRecords,
tables: stats,
skippedTables: skipped,
};
fs.writeFileSync(path.join(backupDir, '_backup-info.json'), JSON.stringify(backupInfo, null, 2));
console.log(`\n✅ Backup abgeschlossen!`);
console.log(` 📊 ${totalRecords} Datensätze in ${stats.filter(s => s.count > 0).length} Tabellen`);
if (skipped.length > 0) {
console.log(` ⚠️ ${skipped.length} Tabellen übersprungen: ${skipped.join(', ')}`);
}
console.log(` 📁 Gespeichert in: ${backupDir}\n`);
}
+373 -144
View File
@@ -4,40 +4,43 @@
* Stellt Daten aus einem JSON-Backup wieder her.
*
* Verwendung:
* npm run db:restore # Letztes Backup
* npx tsx prisma/restore-data.ts <ordner> # Bestimmtes Backup
* npx ts-node prisma/restore-data.ts [backup-ordner]
*
* WICHTIG: Führe vorher 'npx prisma db push' oder 'npx prisma migrate deploy' aus,
* damit das Schema zur DB passt!
* Beispiele:
* npx ts-node prisma/restore-data.ts # Letztes Backup
* npx ts-node prisma/restore-data.ts 2025-01-31_14-30-00 # Bestimmtes Backup
*
* WICHTIG: Führe vorher 'npx prisma migrate deploy' oder 'npx prisma db push' aus!
*/
import { PrismaClient, Prisma } from '@prisma/client';
import { PrismaClient } from '@prisma/client';
import * as fs from 'fs';
import * as path from 'path';
const prisma = new PrismaClient();
// Hilfsfunktion: JSON-Datei lesen (leer bei fehlender Datei)
// Hilfsfunktion: JSON-Datei lesen
function readJsonFile<T>(filePath: string): T[] {
if (!fs.existsSync(filePath)) return [];
try {
const content = fs.readFileSync(filePath, 'utf-8');
return JSON.parse(content);
} catch {
if (!fs.existsSync(filePath)) {
return [];
}
const content = fs.readFileSync(filePath, 'utf-8');
return JSON.parse(content);
}
// Hilfsfunktion: ISO-Datum-Strings rekursiv zu Date-Objekten
// Hilfsfunktion: Datum-Strings zu Date-Objekten konvertieren
function convertDates(obj: any): any {
if (obj === null || obj === undefined) return obj;
if (typeof obj === 'string') {
// ISO-Datumsformat erkennen
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(obj)) {
return new Date(obj);
}
return obj;
}
if (Array.isArray(obj)) return obj.map(convertDates);
if (Array.isArray(obj)) {
return obj.map(convertDates);
}
if (typeof obj === 'object') {
const result: any = {};
for (const [key, value] of Object.entries(obj)) {
@@ -48,75 +51,19 @@ function convertDates(obj: any): any {
return obj;
}
/**
* Generischer Restore-Helper: nutzt createMany mit skipDuplicates
* wenn möglich, sonst einzelnes upsert per ID.
*/
async function restoreTable<T extends { id?: any }>(
tableName: string,
data: T[],
model: any,
options: { useCreateMany?: boolean; compositeKey?: string[] } = {},
): Promise<number> {
if (data.length === 0) return 0;
const converted = data.map(convertDates) as T[];
// Bei einfachen Tabellen: createMany mit skipDuplicates
if (options.useCreateMany) {
try {
const result = await model.createMany({
data: converted,
skipDuplicates: true,
});
return result.count;
} catch (err: any) {
// Fallback auf einzeln
}
}
// Upsert per ID (oder Composite-Key)
let count = 0;
for (const item of converted) {
try {
if (options.compositeKey) {
const where: any = {};
const compositeWhere: any = {};
for (const key of options.compositeKey) {
compositeWhere[key] = (item as any)[key];
}
where[options.compositeKey.join('_')] = compositeWhere;
await model.upsert({
where,
update: {},
create: item,
});
} else {
await model.upsert({
where: { id: (item as any).id },
update: item,
create: item,
});
}
count++;
} catch (err: any) {
console.log(` ⚠️ Eintrag in ${tableName} (id=${(item as any).id}): ${err.message?.slice(0, 80)}`);
}
}
return count;
}
async function main() {
// Backup-Ordner bestimmen
const backupsDir = path.join(__dirname, 'backups');
let backupName = process.argv[2];
if (!backupName) {
// Neuestes Backup finden
if (!fs.existsSync(backupsDir)) {
console.error('❌ Kein Backup-Ordner gefunden!');
process.exit(1);
}
const backups = fs.readdirSync(backupsDir)
.filter((f) => fs.statSync(path.join(backupsDir, f)).isDirectory())
.filter(f => fs.statSync(path.join(backupsDir, f)).isDirectory())
.sort()
.reverse();
@@ -129,16 +76,18 @@ async function main() {
}
const backupDir = path.join(backupsDir, backupName);
if (!fs.existsSync(backupDir)) {
console.error(`❌ Backup-Ordner nicht gefunden: ${backupDir}`);
process.exit(1);
}
// Backup-Info lesen
const infoPath = path.join(backupDir, '_backup-info.json');
if (fs.existsSync(infoPath)) {
const info = JSON.parse(fs.readFileSync(infoPath, 'utf-8'));
console.log(`\n📅 Backup vom: ${new Date(info.timestamp).toLocaleString('de-DE')}`);
console.log(`📊 ${info.totalRecords} Datensätze\n`);
console.log(`📊 ${info.totalRecords} Datensätze in ${info.tables.filter((t: any) => t.count > 0).length} Tabellen\n`);
}
console.log(`🔄 Starte Wiederherstellung aus: ${backupDir}\n`);
@@ -147,76 +96,362 @@ async function main() {
await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS = 0');
try {
// Tabellen in Abhängigkeitsreihenfolge (gleich wie im Backup)
const order: Array<{
name: string;
model: any;
compositeKey?: string[];
}> = [
// Level 0
{ name: 'Permission', model: prisma.permission },
{ name: 'Role', model: prisma.role },
{ name: 'SalesPlatform', model: prisma.salesPlatform },
{ name: 'ContractCategory', model: prisma.contractCategory },
{ name: 'CancellationPeriod', model: prisma.cancellationPeriod },
{ name: 'ContractDuration', model: prisma.contractDuration },
{ name: 'AppSetting', model: prisma.appSetting },
{ name: 'EmailProviderConfig', model: prisma.emailProviderConfig },
{ name: 'Provider', model: prisma.provider },
{ name: 'PdfTemplate', model: prisma.pdfTemplate },
{ name: 'AuditRetentionPolicy', model: prisma.auditRetentionPolicy },
// Tabellen in Abhängigkeitsreihenfolge wiederherstellen
const restoreOrder = [
// Level 0: Keine Abhängigkeiten
{
name: 'Permission',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.permission.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'Role',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.role.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'SalesPlatform',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.salesPlatform.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'ContractCategory',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.contractCategory.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'CancellationPeriod',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.cancellationPeriod.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'ContractDuration',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.contractDuration.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'AppSetting',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.appSetting.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'EmailProviderConfig',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.emailProviderConfig.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'EnergyProvider',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.energyProvider.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'TelecomProvider',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.telecomProvider.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
// Level 1
{ name: 'RolePermission', model: prisma.rolePermission, compositeKey: ['roleId', 'permissionId'] },
{ name: 'User', model: prisma.user },
{ name: 'Customer', model: prisma.customer },
{ name: 'Tariff', model: prisma.tariff },
{
name: 'RolePermission',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.rolePermission.upsert({
where: { roleId_permissionId: { roleId: item.roleId, permissionId: item.permissionId } },
update: {},
create: convertDates(item),
});
}
},
},
{
name: 'User',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.user.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'Customer',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.customer.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'Tariff',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.tariff.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
// Level 2: Customer-abhängig
{ name: 'UserRole', model: prisma.userRole, compositeKey: ['userId', 'roleId'] },
{ name: 'Address', model: prisma.address },
{ name: 'BankCard', model: prisma.bankCard },
{ name: 'IdentityDocument', model: prisma.identityDocument },
{ name: 'Meter', model: prisma.meter },
{ name: 'StressfreiEmail', model: prisma.stressfreiEmail },
{ name: 'CustomerRepresentative', model: prisma.customerRepresentative },
{ name: 'CustomerConsent', model: prisma.customerConsent },
{ name: 'DataDeletionRequest', model: prisma.dataDeletionRequest },
// Level 2
{
name: 'UserRole',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.userRole.upsert({
where: { userId_roleId: { userId: item.userId, roleId: item.roleId } },
update: {},
create: convertDates(item),
});
}
},
},
{
name: 'CustomerRepresentative',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.customerRepresentative.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'StressfreiEmail',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.stressfreiEmail.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'Contract',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.contract.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'Meter',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.meter.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
// Level 3: Contracts
{ name: 'Contract', model: prisma.contract },
{ name: 'RepresentativeAuthorization', model: prisma.representativeAuthorization },
// Level 3
{
name: 'CachedEmail',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.cachedEmail.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'ContractTask',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.contractTask.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'MeterReading',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.meterReading.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'ContractNote',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.contractNote.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'ContractDocument',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.contractDocument.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
// Level 4: Vertragstyp-Details
{ name: 'EnergyContractDetails', model: prisma.energyContractDetails },
{ name: 'InternetContractDetails', model: prisma.internetContractDetails },
{ name: 'MobileContractDetails', model: prisma.mobileContractDetails },
{ name: 'TvContractDetails', model: prisma.tvContractDetails },
{ name: 'CarInsuranceDetails', model: prisma.carInsuranceDetails },
{ name: 'ContractMeter', model: prisma.contractMeter },
{ name: 'ContractDocument', model: prisma.contractDocument },
{ name: 'ContractHistoryEntry', model: prisma.contractHistoryEntry },
{ name: 'ContractTask', model: prisma.contractTask },
{ name: 'Invoice', model: prisma.invoice },
{ name: 'MeterReading', model: prisma.meterReading },
// Level 4
{
name: 'ContractTaskSubtask',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.contractTaskSubtask.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
// Level 5: Sub-Tabellen
{ name: 'ContractTaskSubtask', model: prisma.contractTaskSubtask },
{ name: 'PhoneNumber', model: prisma.phoneNumber },
{ name: 'SimCard', model: prisma.simCard },
// Level 6: Logs & Emails
{ name: 'CachedEmail', model: prisma.cachedEmail },
{ name: 'EmailLog', model: prisma.emailLog },
{ name: 'AuditLog', model: prisma.auditLog },
// Vertragsdetails
{
name: 'EnergyContractDetails',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.energyContractDetails.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'TelecomContractDetails',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.telecomContractDetails.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'CarInsuranceDetails',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.carInsuranceDetails.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
];
let totalRestored = 0;
const skipped: string[] = [];
for (const table of order) {
for (const table of restoreOrder) {
const filePath = path.join(backupDir, `${table.name}.json`);
const data = readJsonFile<any>(filePath);
const data = readJsonFile(filePath);
if (data.length === 0) {
console.log(`${table.name}: Keine Daten`);
@@ -224,23 +459,17 @@ async function main() {
}
try {
const count = await restoreTable(table.name, data, table.model, {
compositeKey: table.compositeKey,
});
totalRestored += count;
console.log(`${table.name}: ${count}/${data.length} Einträge wiederhergestellt`);
await table.restore(data);
totalRestored += data.length;
console.log(`${table.name}: ${data.length} Einträge wiederhergestellt`);
} catch (error: any) {
skipped.push(table.name);
console.log(`⚠️ ${table.name}: Fehler - ${error.message?.slice(0, 100)}`);
console.log(`⚠️ ${table.name}: Fehler - ${error.message?.slice(0, 80)}`);
}
}
console.log(`\n✅ Wiederherstellung abgeschlossen!`);
console.log(` 📊 ${totalRestored} Datensätze wiederhergestellt`);
if (skipped.length > 0) {
console.log(` ⚠️ ${skipped.length} Tabellen mit Fehlern: ${skipped.join(', ')}`);
}
console.log('');
console.log(` 📊 ${totalRestored} Datensätze wiederhergestellt\n`);
} finally {
// Foreign Key Checks wieder aktivieren
await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS = 1');
-71
View File
@@ -78,10 +78,6 @@ model User {
isActive Boolean @default(true)
tokenInvalidatedAt DateTime? // Zeitpunkt ab dem alle Tokens ungültig sind (für Zwangslogout bei Rechteänderung)
// Passwort-Reset
passwordResetToken String? @unique
passwordResetExpiresAt DateTime?
// Messaging-Kanäle (für Datenschutz-Link-Versand)
whatsappNumber String?
telegramUsername String?
@@ -167,22 +163,9 @@ model Customer {
portalPasswordEncrypted String? // Verschlüsseltes Passwort (für Anzeige)
portalLastLogin DateTime? // Letzte Anmeldung
// Portal Passwort-Reset
portalPasswordResetToken String? @unique
portalPasswordResetExpiresAt DateTime?
// Portal Session-Invalidation (nach Passwort-Reset / Rechte-Änderung)
portalTokenInvalidatedAt DateTime?
// Geburtstagsmodal: Jahr in dem dem Kunden der Geburtstagsgruß gezeigt wurde (vermeidet mehrfaches Anzeigen)
lastBirthdayGreetingYear Int?
// Anrede-Verhältnis: true = Du (informell), false = Sie (formell, Default)
useInformalAddress Boolean @default(false)
// Automatischer Geburtstagsgruß-Versand
autoBirthdayGreeting Boolean @default(false)
autoBirthdayChannel String? // "email", "whatsapp", "telegram", "signal"
user User?
addresses Address[]
bankCards BankCard[]
@@ -368,10 +351,6 @@ model EmailProviderConfig {
systemEmailAddress String? // z.B. "info@stressfrei-wechseln.de"
systemEmailPasswordEncrypted String? // Passwort (verschlüsselt)
// Label für Kunden-E-Mail-Adressen in der UI (z.B. "Stressfrei-Wechseln")
// Wenn leer, wird automatisch aus der Domain abgeleitet (z.B. "stressfrei-wechseln.de" → "Stressfrei-Wechseln")
customerEmailLabel String?
isActive Boolean @default(true)
isDefault Boolean @default(false) // Standard-Provider
createdAt DateTime @default(now())
@@ -1113,53 +1092,3 @@ model AuditRetentionPolicy {
@@unique([resourceType, sensitivity])
}
// ==================== SECURITY MONITORING ====================
// Sicherheitsrelevante Events für Realtime-Alerting + Forensik.
// Im Gegensatz zum AuditLog (forensisch, hash-gekettet) ist das hier
// optimiert für schnelles Filtern + Alerting (nicht-tamper-evident, dafür
// effizient querybar). Threshold-Detection läuft per Cron.
enum SecurityEventType {
LOGIN_FAILED // falsches Passwort / unbekannter User
LOGIN_SUCCESS // erfolgreicher Login (informativ)
RATE_LIMIT_HIT // express-rate-limit hat zugeschlagen
ACCESS_DENIED // 403 von canAccess* (versuchter IDOR)
SSRF_BLOCKED // ssrfGuard hat geblockte Adresse abgefangen
PASSWORD_RESET_REQUEST // Reset-Mail angefordert
PASSWORD_RESET_CONFIRM // Reset abgeschlossen
LOGOUT // expliziter Logout
TOKEN_REJECTED // ungültiger / abgelaufener / manipulierter JWT
PERMISSION_CHANGED // Admin hat Rolle/Permission geändert
SUSPICIOUS // generischer Catch-All
}
enum SecuritySeverity {
INFO // Login-Success, Logout
LOW // Einzelner failed Login, einzelner 403
MEDIUM // Rate-Limit-Hit, mehrere 403er
HIGH // SSRF-Block, JWT-Manipulation
CRITICAL // Threshold überschritten (>10 failed login/h, >5 403/min)
}
model SecurityEvent {
id Int @id @default(autoincrement())
type SecurityEventType
severity SecuritySeverity
message String @db.Text
ipAddress String?
userId Int? // Mitarbeiter (falls eingeloggt)
customerId Int? // Portal-Kunde (falls eingeloggt)
userEmail String? // beste Schätzung auch bei nicht eingeloggt
endpoint String? // betroffener Endpoint
details Json? // strukturierte Zusatzinfo
alerted Boolean @default(false) // schon per Email versendet?
alertedAt DateTime?
createdAt DateTime @default(now())
@@index([type, createdAt])
@@index([severity, createdAt])
@@index([ipAddress, createdAt])
@@index([alerted, severity])
}
-325
View File
@@ -1,325 +0,0 @@
/**
* Seed-Script: Factory-Defaults aus backend/factory-defaults/ in die DB einspielen.
*
* - Liest alle JSON-Dateien aus den Unterordnern (providers/, contract-meta/, pdf-templates/)
* - Merged mehrere Dateien pro Kategorie automatisch
* - Nutzt Prisma upsert → idempotent, kann mehrfach aufgerufen werden
* - Kopiert PDF-Dateien aus pdf-templates/ nach uploads/pdf-templates/
*
* Aufruf:
* npm run seed:defaults
*/
import fs from 'fs';
import path from 'path';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const ROOT = path.join(process.cwd(), 'factory-defaults');
const UPLOADS_ROOT = path.join(process.cwd(), 'uploads');
const PDF_UPLOAD_DIR = path.join(UPLOADS_ROOT, 'pdf-templates');
interface ProviderDef {
name: string;
portalUrl?: string | null;
usernameFieldName?: string | null;
passwordFieldName?: string | null;
isActive?: boolean;
tariffs?: { name: string; isActive?: boolean }[];
}
interface CancellationPeriodDef {
code: string;
description: string;
isActive?: boolean;
}
interface ContractDurationDef {
code: string;
description: string;
isActive?: boolean;
}
interface ContractCategoryDef {
code: string;
name: string;
icon?: string | null;
color?: string | null;
sortOrder?: number;
isActive?: boolean;
}
interface PdfTemplateDef {
name: string;
description?: string | null;
providerName?: string | null;
originalName: string;
fieldMapping: any;
phoneFieldPrefix?: string | null;
maxPhoneFields?: number | null;
isActive?: boolean;
pdfFilename: string; // Dateiname im pdf-templates/-Ordner
}
/**
* Liest alle *.json Dateien aus einem Ordner und gibt die zusammengeführten Arrays zurück.
*/
function readJsonArrays<T>(dir: string): T[] {
if (!fs.existsSync(dir)) return [];
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.json'));
const result: T[] = [];
for (const f of files) {
const content = fs.readFileSync(path.join(dir, f), 'utf-8');
try {
const data = JSON.parse(content);
if (Array.isArray(data)) {
result.push(...data);
}
} catch (e) {
console.warn(`⚠ Konnte ${f} nicht parsen überspringe.`);
}
}
return result;
}
/**
* Dedupliziert Einträge per unique-Key (letzter Eintrag gewinnt).
*/
function dedupe<T>(items: T[], keyFn: (item: T) => string): T[] {
const map = new Map<string, T>();
for (const item of items) {
map.set(keyFn(item), item);
}
return Array.from(map.values());
}
async function seedProviders() {
const items = dedupe(readJsonArrays<ProviderDef>(path.join(ROOT, 'providers')), (p) => p.name);
if (items.length === 0) {
console.log(' providers/ keine Einträge gefunden');
return;
}
let providerCount = 0;
let tariffCount = 0;
for (const p of items) {
const provider = await prisma.provider.upsert({
where: { name: p.name },
update: {
portalUrl: p.portalUrl ?? null,
usernameFieldName: p.usernameFieldName ?? null,
passwordFieldName: p.passwordFieldName ?? null,
isActive: p.isActive ?? true,
},
create: {
name: p.name,
portalUrl: p.portalUrl ?? null,
usernameFieldName: p.usernameFieldName ?? null,
passwordFieldName: p.passwordFieldName ?? null,
isActive: p.isActive ?? true,
},
});
providerCount++;
if (p.tariffs && p.tariffs.length > 0) {
for (const t of p.tariffs) {
await prisma.tariff.upsert({
where: { providerId_name: { providerId: provider.id, name: t.name } },
update: { isActive: t.isActive ?? true },
create: {
providerId: provider.id,
name: t.name,
isActive: t.isActive ?? true,
},
});
tariffCount++;
}
}
}
console.log(` ✓ Anbieter: ${providerCount}, Tarife: ${tariffCount}`);
}
async function seedCancellationPeriods() {
const items = dedupe(
readJsonArrays<CancellationPeriodDef>(path.join(ROOT, 'contract-meta')).filter((i) => i.code && i.description),
(i) => i.code,
);
// Nur die relevanten Objekte (CancellationPeriod hat code+description, keine 'name')
const relevant = items.filter((i) => 'code' in i && 'description' in i && !('name' in i) && !('icon' in i));
if (relevant.length === 0) {
console.log(' cancellation-periods keine Einträge');
return;
}
for (const c of relevant) {
await prisma.cancellationPeriod.upsert({
where: { code: c.code },
update: { description: c.description, isActive: c.isActive ?? true },
create: { code: c.code, description: c.description, isActive: c.isActive ?? true },
});
}
console.log(` ✓ Kündigungsfristen: ${relevant.length}`);
}
async function seedContractDurations() {
const items = dedupe(
readJsonArrays<ContractDurationDef>(path.join(ROOT, 'contract-meta')).filter((i) => i.code && i.description),
(i) => i.code,
);
const relevant = items.filter((i) => !('name' in i) && !('icon' in i));
if (relevant.length === 0) {
console.log(' contract-durations keine Einträge');
return;
}
for (const d of relevant) {
await prisma.contractDuration.upsert({
where: { code: d.code },
update: { description: d.description, isActive: d.isActive ?? true },
create: { code: d.code, description: d.description, isActive: d.isActive ?? true },
});
}
console.log(` ✓ Laufzeiten: ${relevant.length}`);
}
async function seedContractCategories() {
const items = dedupe(
readJsonArrays<ContractCategoryDef>(path.join(ROOT, 'contract-meta')).filter((i) => i.code && (i as any).name),
(i) => i.code,
);
if (items.length === 0) {
console.log(' contract-categories keine Einträge');
return;
}
for (const c of items) {
await prisma.contractCategory.upsert({
where: { code: c.code },
update: {
name: c.name,
icon: c.icon ?? null,
color: c.color ?? null,
sortOrder: c.sortOrder ?? 0,
isActive: c.isActive ?? true,
},
create: {
code: c.code,
name: c.name,
icon: c.icon ?? null,
color: c.color ?? null,
sortOrder: c.sortOrder ?? 0,
isActive: c.isActive ?? true,
},
});
}
console.log(` ✓ Vertragskategorien: ${items.length}`);
}
async function seedPdfTemplates() {
const items = dedupe(
readJsonArrays<PdfTemplateDef>(path.join(ROOT, 'pdf-templates')),
(t) => t.name,
);
if (items.length === 0) {
console.log(' pdf-templates keine Einträge');
return;
}
// Upload-Verzeichnis sicherstellen
if (!fs.existsSync(PDF_UPLOAD_DIR)) {
fs.mkdirSync(PDF_UPLOAD_DIR, { recursive: true });
}
let count = 0;
let skipped = 0;
for (const t of items) {
const srcPdf = path.join(ROOT, 'pdf-templates', t.pdfFilename);
if (!fs.existsSync(srcPdf)) {
console.warn(` ⚠ PDF fehlt: ${t.pdfFilename} Template "${t.name}" übersprungen`);
skipped++;
continue;
}
// PDF nach uploads/pdf-templates/ kopieren (mit eindeutigem Namen)
const ext = path.extname(t.originalName || t.pdfFilename) || '.pdf';
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const destFilename = `seed-${t.name.replace(/[^a-zA-Z0-9]/g, '-')}-${uniqueSuffix}${ext}`;
const destPdf = path.join(PDF_UPLOAD_DIR, destFilename);
const relativePath = `/uploads/pdf-templates/${destFilename}`;
fs.copyFileSync(srcPdf, destPdf);
const fieldMappingJson = JSON.stringify(t.fieldMapping || {});
// Bei existierendem Template: alten Pfad löschen, wenn Neuimport
const existing = await prisma.pdfTemplate.findUnique({ where: { name: t.name } });
if (existing?.templatePath) {
const oldRel = existing.templatePath.startsWith('/uploads/')
? existing.templatePath.substring('/uploads/'.length)
: existing.templatePath;
const oldAbs = path.join(UPLOADS_ROOT, oldRel);
if (fs.existsSync(oldAbs)) {
try {
fs.unlinkSync(oldAbs);
} catch {
// ignore
}
}
}
await prisma.pdfTemplate.upsert({
where: { name: t.name },
update: {
description: t.description ?? null,
providerName: t.providerName ?? null,
templatePath: relativePath,
originalName: t.originalName,
fieldMapping: fieldMappingJson,
phoneFieldPrefix: t.phoneFieldPrefix ?? null,
maxPhoneFields: t.maxPhoneFields ?? 8,
isActive: t.isActive ?? true,
},
create: {
name: t.name,
description: t.description ?? null,
providerName: t.providerName ?? null,
templatePath: relativePath,
originalName: t.originalName,
fieldMapping: fieldMappingJson,
phoneFieldPrefix: t.phoneFieldPrefix ?? null,
maxPhoneFields: t.maxPhoneFields ?? 8,
isActive: t.isActive ?? true,
},
});
count++;
}
console.log(` ✓ PDF-Vorlagen: ${count}${skipped > 0 ? ` (${skipped} übersprungen)` : ''}`);
}
async function main() {
console.log('\n📦 Factory-Defaults werden eingespielt...\n');
if (!fs.existsSync(ROOT)) {
console.error(`❌ Ordner nicht gefunden: ${ROOT}`);
console.error(' Lege Export-Dateien unter backend/factory-defaults/ ab.');
process.exit(1);
}
await seedProviders();
await seedCancellationPeriods();
await seedContractDurations();
await seedContractCategories();
await seedPdfTemplates();
console.log('\n✅ Factory-Defaults erfolgreich eingespielt.\n');
}
main()
.catch((e) => {
console.error('\n❌ Fehler beim Einspielen:', e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+4 -182
View File
@@ -1,14 +1,12 @@
import { Request, Response } from 'express';
import * as authService from '../services/auth.service.js';
import { AuthRequest, ApiResponse } from '../types/index.js';
import prisma from '../lib/prisma.js';
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
// Mitarbeiter-Login
export async function login(req: Request, res: Response): Promise<void> {
const { email, password } = req.body || {};
const ctx = contextFromRequest(req);
try {
const { email, password } = req.body;
if (!email || !password) {
res.status(400).json({
success: false,
@@ -18,25 +16,8 @@ export async function login(req: Request, res: Response): Promise<void> {
}
const result = await authService.login(email, password);
emitSecurityEvent({
type: 'LOGIN_SUCCESS',
severity: 'INFO',
message: `Mitarbeiter-Login: ${email}`,
ipAddress: ctx.ipAddress,
userId: result.user.id,
userEmail: email,
endpoint: ctx.endpoint,
});
res.json({ success: true, data: result } as ApiResponse);
} catch (error) {
emitSecurityEvent({
type: 'LOGIN_FAILED',
severity: 'LOW',
message: `Login-Fehlversuch (Mitarbeiter): ${email || '<leer>'}`,
ipAddress: ctx.ipAddress,
userEmail: email,
endpoint: ctx.endpoint,
});
res.status(401).json({
success: false,
error: error instanceof Error ? error.message : 'Anmeldung fehlgeschlagen',
@@ -46,9 +27,9 @@ export async function login(req: Request, res: Response): Promise<void> {
// Kundenportal-Login
export async function customerLogin(req: Request, res: Response): Promise<void> {
const { email, password } = req.body || {};
const ctx = contextFromRequest(req);
try {
const { email, password } = req.body;
if (!email || !password) {
res.status(400).json({
success: false,
@@ -58,25 +39,8 @@ export async function customerLogin(req: Request, res: Response): Promise<void>
}
const result = await authService.customerLogin(email, password);
emitSecurityEvent({
type: 'LOGIN_SUCCESS',
severity: 'INFO',
message: `Portal-Login: ${email}`,
ipAddress: ctx.ipAddress,
customerId: result.user.customerId,
userEmail: email,
endpoint: ctx.endpoint,
});
res.json({ success: true, data: result } as ApiResponse);
} catch (error) {
emitSecurityEvent({
type: 'LOGIN_FAILED',
severity: 'LOW',
message: `Login-Fehlversuch (Portal): ${email || '<leer>'}`,
ipAddress: ctx.ipAddress,
userEmail: email,
endpoint: ctx.endpoint,
});
res.status(401).json({
success: false,
error: error instanceof Error ? error.message : 'Anmeldung fehlgeschlagen',
@@ -135,148 +99,6 @@ export async function me(req: AuthRequest, res: Response): Promise<void> {
}
}
/**
* Passwort-Reset anfordern (Email + Token per Mail).
* Immer 200 OK zurückgeben um Email-Existenz nicht preiszugeben (User-Enumeration-Schutz).
*/
export async function requestPasswordReset(req: Request, res: Response): Promise<void> {
try {
const { email, userType } = req.body; // userType: 'admin' | 'portal'
if (!email) {
res.status(400).json({ success: false, error: 'E-Mail erforderlich' } as ApiResponse);
return;
}
await authService.requestPasswordReset(email, userType === 'portal' ? 'portal' : 'admin');
const ctx = contextFromRequest(req);
emitSecurityEvent({
type: 'PASSWORD_RESET_REQUEST',
severity: 'MEDIUM',
message: `Passwort-Reset angefordert (${userType === 'portal' ? 'Portal' : 'Mitarbeiter'}): ${email}`,
ipAddress: ctx.ipAddress,
userEmail: email,
endpoint: ctx.endpoint,
details: { userType: userType === 'portal' ? 'portal' : 'admin' },
});
// IMMER success senden, damit Angreifer nicht herausfinden kann welche Emails existieren
res.json({
success: true,
message: 'Wenn ein Konto mit dieser E-Mail existiert, wurde ein Link zum Zurücksetzen gesendet.',
} as ApiResponse);
} catch (error) {
console.error('Password reset request error:', error);
// Auch bei Fehlern dieselbe Antwort
res.json({
success: true,
message: 'Wenn ein Konto mit dieser E-Mail existiert, wurde ein Link zum Zurücksetzen gesendet.',
} as ApiResponse);
}
}
/**
* Passwort-Reset bestätigen (Token + neues Passwort).
*/
export async function confirmPasswordReset(req: Request, res: Response): Promise<void> {
try {
const { token, password } = req.body;
if (!token || !password) {
res.status(400).json({
success: false,
error: 'Token und neues Passwort erforderlich',
} as ApiResponse);
return;
}
if (password.length < 6) {
res.status(400).json({
success: false,
error: 'Das Passwort muss mindestens 6 Zeichen lang sein',
} as ApiResponse);
return;
}
await authService.confirmPasswordReset(token, password);
const ctx = contextFromRequest(req);
emitSecurityEvent({
type: 'PASSWORD_RESET_CONFIRM',
severity: 'HIGH',
message: 'Passwort-Reset abgeschlossen',
ipAddress: ctx.ipAddress,
endpoint: ctx.endpoint,
});
res.json({
success: true,
message: 'Passwort erfolgreich zurückgesetzt. Du kannst dich jetzt einloggen.',
} as ApiResponse);
} catch (error) {
const ctx = contextFromRequest(req);
emitSecurityEvent({
type: 'TOKEN_REJECTED',
severity: 'MEDIUM',
message: 'Passwort-Reset mit ungültigem/abgelaufenem Token versucht',
ipAddress: ctx.ipAddress,
endpoint: ctx.endpoint,
});
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Passwort-Reset fehlgeschlagen',
} as ApiResponse);
}
}
/**
* Logout: invalidiert den aktuellen JWT serverseitig durch Setzen von
* tokenInvalidatedAt / portalTokenInvalidatedAt auf jetzt. Auth-Middleware
* prüft dieses Feld und lehnt Tokens ab, deren `iat` davor liegt.
*
* Hinweis: Da JWTs stateless sind, gibt es keine echte Token-Revocation
* ohne dieses Pattern. Logout invalidiert ALLE aktiven Sessions des Users
* (auch andere Geräte) akzeptabel für ein Sicherheits-Logout.
*/
export async function logout(req: AuthRequest, res: Response): Promise<void> {
try {
const user = req.user as any;
if (!user) {
res.json({ success: true, message: 'Bereits abgemeldet' } as ApiResponse);
return;
}
if (user.isCustomerPortal && user.customerId) {
await prisma.customer.update({
where: { id: user.customerId },
data: { portalTokenInvalidatedAt: new Date() },
});
} else if (user.userId) {
await prisma.user.update({
where: { id: user.userId },
data: { tokenInvalidatedAt: new Date() },
});
}
const ctx = contextFromRequest(req);
emitSecurityEvent({
type: 'LOGOUT',
severity: 'INFO',
message: `Logout: ${user.email || (user.isCustomerPortal ? 'Portal-User' : 'Mitarbeiter')}`,
ipAddress: ctx.ipAddress,
userId: ctx.userId,
customerId: ctx.customerId,
userEmail: user.email,
endpoint: ctx.endpoint,
});
res.json({ success: true, message: 'Abgemeldet' } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: 'Fehler beim Abmelden',
} as ApiResponse);
}
}
export async function register(req: Request, res: Response): Promise<void> {
try {
const { email, password, firstName, lastName, roleIds } = req.body;
+6 -15
View File
@@ -1,14 +1,5 @@
import { Request, Response } from 'express';
import * as backupService from '../services/backup.service.js';
/**
* Validiert Backup-Namen: nur Zeichen die auch der Backup-Generator erstellen darf
* (ISO-Zeitstempel mit Buchstaben, Zahlen, Bindestrich, optional -N Suffix).
* Blockt Path-Traversal-Versuche wie "../../etc/passwd".
*/
function isValidBackupName(name: string): boolean {
return /^[A-Za-z0-9_-]+$/.test(name) && !name.includes('..');
}
import { logChange } from '../services/audit.service.js';
/**
@@ -54,8 +45,8 @@ export async function restoreBackup(req: Request, res: Response) {
try {
const { name } = req.params;
if (!name || !isValidBackupName(name)) {
return res.status(400).json({ error: 'Ungültiger Backup-Name' });
if (!name) {
return res.status(400).json({ error: 'Backup-Name erforderlich' });
}
const result = await backupService.restoreBackup(name);
@@ -88,8 +79,8 @@ export async function deleteBackup(req: Request, res: Response) {
try {
const { name } = req.params;
if (!name || !isValidBackupName(name)) {
return res.status(400).json({ error: 'Ungültiger Backup-Name' });
if (!name) {
return res.status(400).json({ error: 'Backup-Name erforderlich' });
}
const result = await backupService.deleteBackup(name);
@@ -116,8 +107,8 @@ export async function downloadBackup(req: Request, res: Response) {
try {
const { name } = req.params;
if (!name || !isValidBackupName(name)) {
return res.status(400).json({ error: 'Ungültiger Backup-Name' });
if (!name) {
return res.status(400).json({ error: 'Backup-Name erforderlich' });
}
const result = await backupService.createBackupZip(name);
@@ -1,9 +1,6 @@
import { Response } from 'express';
import { AuthRequest } from '../types/index.js';
import * as birthdayService from '../services/birthday.service.js';
import { sendEmail, SmtpCredentials } from '../services/smtpService.js';
import { getSystemEmailCredentials } from '../services/emailProvider/emailProviderService.js';
import { createAuditLog } from '../services/audit.service.js';
/**
* Admin/Mitarbeiter: Kommende und vergangene Geburtstage
@@ -57,125 +54,3 @@ export async function acknowledgeMyBirthday(req: AuthRequest, res: Response) {
res.status(500).json({ success: false, error: 'Fehler beim Speichern' });
}
}
/**
* Admin: Geburtstagsgruß-Marker für einen Kunden zurücksetzen (Debug / Re-Trigger).
*/
export async function resetBirthdayGreeting(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
await birthdayService.resetBirthdayGreeting(customerId);
await createAuditLog({
userId: req.user?.userId,
userEmail: req.user?.email || 'unknown',
action: 'UPDATE',
resourceType: 'Customer',
resourceId: customerId.toString(),
resourceLabel: `Geburtstagsgruß-Marker zurückgesetzt`,
endpoint: req.path,
httpMethod: req.method,
ipAddress: req.socket.remoteAddress || 'unknown',
dataSubjectId: customerId,
});
res.json({ success: true });
} catch (error) {
console.error('Fehler beim Zurücksetzen:', error);
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Zurücksetzen',
});
}
}
/**
* Admin: Geburtstagsgruß manuell senden (Email oder Link für WhatsApp/Telegram/Signal).
*/
export async function sendBirthdayGreeting(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
const { channel } = req.body; // 'email', 'whatsapp', 'telegram', 'signal'
if (!['email', 'whatsapp', 'telegram', 'signal'].includes(channel)) {
return res.status(400).json({ success: false, error: 'Ungültiger Kanal' });
}
const data = await birthdayService.getBirthdayGreetingData(customerId);
if (!data) {
return res.status(400).json({
success: false,
error: 'Kunde hat kein Geburtsdatum hinterlegt',
});
}
const { subject, plain, html } = birthdayService.buildBirthdayGreetingText(data, data.age);
if (channel === 'email') {
if (!data.email) {
return res.status(400).json({
success: false,
error: 'Kunde hat keine E-Mail-Adresse hinterlegt',
});
}
const systemEmail = await getSystemEmailCredentials();
if (!systemEmail) {
return res.status(400).json({
success: false,
error: 'Keine System-E-Mail konfiguriert. Bitte in den Email-Provider-Einstellungen hinterlegen.',
});
}
const credentials: SmtpCredentials = {
host: systemEmail.smtpServer,
port: systemEmail.smtpPort,
user: systemEmail.emailAddress,
password: systemEmail.password,
encryption: systemEmail.smtpEncryption,
allowSelfSignedCerts: systemEmail.allowSelfSignedCerts,
};
const result = await sendEmail(credentials, systemEmail.emailAddress, {
to: data.email,
subject,
html,
}, {
context: 'birthday-greeting',
customerId,
triggeredBy: req.user?.email,
});
if (!result.success) {
return res.status(400).json({
success: false,
error: `E-Mail-Versand fehlgeschlagen: ${result.error}`,
});
}
}
await createAuditLog({
userId: req.user?.userId,
userEmail: req.user?.email || 'unknown',
action: 'CREATE',
resourceType: 'Customer',
resourceId: customerId.toString(),
resourceLabel: `Geburtstagsgruß gesendet (${channel})`,
endpoint: req.path,
httpMethod: req.method,
ipAddress: req.socket.remoteAddress || 'unknown',
dataSubjectId: customerId,
});
res.json({
success: true,
data: { channel, messageText: plain },
});
} catch (error) {
console.error('Fehler beim Senden des Geburtstagsgrußes:', error);
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Senden',
});
}
}
+58 -240
View File
@@ -11,25 +11,17 @@ import { decrypt } from '../utils/encryption.js';
import { ApiResponse } from '../types/index.js';
import { getCustomerTargets, getContractTargets, getIdentityDocumentTargets, getBankCardTargets, documentTargets } from '../config/documentTargets.config.js';
import { generateEmailPdf } from '../services/pdfService.js';
import { maybeActivateOnDeliveryConfirmation } from '../services/contractStatusScheduler.service.js';
import { DocumentType } from '@prisma/client';
import prisma from '../lib/prisma.js';
import path from 'path';
import fs from 'fs';
import { AuthRequest } from '../types/index.js';
import {
canAccessCustomer,
canAccessContract,
canAccessCachedEmail,
} from '../utils/accessControl.js';
// ==================== E-MAIL LIST ====================
// E-Mails für einen Kunden abrufen
export async function getEmailsForCustomer(req: AuthRequest, res: Response): Promise<void> {
export async function getEmailsForCustomer(req: Request, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const stressfreiEmailId = req.query.accountId ? parseInt(req.query.accountId as string) : undefined;
const folder = req.query.folder as string | undefined; // INBOX oder SENT
const limit = req.query.limit ? parseInt(req.query.limit as string) : 50;
@@ -55,10 +47,9 @@ export async function getEmailsForCustomer(req: AuthRequest, res: Response): Pro
}
// E-Mails für einen Vertrag abrufen
export async function getEmailsForContract(req: AuthRequest, res: Response): Promise<void> {
export async function getEmailsForContract(req: Request, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.contractId);
if (!(await canAccessContract(req, res, contractId))) return;
const folder = req.query.folder as string | undefined; // INBOX oder SENT
const limit = req.query.limit ? parseInt(req.query.limit as string) : 50;
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0;
@@ -84,11 +75,9 @@ export async function getEmailsForContract(req: AuthRequest, res: Response): Pro
// ==================== SINGLE EMAIL ====================
// Einzelne E-Mail abrufen (mit Body)
export async function getEmail(req: AuthRequest, res: Response): Promise<void> {
export async function getEmail(req: Request, res: Response): Promise<void> {
try {
const id = parseInt(req.params.id);
if (!(await canAccessCachedEmail(req, res, id))) return;
const email = await cachedEmailService.getCachedEmailById(id);
if (!email) {
@@ -257,30 +246,12 @@ export async function syncAccount(req: Request, res: Response): Promise<void> {
}
}
// Security: verhindert Header-Injection via CRLF in E-Mail-Feldern.
// nodemailer prüft das zwar auch selbst, aber besser vor dem Versand
// einen sauberen 400er zurückgeben als einen unklaren SMTP-Fehler.
function hasCRLF(value: unknown): boolean {
if (typeof value === 'string') return /[\r\n]/.test(value);
if (Array.isArray(value)) return value.some(hasCRLF);
return false;
}
// E-Mail senden
export async function sendEmailFromAccount(req: Request, res: Response): Promise<void> {
try {
const stressfreiEmailId = parseInt(req.params.id);
const { to, cc, subject, text, html, inReplyTo, references, attachments, contractId } = req.body;
// Header-Injection (CRLF) in Empfänger/Betreff ablehnen
if (hasCRLF(to) || hasCRLF(cc) || hasCRLF(subject) || hasCRLF(inReplyTo) || hasCRLF(references)) {
res.status(400).json({
success: false,
error: 'Ungültige Zeichen in E-Mail-Feldern (Zeilenumbrüche nicht erlaubt)',
} as ApiResponse);
return;
}
// StressfreiEmail laden
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(stressfreiEmailId);
@@ -425,10 +396,9 @@ export async function sendEmailFromAccount(req: Request, res: Response): Promise
// ==================== ATTACHMENTS ====================
// Anhang-Liste einer E-Mail abrufen
export async function getAttachments(req: AuthRequest, res: Response): Promise<void> {
export async function getAttachments(req: Request, res: Response): Promise<void> {
try {
const emailId = parseInt(req.params.emailId);
if (!(await canAccessCachedEmail(req, res, emailId))) return;
// E-Mail aus Cache laden
const email = await cachedEmailService.getCachedEmailById(emailId);
@@ -459,14 +429,11 @@ export async function getAttachments(req: AuthRequest, res: Response): Promise<v
}
// Einzelnen Anhang herunterladen
export async function downloadAttachment(req: AuthRequest, res: Response): Promise<void> {
export async function downloadAttachment(req: Request, res: Response): Promise<void> {
try {
const emailId = parseInt(req.params.emailId);
const filename = decodeURIComponent(req.params.filename);
// Portal-Isolation: nur eigene/vertretene Emails
if (!(await canAccessCachedEmail(req, res, emailId))) return;
// E-Mail aus Cache laden
const email = await cachedEmailService.getCachedEmailById(emailId);
if (!email) {
@@ -533,48 +500,17 @@ export async function downloadAttachment(req: AuthRequest, res: Response): Promi
return;
}
// Security: Content-Type aus IMAP kommt vom Absender und kann `text/html`
// o.ä. sein. Für inline-Preview nur eine Whitelist "harmloser" Typen
// zulassen, sonst zwingend als Download (attachment) ausliefern, um XSS
// via inline-HTML-Anhang zu verhindern. Zusätzlich nosniff setzen.
const INLINE_SAFE_TYPES = new Set([
'application/pdf',
'image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp',
'image/svg+xml' /* wird unten trotzdem als download erzwungen */,
'text/plain',
]);
const rawType = (attachment.contentType || 'application/octet-stream').toLowerCase();
// SVG kann Skripte enthalten → niemals inline
const isSafeInline = INLINE_SAFE_TYPES.has(rawType) && rawType !== 'image/svg+xml';
const requestedDisposition = req.query.view === 'true' ? 'inline' : 'attachment';
const disposition = requestedDisposition === 'inline' && isSafeInline ? 'inline' : 'attachment';
// Filename: Steuerzeichen entfernen (CRLF-Injection in Header)
const safeFilename = (attachment.filename || 'attachment').replace(/[\r\n"\\]/g, '_');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Type', isSafeInline ? rawType : 'application/octet-stream');
res.setHeader('Content-Disposition', `${disposition}; filename="${encodeURIComponent(safeFilename)}"`);
// Datei senden - inline (öffnen) oder attachment (download)
const disposition = req.query.view === 'true' ? 'inline' : 'attachment';
res.setHeader('Content-Type', attachment.contentType);
res.setHeader('Content-Disposition', `${disposition}; filename="${encodeURIComponent(attachment.filename)}"`);
res.setHeader('Content-Length', attachment.size);
res.send(attachment.content);
} catch (error) {
console.error('downloadAttachment error:', error);
const rawMsg = error instanceof Error ? error.message : 'Unbekannter Fehler';
const lower = rawMsg.toLowerCase();
let friendly = rawMsg;
if (lower.includes('socket disconnected') && lower.includes('tls')) {
friendly =
'IMAP-Server hat die TLS-Verbindung abgelehnt. Mögliche Ursache: selbstsigniertes Zertifikat. Bitte in den E-Mail-Provider-Einstellungen "Selbstsignierte Zertifikate erlauben" aktivieren.';
} else if (lower.includes('econnrefused')) {
friendly = 'IMAP-Server ist nicht erreichbar (Verbindung verweigert). Bitte Server/Port prüfen.';
} else if (lower.includes('etimedout')) {
friendly = 'Zeitüberschreitung beim Verbinden zum IMAP-Server. Bitte später erneut versuchen.';
} else if (lower.includes('authentication') || lower.includes('auth')) {
friendly = 'IMAP-Authentifizierung fehlgeschlagen. Bitte Zugangsdaten prüfen.';
}
res.status(500).json({
success: false,
error: `Fehler beim Herunterladen des Anhangs: ${friendly}`,
error: 'Fehler beim Herunterladen des Anhangs',
} as ApiResponse);
}
}
@@ -1643,7 +1579,7 @@ export async function saveEmailAsInvoice(req: Request, res: Response): Promise<v
return;
}
// Vertrag laden
// Vertrag laden und prüfen ob es ein Energievertrag ist
const contract = await prisma.contract.findUnique({
where: { id: email.contractId },
include: { energyDetails: true },
@@ -1657,6 +1593,22 @@ export async function saveEmailAsInvoice(req: Request, res: Response): Promise<v
return;
}
if (!['ELECTRICITY', 'GAS'].includes(contract.type)) {
res.status(400).json({
success: false,
error: 'Nur für Strom- und Gas-Verträge verfügbar',
} as ApiResponse);
return;
}
if (!contract.energyDetails) {
res.status(400).json({
success: false,
error: 'Keine Energie-Details für diesen Vertrag vorhanden',
} as ApiResponse);
return;
}
// Empfänger-Adressen parsen (JSON Array)
let toAddresses: string[] = [];
let ccAddresses: string[] = [];
@@ -1693,20 +1645,13 @@ export async function saveEmailAsInvoice(req: Request, res: Response): Promise<v
// PDF speichern
fs.writeFileSync(filePath, pdfBuffer);
// Invoice in DB erstellen (für alle Vertragstypen)
const invoice = contract.energyDetails
? await invoiceService.addInvoice(contract.energyDetails.id, {
invoiceDate: new Date(invoiceDate),
invoiceType,
documentPath: relativePath,
notes: notes || undefined,
})
: await invoiceService.addInvoiceByContract(contract.id, {
invoiceDate: new Date(invoiceDate),
invoiceType,
documentPath: relativePath,
notes: notes || undefined,
});
// Invoice in DB erstellen
const invoice = await invoiceService.addInvoice(contract.energyDetails.id, {
invoiceDate: new Date(invoiceDate),
invoiceType,
documentPath: relativePath,
notes: notes || undefined,
});
res.json({
success: true,
@@ -1770,7 +1715,7 @@ export async function saveAttachmentAsInvoice(req: Request, res: Response): Prom
return;
}
// Vertrag laden
// Vertrag laden und prüfen ob es ein Energievertrag ist
const contract = await prisma.contract.findUnique({
where: { id: email.contractId },
include: { energyDetails: true },
@@ -1784,6 +1729,22 @@ export async function saveAttachmentAsInvoice(req: Request, res: Response): Prom
return;
}
if (!['ELECTRICITY', 'GAS'].includes(contract.type)) {
res.status(400).json({
success: false,
error: 'Nur für Strom- und Gas-Verträge verfügbar',
} as ApiResponse);
return;
}
if (!contract.energyDetails) {
res.status(400).json({
success: false,
error: 'Keine Energie-Details für diesen Vertrag vorhanden',
} as ApiResponse);
return;
}
// Für gesendete E-Mails: Prüfen ob UID vorhanden
if (email.folder === 'SENT' && email.uid === 0) {
res.status(400).json({
@@ -1855,20 +1816,13 @@ export async function saveAttachmentAsInvoice(req: Request, res: Response): Prom
// Datei speichern
fs.writeFileSync(filePath, attachment.content);
// Invoice in DB erstellen (für alle Vertragstypen)
const invoice = contract.energyDetails
? await invoiceService.addInvoice(contract.energyDetails.id, {
invoiceDate: new Date(invoiceDate),
invoiceType,
documentPath: relativePath,
notes: notes || undefined,
})
: await invoiceService.addInvoiceByContract(contract.id, {
invoiceDate: new Date(invoiceDate),
invoiceType,
documentPath: relativePath,
notes: notes || undefined,
});
// Invoice in DB erstellen
const invoice = await invoiceService.addInvoice(contract.energyDetails.id, {
invoiceDate: new Date(invoiceDate),
invoiceType,
documentPath: relativePath,
notes: notes || undefined,
});
res.json({
success: true,
@@ -1883,139 +1837,3 @@ export async function saveAttachmentAsInvoice(req: Request, res: Response): Prom
} as ApiResponse);
}
}
/**
* Anhang einer E-Mail als Vertragsdokument (ContractDocument) speichern.
* Nutzt die flexible ContractDocument-Tabelle mit documentType (Auftragsformular, Lieferbestätigung, etc.)
*/
export async function saveAttachmentAsContractDocument(req: Request, res: Response): Promise<void> {
try {
const emailId = parseInt(req.params.id);
const filename = decodeURIComponent(req.params.filename);
const { documentType, notes } = req.body;
if (!documentType || typeof documentType !== 'string') {
res.status(400).json({
success: false,
error: 'documentType ist erforderlich',
} as ApiResponse);
return;
}
const email = await cachedEmailService.getCachedEmailById(emailId);
if (!email) {
res.status(404).json({ success: false, error: 'E-Mail nicht gefunden' } as ApiResponse);
return;
}
if (!email.contractId) {
res.status(400).json({
success: false,
error: 'E-Mail ist keinem Vertrag zugeordnet',
} as ApiResponse);
return;
}
const contract = await prisma.contract.findUnique({
where: { id: email.contractId },
select: { id: true, contractNumber: true, customerId: true },
});
if (!contract) {
res.status(404).json({ success: false, error: 'Vertrag nicht gefunden' } as ApiResponse);
return;
}
// Ownership-Check (Portal-Kunde darf nur auf eigenen/vertretenen Vertrag)
if (!(await canAccessContract(req as AuthRequest, res, contract.id))) return;
// Für gesendete E-Mails: Prüfen ob UID vorhanden
if (email.folder === 'SENT' && email.uid === 0) {
res.status(400).json({
success: false,
error: 'Anhang nicht verfügbar - E-Mail wurde vor der IMAP-Speicherung gesendet',
} as ApiResponse);
return;
}
// StressfreiEmail für IMAP-Zugangsdaten
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(email.stressfreiEmailId);
if (!stressfreiEmail || !stressfreiEmail.emailPasswordEncrypted) {
res.status(400).json({
success: false,
error: 'Keine Mailbox-Zugangsdaten verfügbar',
} as ApiResponse);
return;
}
const settings = await getImapSmtpSettings();
if (!settings) {
res.status(400).json({
success: false,
error: 'Keine E-Mail-Provider-Einstellungen gefunden',
} as ApiResponse);
return;
}
const password = decrypt(stressfreiEmail.emailPasswordEncrypted);
const credentials: ImapCredentials = {
host: settings.imapServer,
port: settings.imapPort,
user: stressfreiEmail.email,
password,
encryption: settings.imapEncryption,
allowSelfSignedCerts: settings.allowSelfSignedCerts,
};
const imapFolder = email.folder === 'SENT' ? 'Sent' : 'INBOX';
const attachment = await fetchAttachment(credentials, email.uid, filename, imapFolder);
if (!attachment) {
res.status(404).json({
success: false,
error: 'Anhang nicht gefunden oder nicht mehr verfügbar',
} as ApiResponse);
return;
}
// Uploads-Verzeichnis
const uploadsDir = path.join(process.cwd(), 'uploads', 'contract-documents');
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
const ext = path.extname(filename) || '.pdf';
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const safeType = documentType.toLowerCase().replace(/[^a-z0-9]/g, '-');
const newFilename = `${safeType}-${uniqueSuffix}${ext}`;
const filePath = path.join(uploadsDir, newFilename);
const relativePath = `/uploads/contract-documents/${newFilename}`;
fs.writeFileSync(filePath, attachment.content);
const doc = await prisma.contractDocument.create({
data: {
contractId: contract.id,
documentType,
documentPath: relativePath,
originalName: filename,
notes: notes || null,
uploadedBy: (req as any).user?.email || 'email-import',
},
});
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer
const deliveryDate = typeof req.body?.deliveryDate === 'string' ? req.body.deliveryDate : null;
await maybeActivateOnDeliveryConfirmation(contract.id, documentType, req, deliveryDate);
res.json({ success: true, data: doc } as ApiResponse);
} catch (error) {
console.error('saveAttachmentAsContractDocument error:', error);
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
res.status(500).json({
success: false,
error: `Fehler beim Speichern: ${errorMessage}`,
} as ApiResponse);
}
}
+9 -48
View File
@@ -6,8 +6,6 @@ import * as contractHistoryService from '../services/contractHistory.service.js'
import * as authorizationService from '../services/authorization.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { logChange } from '../services/audit.service.js';
import { canAccessContract } from '../utils/accessControl.js';
import { maybeActivateOnDeliveryConfirmation } from '../services/contractStatusScheduler.service.js';
export async function getContracts(req: AuthRequest, res: Response): Promise<void> {
try {
@@ -256,12 +254,9 @@ export async function createFollowUp(req: AuthRequest, res: Response): Promise<v
}
}
export async function getContractPassword(req: AuthRequest, res: Response): Promise<void> {
export async function getContractPassword(req: Request, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
const password = await contractService.getContractPassword(contractId);
const password = await contractService.getContractPassword(parseInt(req.params.id));
if (password === null) {
res.status(404).json({
success: false,
@@ -278,21 +273,9 @@ export async function getContractPassword(req: AuthRequest, res: Response): Prom
}
}
export async function getSimCardCredentials(req: AuthRequest, res: Response): Promise<void> {
export async function getSimCardCredentials(req: Request, res: Response): Promise<void> {
try {
const simCardId = parseInt(req.params.simCardId);
// SimCard → MobileDetails → Contract
const sim = await prisma.simCard.findUnique({
where: { id: simCardId },
select: { mobileDetails: { select: { contractId: true } } },
});
if (!sim?.mobileDetails) {
res.status(404).json({ success: false, error: 'SIM-Karte nicht gefunden' } as ApiResponse);
return;
}
if (!(await canAccessContract(req, res, sim.mobileDetails.contractId))) return;
const credentials = await contractService.getSimCardCredentials(simCardId);
const credentials = await contractService.getSimCardCredentials(parseInt(req.params.simCardId));
res.json({ success: true, data: credentials } as ApiResponse);
} catch (error) {
res.status(500).json({
@@ -302,12 +285,9 @@ export async function getSimCardCredentials(req: AuthRequest, res: Response): Pr
}
}
export async function getInternetCredentials(req: AuthRequest, res: Response): Promise<void> {
export async function getInternetCredentials(req: Request, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
const credentials = await contractService.getInternetCredentials(contractId);
const credentials = await contractService.getInternetCredentials(parseInt(req.params.id));
res.json({ success: true, data: credentials } as ApiResponse);
} catch (error) {
res.status(500).json({
@@ -317,21 +297,9 @@ export async function getInternetCredentials(req: AuthRequest, res: Response): P
}
}
export async function getSipCredentials(req: AuthRequest, res: Response): Promise<void> {
export async function getSipCredentials(req: Request, res: Response): Promise<void> {
try {
const phoneNumberId = parseInt(req.params.phoneNumberId);
// PhoneNumber → InternetDetails → Contract
const phone = await prisma.phoneNumber.findUnique({
where: { id: phoneNumberId },
select: { internetDetails: { select: { contractId: true } } },
});
if (!phone?.internetDetails) {
res.status(404).json({ success: false, error: 'Rufnummer nicht gefunden' } as ApiResponse);
return;
}
if (!(await canAccessContract(req, res, phone.internetDetails.contractId))) return;
const credentials = await contractService.getSipCredentials(phoneNumberId);
const credentials = await contractService.getSipCredentials(parseInt(req.params.phoneNumberId));
res.json({ success: true, data: credentials } as ApiResponse);
} catch (error) {
res.status(500).json({
@@ -447,8 +415,6 @@ export async function removeContractMeter(req: AuthRequest, res: Response): Prom
export async function getContractDocuments(req: AuthRequest, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
const documents = await prisma.contractDocument.findMany({
where: { contractId },
orderBy: { createdAt: 'desc' },
@@ -462,8 +428,7 @@ export async function getContractDocuments(req: AuthRequest, res: Response): Pro
export async function uploadContractDocument(req: AuthRequest, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
const { documentType, notes, deliveryDate } = req.body;
const { documentType, notes } = req.body;
if (!req.file) {
res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' } as ApiResponse);
@@ -496,9 +461,6 @@ export async function uploadContractDocument(req: AuthRequest, res: Response): P
customerId: contract?.customerId,
});
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer
await maybeActivateOnDeliveryConfirmation(contractId, documentType, req, deliveryDate);
res.status(201).json({ success: true, data: doc } as ApiResponse);
} catch (error) {
res.status(400).json({
@@ -512,7 +474,6 @@ export async function deleteContractDocument(req: AuthRequest, res: Response): P
try {
const documentId = parseInt(req.params.documentId);
const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
const doc = await prisma.contractDocument.findUnique({ where: { id: documentId } });
if (!doc || doc.contractId !== contractId) {
+34 -87
View File
@@ -4,23 +4,9 @@ import * as customerService from '../services/customer.service.js';
import * as authService from '../services/auth.service.js';
import { logChange } from '../services/audit.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import {
sanitizeCustomer,
sanitizeCustomers,
sanitizeCustomerStrict,
pickCustomerCreate,
pickCustomerUpdate,
} from '../utils/sanitize.js';
import {
canAccessMeter,
canAccessAddress,
canAccessBankCard,
canAccessIdentityDocument,
canAccessCustomer,
} from '../utils/accessControl.js';
// Customer CRUD
export async function getCustomers(req: AuthRequest, res: Response): Promise<void> {
export async function getCustomers(req: Request, res: Response): Promise<void> {
try {
const { search, type, page, limit } = req.query;
const result = await customerService.getAllCustomers({
@@ -29,25 +15,7 @@ export async function getCustomers(req: AuthRequest, res: Response): Promise<voi
page: page ? parseInt(page as string) : undefined,
limit: limit ? parseInt(limit as string) : undefined,
});
let customers = result.customers as any[];
// Portal-Kunden: Liste auf eigenen + vertretene Kunden einschränken.
// Ohne diesen Filter würde der List-Endpoint die komplette Kundendatenbank
// an einen einzelnen Portal-Account preisgeben.
if (req.user?.isCustomerPortal) {
const allowedIds = new Set<number>();
if (req.user.customerId) allowedIds.add(req.user.customerId);
const represented = (req.user as any).representedCustomerIds || [];
for (const id of represented) allowedIds.add(id);
customers = customers.filter((c) => allowedIds.has(c.id));
}
// Portal-Kunden oder andere Rollen sehen kein portalPasswordEncrypted
const canSeePasswords = req.user?.permissions?.includes('customers:update') ?? false;
const sanitized = canSeePasswords
? sanitizeCustomers(customers)
: customers.map((c) => sanitizeCustomerStrict(c)).filter(Boolean);
res.json({ success: true, data: sanitized, pagination: result.pagination } as ApiResponse);
res.json({ success: true, data: result.customers, pagination: result.pagination } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
@@ -56,21 +24,14 @@ export async function getCustomers(req: AuthRequest, res: Response): Promise<voi
}
}
export async function getCustomer(req: AuthRequest, res: Response): Promise<void> {
export async function getCustomer(req: Request, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.id);
if (!(await canAccessCustomer(req, res, customerId))) return;
const customer = await customerService.getCustomerById(customerId);
const customer = await customerService.getCustomerById(parseInt(req.params.id));
if (!customer) {
res.status(404).json({ success: false, error: 'Kunde nicht gefunden' } as ApiResponse);
return;
}
// Portal-Kunden/Read-only sehen kein portalPasswordEncrypted
const canSeePasswords = req.user?.permissions?.includes('customers:update') ?? false;
const sanitized = canSeePasswords
? sanitizeCustomer(customer as any)
: sanitizeCustomerStrict(customer as any);
res.json({ success: true, data: sanitized } as ApiResponse);
res.json({ success: true, data: customer } as ApiResponse);
} catch (error) {
res.status(500).json({ success: false, error: 'Fehler beim Laden des Kunden' } as ApiResponse);
}
@@ -78,8 +39,7 @@ export async function getCustomer(req: AuthRequest, res: Response): Promise<void
export async function createCustomer(req: Request, res: Response): Promise<void> {
try {
// Whitelist: nur erlaubte Felder aus req.body übernehmen
const data: any = pickCustomerCreate(req.body);
const data = { ...req.body };
// Convert birthDate string to Date if present
if (data.birthDate) {
data.birthDate = new Date(data.birthDate);
@@ -103,8 +63,7 @@ export async function createCustomer(req: Request, res: Response): Promise<void>
export async function updateCustomer(req: Request, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.id);
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
const data: any = pickCustomerUpdate(req.body);
const data = { ...req.body };
// Vorherigen Stand laden für Audit
const before = await prisma.customer.findUnique({ where: { id: customerId } });
@@ -129,9 +88,6 @@ export async function updateCustomer(req: Request, res: Response): Promise<void>
salutation: 'Anrede', firstName: 'Vorname', lastName: 'Nachname', email: 'E-Mail',
phone: 'Telefon', mobile: 'Mobil', birthDate: 'Geburtsdatum', birthPlace: 'Geburtsort',
companyName: 'Firma', type: 'Typ', taxNumber: 'Steuernummer', notes: 'Notizen',
useInformalAddress: 'Anrede per',
autoBirthdayGreeting: 'Autom. Geburtstagsgruß',
autoBirthdayChannel: 'Kanal für Geburtstagsgruß',
};
for (const [key, value] of Object.entries(data)) {
// Technische/interne Felder überspringen
@@ -201,21 +157,18 @@ export async function deleteCustomer(req: Request, res: Response): Promise<void>
}
// Addresses
export async function getAddresses(req: AuthRequest, res: Response): Promise<void> {
export async function getAddresses(req: Request, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const addresses = await customerService.getCustomerAddresses(customerId);
const addresses = await customerService.getCustomerAddresses(parseInt(req.params.customerId));
res.json({ success: true, data: addresses } as ApiResponse);
} catch (error) {
res.status(500).json({ success: false, error: 'Fehler beim Laden der Adressen' } as ApiResponse);
}
}
export async function createAddress(req: AuthRequest, res: Response): Promise<void> {
export async function createAddress(req: Request, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const address = await customerService.createAddress(customerId, req.body);
await logChange({
req, action: 'CREATE', resourceType: 'Address',
@@ -317,22 +270,22 @@ export async function deleteAddress(req: Request, res: Response): Promise<void>
}
// Bank Cards
export async function getBankCards(req: AuthRequest, res: Response): Promise<void> {
export async function getBankCards(req: Request, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const showInactive = req.query.showInactive === 'true';
const cards = await customerService.getCustomerBankCards(customerId, showInactive);
const cards = await customerService.getCustomerBankCards(
parseInt(req.params.customerId),
showInactive
);
res.json({ success: true, data: cards } as ApiResponse);
} catch (error) {
res.status(500).json({ success: false, error: 'Fehler beim Laden der Bankkarten' } as ApiResponse);
}
}
export async function createBankCard(req: AuthRequest, res: Response): Promise<void> {
export async function createBankCard(req: Request, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const card = await customerService.createBankCard(customerId, req.body);
await logChange({
req, action: 'CREATE', resourceType: 'BankCard',
@@ -429,22 +382,22 @@ export async function deleteBankCard(req: Request, res: Response): Promise<void>
}
// Identity Documents
export async function getDocuments(req: AuthRequest, res: Response): Promise<void> {
export async function getDocuments(req: Request, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const showInactive = req.query.showInactive === 'true';
const docs = await customerService.getCustomerDocuments(customerId, showInactive);
const docs = await customerService.getCustomerDocuments(
parseInt(req.params.customerId),
showInactive
);
res.json({ success: true, data: docs } as ApiResponse);
} catch (error) {
res.status(500).json({ success: false, error: 'Fehler beim Laden der Ausweise' } as ApiResponse);
}
}
export async function createDocument(req: AuthRequest, res: Response): Promise<void> {
export async function createDocument(req: Request, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const doc = await customerService.createDocument(customerId, req.body);
await logChange({
req, action: 'CREATE', resourceType: 'IdentityDocument',
@@ -547,22 +500,22 @@ export async function deleteDocument(req: Request, res: Response): Promise<void>
}
// Meters
export async function getMeters(req: AuthRequest, res: Response): Promise<void> {
export async function getMeters(req: Request, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const showInactive = req.query.showInactive === 'true';
const meters = await customerService.getCustomerMeters(customerId, showInactive);
const meters = await customerService.getCustomerMeters(
parseInt(req.params.customerId),
showInactive
);
res.json({ success: true, data: meters } as ApiResponse);
} catch (error) {
res.status(500).json({ success: false, error: 'Fehler beim Laden der Zähler' } as ApiResponse);
}
}
export async function createMeter(req: AuthRequest, res: Response): Promise<void> {
export async function createMeter(req: Request, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const meter = await customerService.createMeter(customerId, req.body);
await logChange({
req, action: 'CREATE', resourceType: 'Meter',
@@ -655,11 +608,9 @@ export async function deleteMeter(req: Request, res: Response): Promise<void> {
}
// Meter Readings
export async function getMeterReadings(req: AuthRequest, res: Response): Promise<void> {
export async function getMeterReadings(req: Request, res: Response): Promise<void> {
try {
const meterId = parseInt(req.params.meterId);
if (!(await canAccessMeter(req, res, meterId))) return;
const readings = await customerService.getMeterReadings(meterId);
const readings = await customerService.getMeterReadings(parseInt(req.params.meterId));
res.json({ success: true, data: readings } as ApiResponse);
} catch (error) {
res.status(500).json({ success: false, error: 'Fehler beim Laden der Zählerstände' } as ApiResponse);
@@ -866,11 +817,9 @@ export async function markReadingTransferred(req: AuthRequest, res: Response): P
// ==================== PORTAL SETTINGS ====================
export async function getPortalSettings(req: AuthRequest, res: Response): Promise<void> {
export async function getPortalSettings(req: Request, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const settings = await customerService.getPortalSettings(customerId);
const settings = await customerService.getPortalSettings(parseInt(req.params.customerId));
if (!settings) {
res.status(404).json({ success: false, error: 'Kunde nicht gefunden' } as ApiResponse);
return;
@@ -998,12 +947,10 @@ export async function getPortalPassword(req: Request, res: Response): Promise<vo
// ==================== REPRESENTATIVE MANAGEMENT ====================
export async function getRepresentatives(req: AuthRequest, res: Response): Promise<void> {
export async function getRepresentatives(req: Request, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
// Wer kann diesen Kunden vertreten (representedBy)?
const representedBy = await customerService.getRepresentedByList(customerId);
const representedBy = await customerService.getRepresentedByList(parseInt(req.params.customerId));
res.json({ success: true, data: representedBy } as ApiResponse);
} catch (error) {
res.status(500).json({
@@ -4,14 +4,6 @@ import { Request, Response } from 'express';
import * as emailProviderService from '../services/emailProvider/emailProviderService.js';
import { logChange } from '../services/audit.service.js';
import { ApiResponse } from '../types/index.js';
import { testImapConnection, ImapCredentials } from '../services/imapService.js';
import { testSmtpConnection, SmtpCredentials } from '../services/smtpService.js';
import { decrypt } from '../utils/encryption.js';
import { assertAllowedHost, safeResolveHost } from '../utils/ssrfGuard.js';
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
// ==================== CONFIG CRUD ====================
@@ -120,33 +112,6 @@ export async function testConnection(req: Request, res: Response): Promise<void>
domain: req.body.domain,
} : undefined;
// SSRF-Guard inkl. DNS-Rebinding: testData.apiUrl-Hostname zu IP auflösen
// und prüfen. Wenn DNS auf eine geblockte IP zeigt, abbrechen ohne dass
// ein zweiter Lookup zur Connection-Zeit eine andere IP liefern könnte.
if (testData?.apiUrl) {
try {
const url = new URL(testData.apiUrl);
await safeResolveHost(url.hostname, 'apiUrl-Host');
} catch (err) {
if (err instanceof Error && (err.message.includes('geblockte') || err.message.includes('DNS'))) {
const ctx = contextFromRequest(req);
emitSecurityEvent({
type: 'SSRF_BLOCKED',
severity: 'HIGH',
message: err.message,
ipAddress: ctx.ipAddress,
userId: ctx.userId,
userEmail: ctx.userEmail,
endpoint: ctx.endpoint,
details: { apiUrl: testData.apiUrl },
});
res.status(400).json({ success: false, error: err.message } as ApiResponse);
return;
}
// URL-Parse-Fehler ignorieren Backend reagiert sowieso mit Fehler
}
}
const result = await emailProviderService.testProviderConnection({ id, testData });
res.json({ success: result.success, data: result } as ApiResponse);
} catch (error) {
@@ -157,188 +122,6 @@ export async function testConnection(req: Request, res: Response): Promise<void>
}
}
/**
* Testet IMAP + SMTP-Zugang für die System-E-Mail eines Providers.
* - Option A: Provider-ID + optional überschreibendes Passwort aus Body (Modal)
* - Option B: Testdaten komplett aus Body (beim Anlegen, noch nicht gespeichert)
*/
export async function testMailAccess(req: Request, res: Response): Promise<void> {
try {
const id = req.body?.id ? parseInt(req.body.id) : undefined;
const bodyEmail = typeof req.body?.systemEmailAddress === 'string' ? req.body.systemEmailAddress : undefined;
const bodyPassword = typeof req.body?.systemEmailPassword === 'string' ? req.body.systemEmailPassword : undefined;
let emailAddress: string | undefined;
let password: string | undefined;
let smtpServer: string;
let smtpPort: number;
let imapServer: string;
let imapPort: number;
let smtpEncryption: 'SSL' | 'STARTTLS' | 'NONE';
let imapEncryption: 'SSL' | 'STARTTLS' | 'NONE';
let allowSelfSignedCerts: boolean;
if (id) {
// Gespeicherten Provider laden
const config = await prisma.emailProviderConfig.findUnique({ where: { id } });
if (!config) {
res.status(404).json({ success: false, error: 'Provider nicht gefunden' } as ApiResponse);
return;
}
emailAddress = bodyEmail || config.systemEmailAddress || undefined;
if (bodyPassword) {
password = bodyPassword;
} else if (config.systemEmailPasswordEncrypted) {
try {
password = decrypt(config.systemEmailPasswordEncrypted);
} catch {
password = undefined;
}
}
// IMAP/SMTP-Settings vom Provider ableiten
const settings = await emailProviderService.getImapSmtpSettings();
if (!settings) {
res.status(400).json({ success: false, error: 'Keine IMAP/SMTP-Einstellungen verfügbar' } as ApiResponse);
return;
}
smtpServer = settings.smtpServer;
smtpPort = settings.smtpPort;
imapServer = settings.imapServer;
imapPort = settings.imapPort;
smtpEncryption = settings.smtpEncryption;
imapEncryption = settings.imapEncryption;
allowSelfSignedCerts = settings.allowSelfSignedCerts;
} else if (req.body?.apiUrl) {
// Formulardaten ohne gespeicherten Provider
emailAddress = bodyEmail;
password = bodyPassword;
try {
const url = new URL(req.body.apiUrl);
smtpServer = url.hostname;
imapServer = url.hostname;
} catch {
smtpServer = `mail.${req.body.domain || ''}`;
imapServer = smtpServer;
}
imapEncryption = (req.body.imapEncryption || 'SSL') as 'SSL' | 'STARTTLS' | 'NONE';
smtpEncryption = (req.body.smtpEncryption || 'SSL') as 'SSL' | 'STARTTLS' | 'NONE';
allowSelfSignedCerts = !!req.body.allowSelfSignedCerts;
imapPort = imapEncryption === 'SSL' ? 993 : 143;
smtpPort = smtpEncryption === 'SSL' ? 465 : smtpEncryption === 'STARTTLS' ? 587 : 25;
} else {
res.status(400).json({ success: false, error: 'Provider-ID oder Testdaten erforderlich' } as ApiResponse);
return;
}
if (!emailAddress || !password) {
res.status(400).json({
success: false,
error: 'System-E-Mail-Adresse und Passwort sind erforderlich',
} as ApiResponse);
return;
}
// SSRF-Guard inkl. DNS-Rebinding: Hostnames pre-resolven und gegen
// geblockte IPs prüfen. Connection läuft danach gegen die IP, der
// ursprüngliche Hostname wird als TLS-servername gesetzt damit kann
// ein zweiter DNS-Lookup keine andere IP unterschieben.
let smtpResolved: { ip: string; servername: string };
let imapResolved: { ip: string; servername: string };
try {
[smtpResolved, imapResolved] = await Promise.all([
safeResolveHost(smtpServer, 'SMTP-Server'),
safeResolveHost(imapServer, 'IMAP-Server'),
]);
} catch (err) {
const ctx = contextFromRequest(req);
emitSecurityEvent({
type: 'SSRF_BLOCKED',
severity: 'HIGH',
message: err instanceof Error ? err.message : 'Ungültige Server-Adresse',
ipAddress: ctx.ipAddress,
userId: ctx.userId,
userEmail: ctx.userEmail,
endpoint: ctx.endpoint,
details: { smtpServer, imapServer },
});
res.status(400).json({
success: false,
error: err instanceof Error ? err.message : 'Ungültige Server-Adresse',
} as ApiResponse);
return;
}
// IMAP testen
const imapCredentials: ImapCredentials = {
host: imapResolved.ip,
port: imapPort,
user: emailAddress,
password,
encryption: imapEncryption,
allowSelfSignedCerts,
servername: imapResolved.servername,
};
// SMTP testen
const smtpCredentials: SmtpCredentials = {
host: smtpResolved.ip,
port: smtpPort,
user: emailAddress,
password,
encryption: smtpEncryption,
allowSelfSignedCerts,
servername: smtpResolved.servername,
};
let imapResult: { success: boolean; error?: string } = { success: false };
let smtpResult: { success: boolean; error?: string } = { success: false };
try {
await testImapConnection(imapCredentials);
imapResult = { success: true };
} catch (e) {
imapResult = { success: false, error: e instanceof Error ? e.message : 'Unbekannter Fehler' };
}
try {
await testSmtpConnection(smtpCredentials);
smtpResult = { success: true };
} catch (e) {
smtpResult = { success: false, error: e instanceof Error ? e.message : 'Unbekannter Fehler' };
}
res.json({
success: imapResult.success && smtpResult.success,
data: {
imap: {
...imapResult,
server: imapServer,
port: imapPort,
encryption: imapEncryption,
},
smtp: {
...smtpResult,
server: smtpServer,
port: smtpPort,
encryption: smtpEncryption,
},
user: emailAddress,
},
} as ApiResponse);
} catch (error) {
console.error('testMailAccess error:', error);
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Test',
} as ApiResponse);
}
}
export async function checkEmailExists(req: Request, res: Response): Promise<void> {
try {
const { localPart } = req.params;
@@ -398,20 +181,3 @@ export async function getProviderDomain(req: Request, res: Response): Promise<vo
} as ApiResponse);
}
}
/**
* Öffentliche Provider-Einstellungen für die Frontend-UI:
* Domain + Label für Kunden-E-Mail-Adressen.
* Auch für Nicht-Admin-Mitarbeiter verfügbar, da nur UI-Labels.
*/
export async function getPublicSettings(req: Request, res: Response): Promise<void> {
try {
const settings = await emailProviderService.getProviderPublicSettings();
res.json({ success: true, data: settings } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: 'Fehler beim Laden der Einstellungen',
} as ApiResponse);
}
}
@@ -1,64 +0,0 @@
import { Response } from 'express';
import { AuthRequest } from '../types/index.js';
import * as factoryDefaultsService from '../services/factoryDefaults.service.js';
import { createAuditLog } from '../services/audit.service.js';
/**
* Factory-Defaults als ZIP exportieren (Download).
*/
export async function exportFactoryDefaults(req: AuthRequest, res: Response) {
try {
const buffer = await factoryDefaultsService.exportFactoryDefaults();
const dateStr = new Date().toISOString().split('T')[0];
const filename = `factory-defaults-${dateStr}.zip`;
await createAuditLog({
userId: req.user?.userId,
userEmail: req.user?.email || 'unknown',
action: 'EXPORT',
resourceType: 'FactoryDefaults',
resourceId: dateStr,
resourceLabel: 'Factory-Defaults exportiert',
endpoint: req.path,
httpMethod: req.method,
ipAddress: req.socket.remoteAddress || 'unknown',
});
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader('Content-Length', buffer.length);
res.send(buffer);
} catch (error) {
console.error('Fehler beim Factory-Defaults-Export:', error);
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Export',
});
}
}
/**
* Kurze Übersicht was exportiert würde (für Frontend, ohne Download).
*/
export async function previewFactoryDefaults(req: AuthRequest, res: Response) {
try {
const data = await factoryDefaultsService.collectFactoryDefaults();
res.json({
success: true,
data: {
counts: {
providers: data.providers.length,
tariffs: data.providers.reduce((sum, p) => sum + p.tariffs.length, 0),
cancellationPeriods: data.cancellationPeriods.length,
contractDurations: data.contractDurations.length,
contractCategories: data.contractCategories.length,
pdfTemplates: data.pdfTemplates.length,
},
},
});
} catch (error) {
console.error('Fehler beim Preview:', error);
res.status(500).json({ success: false, error: 'Fehler beim Laden' });
}
}
@@ -1,84 +0,0 @@
import { Response } from 'express';
import path from 'path';
import fs from 'fs';
import { AuthRequest } from '../types/index.js';
import { findUploadOwner } from '../services/fileDownload.service.js';
import { canAccessCustomer, canAccessContract } from '../utils/accessControl.js';
/**
* Authentifizierter Download-Endpoint mit Per-File-Ownership-Check.
* Ersetzt das ungeschützte `express.static('/api/uploads')`.
*
* Aufruf: GET /api/files/download?path=/uploads/<subDir>/<filename>
*
* Schritte:
* 1. Pfad-Format prüfen (muss mit /uploads/ beginnen, kein Traversal)
* 2. Owner via DB-Lookup ermitteln (welcher Customer/Contract gehört dazu?)
* 3. canAccessCustomer / canAccessContract / Permission-Check
* 4. Datei senden (mit korrektem Content-Type)
*
* Sicherheitsgewinn ggü. dem alten static-Handler: ein eingeloggter
* Portal-Kunde kann jetzt nur seine eigenen Files (oder die seiner
* vertretenen Kunden mit Vollmacht) herunterladen nicht mehr beliebige
* Pfade von fremden Kunden, selbst wenn er die Filenames irgendwo
* mitgeschnitten hätte.
*/
export async function downloadFile(req: AuthRequest, res: Response): Promise<void> {
const requested = typeof req.query.path === 'string' ? req.query.path : '';
if (!requested) {
res.status(400).json({ success: false, error: 'path-Parameter fehlt' });
return;
}
// Format-Validierung (Traversal-Schutz)
if (!requested.startsWith('/uploads/') || requested.includes('..') || requested.includes('\0')) {
res.status(400).json({ success: false, error: 'Ungültiger Pfad' });
return;
}
// Owner ermitteln
const owner = await findUploadOwner(requested);
if (!owner) {
res.status(404).json({ success: false, error: 'Datei nicht gefunden' });
return;
}
// Access-Check je nach Owner-Typ
if (owner.kind === 'customer') {
if (!(await canAccessCustomer(req, res, owner.customerId))) return;
} else if (owner.kind === 'contract') {
if (!(await canAccessContract(req, res, owner.contractId))) return;
} else if (owner.kind === 'admin') {
// PDF-Vorlagen: nur Mitarbeiter mit settings:read
const perms = req.user?.permissions || [];
if (!perms.includes('settings:read') && !perms.includes('settings:update')) {
res.status(403).json({ success: false, error: 'Keine Berechtigung' });
return;
}
} else if (owner.kind === 'gdpr-admin') {
const perms = req.user?.permissions || [];
if (!perms.includes('gdpr:admin')) {
res.status(403).json({ success: false, error: 'Keine Berechtigung' });
return;
}
}
// Datei vom Disk lesen
// requested startet mit /uploads/, wir mappen das auf process.cwd()/uploads/...
const relative = requested.substring('/uploads/'.length);
const absolute = path.join(process.cwd(), 'uploads', relative);
// Letzter Pfad-Sicherheitscheck: absolute Path muss noch unter uploads/ liegen.
const uploadsRoot = path.join(process.cwd(), 'uploads') + path.sep;
if (!absolute.startsWith(uploadsRoot)) {
res.status(400).json({ success: false, error: 'Ungültiger Pfad' });
return;
}
if (!fs.existsSync(absolute)) {
res.status(404).json({ success: false, error: 'Datei nicht gefunden' });
return;
}
// Content-Type aus Extension bestimmen (konservativ Express macht das eh)
res.setHeader('X-Content-Type-Options', 'nosniff');
res.sendFile(absolute);
}
+10 -33
View File
@@ -4,7 +4,6 @@ import * as gdprService from '../services/gdpr.service.js';
import * as consentService from '../services/consent.service.js';
import * as consentPublicService from '../services/consent-public.service.js';
import * as appSettingService from '../services/appSetting.service.js';
import { canAccessCustomer } from '../utils/accessControl.js';
import { createAuditLog, logChange } from '../services/audit.service.js';
import { ConsentType, DeletionRequestStatus } from '@prisma/client';
import prisma from '../lib/prisma.js';
@@ -191,12 +190,7 @@ export async function getDeletionProof(req: AuthRequest, res: Response) {
return res.status(404).json({ success: false, error: 'Kein Löschnachweis vorhanden' });
}
// Path-Traversal-Schutz: proofDocument aus der DB darf nur unter uploads/ liegen
const uploadsDir = path.resolve(process.cwd(), 'uploads');
const filepath = path.resolve(uploadsDir, request.proofDocument);
if (!filepath.startsWith(uploadsDir + path.sep)) {
return res.status(400).json({ success: false, error: 'Ungültiger Dateipfad' });
}
const filepath = path.join(process.cwd(), 'uploads', request.proofDocument);
if (!fs.existsSync(filepath)) {
return res.status(404).json({ success: false, error: 'Datei nicht gefunden' });
@@ -230,7 +224,6 @@ export async function getDashboardStats(req: AuthRequest, res: Response) {
export async function getCustomerConsents(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const consents = await consentService.getCustomerConsents(customerId);
// Labels hinzufügen
@@ -253,7 +246,6 @@ export async function getCustomerConsents(req: AuthRequest, res: Response) {
export async function checkConsentStatus(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const result = await consentService.hasFullConsent(customerId);
res.json({ success: true, data: result });
} catch (error) {
@@ -802,7 +794,6 @@ export async function sendAuthorizationRequest(req: AuthRequest, res: Response)
export async function getAuthorizations(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
// Sicherstellen dass Einträge für alle aktiven Vertreter existieren
await authorizationService.ensureAuthorizationEntries(customerId);
const authorizations = await authorizationService.getAuthorizationsForCustomer(customerId);
@@ -970,27 +961,12 @@ export async function toggleMyAuthorization(req: AuthRequest, res: Response) {
const representativeId = parseInt(req.params.representativeId);
const { grant } = req.body;
// Validierungen:
// 1) Self-Grant verhindern (sinnlos und schafft Datenmüll).
if (representativeId === user.customerId) {
return res.status(400).json({ success: false, error: 'Kein Self-Grant möglich' });
}
// 2) Existenz + aktives Vertreter-Verhältnis in EINEM Lookup prüfen.
// Beide Fälle (representative existiert nicht / keine aktive Beziehung)
// geben identisch 403, damit ein Angreifer keine Customer-IDs aus der
// DB enumerieren kann (kein 404-vs-403-Disclosure).
const relation = await prisma.customerRepresentative.findFirst({
where: { customerId: user.customerId, representativeId, isActive: true },
include: { representative: { select: { firstName: true, lastName: true } } },
// Vertreter-Name laden
const representative = await prisma.customer.findUnique({
where: { id: representativeId },
select: { firstName: true, lastName: true },
});
if (!relation) {
return res.status(403).json({
success: false,
error: 'Kein Vertreter-Verhältnis Vollmacht nicht erlaubt',
});
}
const repName = `${relation.representative.firstName} ${relation.representative.lastName}`;
const repName = representative ? `${representative.firstName} ${representative.lastName}` : `#${representativeId}`;
let auth;
if (grant) {
@@ -1006,9 +982,10 @@ export async function toggleMyAuthorization(req: AuthRequest, res: Response) {
res.json({ success: true, data: auth });
} catch (error) {
console.error('Fehler beim Ändern der Vollmacht:', error);
// Generische Fehlermeldung Prisma-Errors enthalten Pfad/Schema und
// sollten nicht an Endkunden geleakt werden.
res.status(400).json({ success: false, error: 'Vollmacht konnte nicht aktualisiert werden' });
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Ändern',
});
}
}
+8 -16
View File
@@ -1,16 +1,14 @@
import { Request, Response } from 'express';
import * as invoiceService from '../services/invoice.service.js';
import { logChange } from '../services/audit.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { canAccessContract, canAccessEnergyContractDetails } from '../utils/accessControl.js';
import { ApiResponse } from '../types/index.js';
/**
* Alle Rechnungen für ein EnergyContractDetails abrufen
*/
export async function getInvoices(req: AuthRequest, res: Response): Promise<void> {
export async function getInvoices(req: Request, res: Response): Promise<void> {
try {
const ecdId = parseInt(req.params.ecdId);
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
const invoices = await invoiceService.getInvoices(ecdId);
res.json({ success: true, data: invoices } as ApiResponse);
} catch (error) {
@@ -25,11 +23,10 @@ export async function getInvoices(req: AuthRequest, res: Response): Promise<void
/**
* Einzelne Rechnung abrufen
*/
export async function getInvoice(req: AuthRequest, res: Response): Promise<void> {
export async function getInvoice(req: Request, res: Response): Promise<void> {
try {
const ecdId = parseInt(req.params.ecdId);
const invoiceId = parseInt(req.params.invoiceId);
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
const invoice = await invoiceService.getInvoice(ecdId, invoiceId);
if (!invoice) {
@@ -53,10 +50,9 @@ export async function getInvoice(req: AuthRequest, res: Response): Promise<void>
/**
* Neue Rechnung hinzufügen
*/
export async function addInvoice(req: AuthRequest, res: Response): Promise<void> {
export async function addInvoice(req: Request, res: Response): Promise<void> {
try {
const ecdId = parseInt(req.params.ecdId);
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
const { invoiceDate, invoiceType, documentPath, notes } = req.body;
if (!invoiceDate || !invoiceType) {
@@ -93,11 +89,10 @@ export async function addInvoice(req: AuthRequest, res: Response): Promise<void>
/**
* Rechnung aktualisieren
*/
export async function updateInvoice(req: AuthRequest, res: Response): Promise<void> {
export async function updateInvoice(req: Request, res: Response): Promise<void> {
try {
const ecdId = parseInt(req.params.ecdId);
const invoiceId = parseInt(req.params.invoiceId);
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
const { invoiceDate, invoiceType, documentPath, notes } = req.body;
const invoice = await invoiceService.updateInvoice(ecdId, invoiceId, {
@@ -126,11 +121,10 @@ export async function updateInvoice(req: AuthRequest, res: Response): Promise<vo
/**
* Rechnung löschen
*/
export async function deleteInvoice(req: AuthRequest, res: Response): Promise<void> {
export async function deleteInvoice(req: Request, res: Response): Promise<void> {
try {
const ecdId = parseInt(req.params.ecdId);
const invoiceId = parseInt(req.params.invoiceId);
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
await invoiceService.deleteInvoice(ecdId, invoiceId);
@@ -152,10 +146,9 @@ export async function deleteInvoice(req: AuthRequest, res: Response): Promise<vo
// ==================== CONTRACT-BASIERTE RECHNUNGEN (für alle Vertragstypen) ====================
export async function getInvoicesByContract(req: AuthRequest, res: Response): Promise<void> {
export async function getInvoicesByContract(req: Request, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
const invoices = await invoiceService.getInvoicesByContract(contractId);
res.json({ success: true, data: invoices } as ApiResponse);
} catch (error) {
@@ -163,10 +156,9 @@ export async function getInvoicesByContract(req: AuthRequest, res: Response): Pr
}
}
export async function addInvoiceByContract(req: AuthRequest, res: Response): Promise<void> {
export async function addInvoiceByContract(req: Request, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
const { invoiceDate, invoiceType, notes } = req.body;
const invoice = await invoiceService.addInvoiceByContract(contractId, {
invoiceDate: new Date(invoiceDate),
@@ -1,209 +0,0 @@
import { Response } from 'express';
import prisma from '../lib/prisma.js';
import { AuthRequest, ApiResponse } from '../types/index.js';
import * as appSettingService from '../services/appSetting.service.js';
import { sendAlertEmail, sendDigest } from '../services/securityAlert.service.js';
import type { SecurityEventType, SecuritySeverity } from '@prisma/client';
/**
* GET /api/monitoring/events
* Liste der Security-Events mit Filter + Pagination.
*/
export async function listEvents(req: AuthRequest, res: Response): Promise<void> {
try {
const page = parseInt((req.query.page as string) || '1');
const limit = Math.min(parseInt((req.query.limit as string) || '50'), 200);
const type = req.query.type as SecurityEventType | undefined;
const severity = req.query.severity as SecuritySeverity | undefined;
const search = req.query.search as string | undefined;
const since = req.query.since as string | undefined;
const ip = req.query.ip as string | undefined;
const where: any = {};
if (type) where.type = type;
if (severity) where.severity = severity;
if (ip) where.ipAddress = ip;
if (since) where.createdAt = { gte: new Date(since) };
if (search) {
where.OR = [
{ message: { contains: search } },
{ userEmail: { contains: search } },
{ endpoint: { contains: search } },
];
}
const [events, total, byType, bySeverity] = await Promise.all([
prisma.securityEvent.findMany({
where,
orderBy: { createdAt: 'desc' },
take: limit,
skip: (page - 1) * limit,
}),
prisma.securityEvent.count({ where }),
prisma.securityEvent.groupBy({
by: ['type'],
where: since ? { createdAt: { gte: new Date(since) } } : {},
_count: true,
}),
prisma.securityEvent.groupBy({
by: ['severity'],
where: since ? { createdAt: { gte: new Date(since) } } : {},
_count: true,
}),
]);
res.json({
success: true,
data: events,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
stats: {
byType: Object.fromEntries(byType.map((r: any) => [r.type, r._count])),
bySeverity: Object.fromEntries(bySeverity.map((r: any) => [r.severity, r._count])),
},
} as any);
} catch (error) {
console.error('listEvents error:', error);
res.status(500).json({ success: false, error: 'Fehler beim Laden der Security-Events' } as ApiResponse);
}
}
/**
* GET /api/monitoring/settings
*/
export async function getMonitoringSettings(_req: AuthRequest, res: Response): Promise<void> {
try {
const alertEmail = await appSettingService.getSetting('monitoringAlertEmail');
const digestEnabled = await appSettingService.getSettingBool('monitoringDigestEnabled');
const lastDigest = await appSettingService.getSetting('monitoringLastDigestAt');
res.json({
success: true,
data: {
alertEmail: alertEmail || '',
digestEnabled,
lastDigestAt: lastDigest || null,
},
} as ApiResponse);
} catch (error) {
res.status(500).json({ success: false, error: 'Fehler beim Laden' } as ApiResponse);
}
}
/**
* PUT /api/monitoring/settings
*/
export async function updateMonitoringSettings(req: AuthRequest, res: Response): Promise<void> {
try {
const { alertEmail, digestEnabled } = req.body || {};
if (typeof alertEmail === 'string') {
// Email-Validierung minimal: muss @ enthalten oder leer sein
if (alertEmail !== '' && !alertEmail.includes('@')) {
res.status(400).json({ success: false, error: 'Ungültige E-Mail-Adresse' } as ApiResponse);
return;
}
await appSettingService.setSetting('monitoringAlertEmail', alertEmail);
}
if (typeof digestEnabled === 'boolean') {
await appSettingService.setSetting('monitoringDigestEnabled', digestEnabled ? 'true' : 'false');
}
res.json({ success: true, message: 'Einstellungen gespeichert' } as ApiResponse);
} catch (error) {
res.status(500).json({ success: false, error: 'Fehler beim Speichern' } as ApiResponse);
}
}
/**
* POST /api/monitoring/test-alert
* Versendet eine Test-Alert-Mail an die konfigurierte Adresse.
*/
export async function testAlert(_req: AuthRequest, res: Response): Promise<void> {
try {
const alertEmail = await appSettingService.getSetting('monitoringAlertEmail');
if (!alertEmail) {
res.status(400).json({
success: false,
error: 'Keine Alert-E-Mail konfiguriert',
} as ApiResponse);
return;
}
const result = await sendAlertEmail(alertEmail, {
subject: '[OpenCRM] Test-Alert',
events: [{
type: 'SUSPICIOUS' as any,
severity: 'INFO' as any,
message: 'Dies ist eine Test-Mail vom Monitoring-System. Alles in Ordnung.',
createdAt: new Date(),
} as any],
isDigest: false,
});
if (result.success) {
res.json({ success: true, message: `Test-Alert an ${alertEmail} versendet` } as ApiResponse);
} else {
res.status(500).json({ success: false, error: result.error || 'Versand fehlgeschlagen' } as ApiResponse);
}
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Test-Alert fehlgeschlagen',
} as ApiResponse);
}
}
/**
* DELETE /api/monitoring/events
* Löscht alle SecurityEvents (oder optional nur älter als ?olderThanDays).
* Alert-versendete CRITICAL-Events werden vorher noch geloggt, damit der
* Audit-Trail erhalten bleibt.
*/
export async function clearEvents(req: AuthRequest, res: Response): Promise<void> {
try {
const olderThanDays = req.query.olderThanDays
? parseInt(req.query.olderThanDays as string)
: undefined;
const where: any = {};
if (olderThanDays && olderThanDays > 0) {
const cutoff = new Date(Date.now() - olderThanDays * 24 * 60 * 60 * 1000);
where.createdAt = { lt: cutoff };
}
const result = await prisma.securityEvent.deleteMany({ where });
// Audit-Spur: Wer hat geleert
const user = (req as any).user;
await prisma.securityEvent.create({
data: {
type: 'PERMISSION_CHANGED',
severity: 'INFO',
message: `Security-Log geleert: ${result.count} Einträge gelöscht${olderThanDays ? ` (älter als ${olderThanDays} Tage)` : ''}`,
userId: user?.userId || null,
userEmail: user?.email || null,
ipAddress: req.ip || 'unknown',
endpoint: 'DELETE /api/monitoring/events',
},
});
res.json({
success: true,
message: `${result.count} Events gelöscht`,
data: { deletedCount: result.count },
} as any);
} catch (error) {
console.error('clearEvents error:', error);
res.status(500).json({ success: false, error: 'Löschen fehlgeschlagen' } as ApiResponse);
}
}
/**
* POST /api/monitoring/run-digest (manueller Trigger für den Hourly-Digest)
*/
export async function runDigestNow(_req: AuthRequest, res: Response): Promise<void> {
try {
const result = await sendDigest({ force: true });
res.json({ success: true, data: result } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Digest fehlgeschlagen',
} as ApiResponse);
}
}
@@ -2,7 +2,6 @@ import { Response } from 'express';
import { AuthRequest } from '../types/index.js';
import * as pdfTemplateService from '../services/pdfTemplate.service.js';
import { logChange } from '../services/audit.service.js';
import { canAccessContract } from '../utils/accessControl.js';
export async function getTemplates(req: AuthRequest, res: Response) {
try {
@@ -150,7 +149,6 @@ export async function getRequiredInputs(req: AuthRequest, res: Response) {
try {
const templateId = parseInt(req.params.id);
const contractId = parseInt(req.params.contractId);
if (!(await canAccessContract(req, res, contractId))) return;
const inputs = await pdfTemplateService.getRequiredInputs(templateId, contractId);
res.json({ success: true, data: inputs });
} catch (error) {
@@ -162,7 +160,6 @@ export async function generatePdf(req: AuthRequest, res: Response) {
try {
const templateId = parseInt(req.params.id);
const contractId = parseInt(req.params.contractId);
if (!(await canAccessContract(req, res, contractId))) return;
// Extras aus Body (POST) oder Query-Parametern (GET)
const stressfreiEmailId = req.body?.stressfreiEmailId || req.query.stressfreiEmailId;
@@ -1,8 +1,7 @@
import { Request, Response } from 'express';
import * as stressfreiEmailService from '../services/stressfreiEmail.service.js';
import { logChange } from '../services/audit.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { canAccessStressfreiEmail } from '../utils/accessControl.js';
import { ApiResponse } from '../types/index.js';
export async function getEmailsByCustomer(req: Request, res: Response): Promise<void> {
try {
@@ -18,12 +17,9 @@ export async function getEmailsByCustomer(req: Request, res: Response): Promise<
}
}
export async function getEmail(req: AuthRequest, res: Response): Promise<void> {
export async function getEmail(req: Request, res: Response): Promise<void> {
try {
const emailId = parseInt(req.params.id);
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
const email = await stressfreiEmailService.getEmailById(emailId);
const email = await stressfreiEmailService.getEmailById(parseInt(req.params.id));
if (!email) {
res.status(404).json({
success: false,
@@ -31,13 +27,7 @@ export async function getEmail(req: AuthRequest, res: Response): Promise<void> {
} as ApiResponse);
return;
}
// Sensibles Feld emailPasswordEncrypted nie an Portal-Kunden geben
const sanitized: any = { ...email };
if (req.user?.isCustomerPortal) {
delete sanitized.emailPasswordEncrypted;
}
res.json({ success: true, data: sanitized } as ApiResponse);
res.json({ success: true, data: email } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
+3 -6
View File
@@ -3,7 +3,6 @@ import prisma from '../lib/prisma.js';
import * as userService from '../services/user.service.js';
import { logChange } from '../services/audit.service.js';
import { ApiResponse } from '../types/index.js';
import { pickUserCreate, pickUserUpdate } from '../utils/sanitize.js';
// Users
export async function getUsers(req: Request, res: Response): Promise<void> {
@@ -50,8 +49,7 @@ export async function getUser(req: Request, res: Response): Promise<void> {
export async function createUser(req: Request, res: Response): Promise<void> {
try {
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
const user = await userService.createUser(pickUserCreate(req.body) as any);
const user = await userService.createUser(req.body);
await logChange({
req, action: 'CREATE', resourceType: 'User',
resourceId: user.id.toString(),
@@ -69,13 +67,12 @@ export async function createUser(req: Request, res: Response): Promise<void> {
export async function updateUser(req: Request, res: Response): Promise<void> {
try {
const userId = parseInt(req.params.id);
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
const data = pickUserUpdate(req.body);
const data = req.body;
// Vorherigen Stand laden für Audit
const before = await prisma.user.findUnique({ where: { id: userId } });
const user = await userService.updateUser(userId, data as any);
const user = await userService.updateUser(userId, data);
if (user) {
// Audit: Geänderte Felder ermitteln und loggen
if (before) {
+9 -105
View File
@@ -1,6 +1,5 @@
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import path from 'path';
import dotenv from 'dotenv';
@@ -33,99 +32,24 @@ import consentPublicRoutes from './routes/consent-public.routes.js';
import emailLogRoutes from './routes/emailLog.routes.js';
import pdfTemplateRoutes from './routes/pdfTemplate.routes.js';
import birthdayRoutes from './routes/birthday.routes.js';
import factoryDefaultsRoutes from './routes/factoryDefaults.routes.js';
import { downloadFile } from './controllers/fileDownload.controller.js';
import { startBirthdayScheduler } from './services/birthdayScheduler.service.js';
import { startContractStatusScheduler } from './services/contractStatusScheduler.service.js';
import { startSecurityMonitorScheduler } from './services/securityAlert.service.js';
import monitoringRoutes from './routes/monitoring.routes.js';
import { auditContextMiddleware } from './middleware/auditContext.js';
import { auditMiddleware } from './middleware/audit.js';
import { authenticate } from './middleware/auth.js';
dotenv.config();
// ==================== SECURITY: Pflicht-Umgebungsvariablen prüfen ====================
if (!process.env.JWT_SECRET || process.env.JWT_SECRET.length < 32) {
console.error('❌ JWT_SECRET ist nicht gesetzt oder zu kurz (min. 32 Zeichen)');
console.error(' Generiere mit: openssl rand -hex 64');
process.exit(1);
}
if (!process.env.ENCRYPTION_KEY || process.env.ENCRYPTION_KEY.length !== 64) {
console.error('❌ ENCRYPTION_KEY ist nicht gesetzt oder hat nicht exakt 64 Hex-Zeichen (32 Byte)');
console.error(' Generiere mit: openssl rand -hex 32');
process.exit(1);
}
const app = express();
const PORT = process.env.PORT || 3001;
// Hinter einem Reverse-Proxy (Nginx/Plesk) läuft der Server typisch auf localhost.
// `trust proxy = 'loopback'` vertraut nur Connections von 127.0.0.1 / ::1
// (= lokaler Reverse-Proxy). Damit kann ein Angreifer mit DIREKTEM Zugriff
// auf das Backend nicht via X-Forwarded-For den Rate-Limiter umgehen,
// während gleichzeitig der lokale Reverse-Proxy die echte Client-IP liefern darf.
//
// WICHTIG für Production: Backend nur auf 127.0.0.1 lauschen lassen
// (LISTEN_ADDR=127.0.0.1) sonst kann ein direkter Connect von außen
// trotzdem als loopback gelten, falls das Routing das so durchstellt.
app.set('trust proxy', 'loopback');
// ==================== SECURITY MIDDLEWARE ====================
// HTTP Security Headers (X-Frame-Options, X-Content-Type-Options, HSTS, etc.)
app.use(
helmet({
// CSP ausschalten wird bei SPA schwierig, frontend setzt eigene CSP via meta
contentSecurityPolicy: false,
// Cross-Origin-Resource-Policy: "same-site" für SPA mit gleicher Origin
crossOriginResourcePolicy: { policy: 'same-site' },
}),
);
// CORS: in Production nur explizit erlaubte Origins. In Dev: alles erlauben.
const corsOrigins = process.env.CORS_ORIGINS
? process.env.CORS_ORIGINS.split(',').map((s) => s.trim())
: process.env.NODE_ENV === 'production'
? false // Gar kein Cross-Origin zulässig (Frontend wird unter gleicher Origin ausgeliefert)
: true; // Dev: alles erlauben
app.use(
cors({
origin: corsOrigins,
credentials: true,
}),
);
// JSON-Body-Limit: 5 MB (Uploads laufen über multer, brauchen kein json())
app.use(express.json({ limit: '5mb' }));
// Middleware
app.use(cors());
app.use(express.json());
// Audit-Logging Middleware (DSGVO-konform)
app.use(auditContextMiddleware);
app.use(auditMiddleware);
// Datei-Download mit Per-File-Ownership-Check (ersetzt das alte
// `/api/uploads/*` express.static).
// Frontend-URLs gehen jetzt über GET /api/files/download?path=/uploads/...
// Der Controller mappt den Pfad auf eine Resource (BankCard, Contract, etc.)
// und prüft canAccessCustomer/canAccessContract damit kann ein Portal-Kunde
// nur seine eigenen Dateien laden, selbst wenn er fremde Filenames kennt.
//
// Kompatibilität: das alte /api/uploads/* bleibt erhalten, leitet aber jeden
// Request über denselben Owner-Check (kein freier static-Handler mehr).
// Authentifizierter Datei-Download mit Per-File-Ownership-Check.
// Akzeptiert Pfade wie /uploads/bank-cards/<filename> egal ob als
// Query-Parameter oder im Pfad-Suffix. Beide gehen über denselben Handler,
// der DB-basiert prüft, ob der eingeloggte User die Resource sehen darf.
app.get('/api/files/download', authenticate as any, downloadFile as any);
// Backwards-compatibility shim: `/api/uploads/*` sieht weiter aus wie früher
// für Bestandsclients/Bookmarks, ruft aber denselben Owner-Check-Handler.
app.get('/api/uploads/*', authenticate as any, (req, res, next) => {
// Pfad in Query-Param umschreiben, dann an downloadFile weiterreichen
req.query.path = req.originalUrl.replace(/^\/api/, '').split('?')[0];
return (downloadFile as any)(req, res, next);
});
// Statische Dateien für Uploads
app.use('/api/uploads', express.static(path.join(process.cwd(), 'uploads')));
// Öffentliche Routes (OHNE Authentifizierung)
app.use('/api/public/consent', consentPublicRoutes);
@@ -159,8 +83,6 @@ app.use('/api/gdpr', gdprRoutes);
app.use('/api/email-logs', emailLogRoutes);
app.use('/api/pdf-templates', pdfTemplateRoutes);
app.use('/api/birthdays', birthdayRoutes);
app.use('/api/factory-defaults', factoryDefaultsRoutes);
app.use('/api/monitoring', monitoringRoutes);
// Health check
app.get('/api/health', (req, res) => {
@@ -185,29 +107,11 @@ if (process.env.NODE_ENV === 'production') {
}
// Error handling
// body-parser wirft 413 (PayloadTooLargeError) bzw. 400 (SyntaxError) mit einem
// `status`-Feld. Ohne Respektierung werden legitime Client-Fehler als 500
// kaschiert und landen als "Interner Serverfehler" beim User.
app.use((err: Error & { status?: number; type?: string }, req: express.Request, res: express.Response, next: express.NextFunction) => {
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
console.error(err.stack);
const status = typeof err.status === 'number' && err.status >= 400 && err.status < 600 ? err.status : 500;
let message = 'Interner Serverfehler';
if (status === 413) message = 'Anfrage zu groß';
else if (status === 400 && (err.type === 'entity.parse.failed' || err instanceof SyntaxError)) {
message = 'Ungültiges JSON';
}
res.status(status).json({ success: false, error: message });
res.status(500).json({ success: false, error: 'Interner Serverfehler' });
});
// Listen-Adresse: in Production typischerweise 127.0.0.1 (nur lokaler
// Reverse-Proxy soll connecten dürfen). LISTEN_ADDR per Env überschreibbar.
const LISTEN_ADDR = process.env.LISTEN_ADDR
|| (process.env.NODE_ENV === 'production' ? '127.0.0.1' : '0.0.0.0');
app.listen(PORT as number, LISTEN_ADDR, () => {
console.log(`Server läuft auf ${LISTEN_ADDR}:${PORT}`);
// Hintergrund-Scheduler (Geburtstagsgrüße etc.) starten
startBirthdayScheduler();
startContractStatusScheduler();
startSecurityMonitorScheduler();
app.listen(PORT, () => {
console.log(`Server läuft auf Port ${PORT}`);
});
+9 -41
View File
@@ -2,7 +2,6 @@ import { Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import prisma from '../lib/prisma.js';
import { AuthRequest, JwtPayload } from '../types/index.js';
import { emit as emitSecurityEvent } from '../services/securityMonitor.service.js';
export async function authenticate(
req: AuthRequest,
@@ -27,27 +26,27 @@ export async function authenticate(
}
try {
// JWT_SECRET wird beim Server-Start geprüft (Fail-Fast in index.ts)
// Algorithmus explizit auf HS256 festlegen (Defense-in-Depth gegen alg-confusion).
const decoded = jwt.verify(token, process.env.JWT_SECRET as string, {
algorithms: ['HS256'],
}) as JwtPayload;
const decoded = jwt.verify(
token,
process.env.JWT_SECRET || 'fallback-secret'
) as JwtPayload;
// Prüfen ob Token durch Rechteänderung/Passwort-Reset invalidiert wurde
// Prüfen ob Token durch Rechteänderung invalidiert wurde (nur für Mitarbeiter)
if (decoded.userId && decoded.iat) {
// Mitarbeiter-Login
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
select: { tokenInvalidatedAt: true, isActive: true },
});
// Benutzer nicht gefunden oder deaktiviert
if (!user || !user.isActive) {
res.status(401).json({ success: false, error: 'Benutzer nicht mehr aktiv' });
return;
}
// Token wurde vor der Invalidierung ausgestellt
if (user.tokenInvalidatedAt) {
const tokenIssuedAt = decoded.iat * 1000;
const tokenIssuedAt = decoded.iat * 1000; // iat ist in Sekunden, Date ist in Millisekunden
if (tokenIssuedAt < user.tokenInvalidatedAt.getTime()) {
res.status(401).json({
success: false,
@@ -56,42 +55,11 @@ export async function authenticate(
return;
}
}
} else if (decoded.isCustomerPortal && decoded.customerId && decoded.iat) {
// Portal-Kunden-Login: gleiche Prüfung
const customer = await prisma.customer.findUnique({
where: { id: decoded.customerId },
select: { portalTokenInvalidatedAt: true, portalEnabled: true },
});
if (!customer || !customer.portalEnabled) {
res.status(401).json({ success: false, error: 'Portal-Zugang nicht mehr aktiv' });
return;
}
if (customer.portalTokenInvalidatedAt) {
const tokenIssuedAt = decoded.iat * 1000;
if (tokenIssuedAt < customer.portalTokenInvalidatedAt.getTime()) {
res.status(401).json({
success: false,
error: 'Ihre Sitzung ist ungültig. Bitte melden Sie sich erneut an.',
});
return;
}
}
}
req.user = decoded;
next();
} catch (err) {
// JWT-Failures sind interessant: alg=none, manipulierte Signature,
// expired Token. Emit SecurityEvent (asynchron, blockt nicht).
emitSecurityEvent({
type: 'TOKEN_REJECTED',
severity: err instanceof jwt.TokenExpiredError ? 'LOW' : 'HIGH',
message: err instanceof Error ? `JWT abgelehnt: ${err.message}` : 'JWT abgelehnt',
ipAddress: req.ip || (req.socket as any)?.remoteAddress || 'unknown',
endpoint: `${req.method} ${req.path}`,
});
} catch {
res.status(401).json({ success: false, error: 'Ungültiger Token' });
}
}
-65
View File
@@ -1,65 +0,0 @@
/**
* Rate-Limiting-Middleware für sensible Endpoints (Login, Passwort-Reset).
* Schützt gegen Brute-Force- und Credential-Stuffing-Angriffe.
*
* Wenn ein Limit überschritten wird, emit() wir zusätzlich ein
* SecurityEvent (RATE_LIMIT_HIT) damit der Monitoring-View und das
* Alert-System sehen, wenn jemand auf die Tür hämmert.
*/
import rateLimit from 'express-rate-limit';
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
function onLimitReached(label: string, severity: 'MEDIUM' | 'HIGH') {
return (req: any, _res: any) => {
const ctx = contextFromRequest(req);
emitSecurityEvent({
type: 'RATE_LIMIT_HIT',
severity,
message: `Rate-Limit überschritten: ${label}`,
ipAddress: ctx.ipAddress,
userEmail: req.body?.email,
endpoint: ctx.endpoint,
details: { limiter: label },
});
};
}
/**
* Login: 10 Versuche pro 15 Minuten pro IP.
* Nach Überschreitung: 15 Min Sperre für diese IP.
*/
export const loginRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 Minuten
limit: 10, // Max. 10 Versuche pro Zeitfenster
standardHeaders: 'draft-7',
legacyHeaders: false,
message: {
success: false,
error: 'Zu viele Login-Versuche. Bitte in 15 Minuten erneut versuchen.',
},
// Erfolgreiche Logins zählen nicht gegen das Limit
skipSuccessfulRequests: true,
handler: (req, res, _next, options) => {
onLimitReached('login', 'HIGH')(req, res);
res.status(options.statusCode).json(options.message);
},
});
/**
* Passwort-Reset-Anfrage: 5 Versuche pro Stunde pro IP.
* Verhindert Mail-Flut und gezielte Brute-Force über Reset-Links.
*/
export const passwordResetRateLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 Stunde
limit: 5,
standardHeaders: 'draft-7',
legacyHeaders: false,
message: {
success: false,
error: 'Zu viele Passwort-Reset-Anfragen. Bitte in einer Stunde erneut versuchen.',
},
handler: (req, res, _next, options) => {
onLimitReached('password-reset', 'MEDIUM')(req, res);
res.status(options.statusCode).json(options.message);
},
});
+2 -8
View File
@@ -1,18 +1,12 @@
import { Router } from 'express';
import * as authController from '../controllers/auth.controller.js';
import { authenticate, requirePermission } from '../middleware/auth.js';
import { loginRateLimiter, passwordResetRateLimiter } from '../middleware/rateLimit.js';
const router = Router();
router.post('/login', loginRateLimiter, authController.login);
router.post('/customer-login', loginRateLimiter, authController.customerLogin);
router.post('/login', authController.login);
router.post('/customer-login', authController.customerLogin); // Kundenportal-Login
router.get('/me', authenticate, authController.me);
router.post('/logout', authenticate, authController.logout);
router.post('/register', authenticate, requirePermission('users:create'), authController.register);
// Passwort-Reset-Flow
router.post('/password-reset/request', passwordResetRateLimiter, authController.requestPasswordReset);
router.post('/password-reset/confirm', passwordResetRateLimiter, authController.confirmPasswordReset);
export default router;
-4
View File
@@ -7,10 +7,6 @@ const router = Router();
// Admin: Kommende und vergangene Geburtstage
router.get('/upcoming', authenticate, requirePermission('customers:read'), birthdayController.getUpcomingBirthdays);
// Admin: Gruß-Marker zurücksetzen + Gruß senden
router.post('/:customerId/reset', authenticate, requirePermission('customers:update'), birthdayController.resetBirthdayGreeting);
router.post('/:customerId/send', authenticate, requirePermission('customers:update'), birthdayController.sendBirthdayGreeting);
// Portal: eigener Geburtstag-Check
router.get('/my-birthday', authenticate, birthdayController.getMyBirthday);
router.post('/my-birthday/acknowledge', authenticate, birthdayController.acknowledgeMyBirthday);
-9
View File
@@ -203,15 +203,6 @@ router.post(
cachedEmailController.saveAttachmentAsInvoice
);
// Anhang als Vertragsdokument speichern
// POST /api/emails/:id/attachments/:filename/save-as-contract-document { documentType, notes? }
router.post(
'/emails/:id/attachments/:filename/save-as-contract-document',
authenticate,
requirePermission('contracts:update'),
cachedEmailController.saveAttachmentAsContractDocument
);
// ==================== VERTRAGSZUORDNUNG ====================
// E-Mail Vertrag zuordnen
@@ -15,9 +15,7 @@ router.delete('/configs/:id', authenticate, requirePermission('settings:update')
// Email Operations
router.post('/test-connection', authenticate, requirePermission('settings:update'), emailProviderController.testConnection);
router.post('/test-mail-access', authenticate, requirePermission('settings:update'), emailProviderController.testMailAccess);
router.get('/domain', authenticate, emailProviderController.getProviderDomain);
router.get('/public-settings', authenticate, emailProviderController.getPublicSettings);
router.get('/check/:localPart', authenticate, requirePermission('customers:read'), emailProviderController.checkEmailExists);
router.post('/provision', authenticate, requirePermission('customers:update'), emailProviderController.provisionEmail);
router.delete('/deprovision/:localPart', authenticate, requirePermission('customers:update'), emailProviderController.deprovisionEmail);
@@ -1,23 +0,0 @@
import { Router } from 'express';
import * as factoryDefaultsController from '../controllers/factoryDefaults.controller.js';
import { authenticate, requirePermission } from '../middleware/auth.js';
const router = Router();
// Preview (was wäre im Export drin?)
router.get(
'/preview',
authenticate,
requirePermission('settings:read'),
factoryDefaultsController.previewFactoryDefaults,
);
// Export als ZIP-Download
router.get(
'/export',
authenticate,
requirePermission('settings:update'),
factoryDefaultsController.exportFactoryDefaults,
);
export default router;
-16
View File
@@ -1,16 +0,0 @@
import { Router } from 'express';
import { authenticate, requirePermission } from '../middleware/auth.js';
import * as monitoringController from '../controllers/monitoring.controller.js';
const router = Router();
router.use(authenticate);
// Monitoring ist Admin-Sache: settings:read fürs Anzeigen, settings:update für Änderungen
router.get('/events', requirePermission('settings:read'), monitoringController.listEvents);
router.get('/settings', requirePermission('settings:read'), monitoringController.getMonitoringSettings);
router.put('/settings', requirePermission('settings:update'), monitoringController.updateMonitoringSettings);
router.post('/test-alert', requirePermission('settings:update'), monitoringController.testAlert);
router.post('/run-digest', requirePermission('settings:update'), monitoringController.runDigestNow);
router.delete('/events', requirePermission('settings:update'), monitoringController.clearEvents);
export default router;
+4 -4
View File
@@ -5,15 +5,15 @@ import { authenticate, requirePermission } from '../middleware/auth.js';
const router = Router();
// Provider routes (Portal-Kunden sollen keine Provider-Liste/Tarife sehen)
router.get('/', authenticate, requirePermission('providers:read'), providerController.getProviders);
// Provider routes
router.get('/', authenticate, providerController.getProviders);
router.post('/', authenticate, requirePermission('providers:create'), providerController.createProvider);
router.get('/:id', authenticate, requirePermission('providers:read'), providerController.getProvider);
router.get('/:id', authenticate, providerController.getProvider);
router.put('/:id', authenticate, requirePermission('providers:update'), providerController.updateProvider);
router.delete('/:id', authenticate, requirePermission('providers:delete'), providerController.deleteProvider);
// Nested tariff routes
router.get('/:providerId/tariffs', authenticate, requirePermission('providers:read'), tariffController.getTariffs);
router.get('/:providerId/tariffs', authenticate, tariffController.getTariffs);
router.post('/:providerId/tariffs', authenticate, requirePermission('providers:create'), tariffController.createTariff);
export default router;
+1 -1
View File
@@ -5,7 +5,7 @@ import { authenticate, requirePermission } from '../middleware/auth.js';
const router = Router();
// Standalone tariff routes (for update/delete by tariff id)
router.get('/:id', authenticate, requirePermission('providers:read'), tariffController.getTariff);
router.get('/:id', authenticate, tariffController.getTariff);
router.put('/:id', authenticate, requirePermission('providers:update'), tariffController.updateTariff);
router.delete('/:id', authenticate, requirePermission('providers:delete'), tariffController.deleteTariff);
+1 -43
View File
@@ -6,7 +6,6 @@ import prisma from '../lib/prisma.js';
import { authenticate, requirePermission } from '../middleware/auth.js';
import { AuthRequest } from '../types/index.js';
import { logChange } from '../services/audit.service.js';
import { canAccessContract } from '../utils/accessControl.js';
const router = Router();
@@ -547,7 +546,6 @@ async function handleContractDocumentUpload(
}
const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
const relativePath = `/uploads/${subDir}/${req.file.filename}`;
// Alte Datei löschen falls vorhanden
@@ -565,51 +563,12 @@ async function handleContractDocumentUpload(
}
}
// Bei Kündigungsbestätigung(s-Optionen): optionales Datum aus multipart
// übernehmen. Ohne Angabe: falls Feld noch leer → heute, sonst nicht anfassen.
const updateData: Record<string, unknown> = { [fieldName]: relativePath };
if (fieldName === 'cancellationConfirmationPath' || fieldName === 'cancellationConfirmationOptionsPath') {
const dateField = fieldName === 'cancellationConfirmationPath'
? 'cancellationConfirmationDate'
: 'cancellationConfirmationOptionsDate';
const provided = typeof req.body?.confirmationDate === 'string' ? req.body.confirmationDate : null;
let target: Date | null = null;
if (provided) {
const parsed = new Date(provided);
if (!isNaN(parsed.getTime())) target = parsed;
}
if (target) {
updateData[dateField] = target;
} else if (!contract[dateField]) {
updateData[dateField] = new Date();
}
}
// Vertrag in der DB aktualisieren
await prisma.contract.update({
where: { id: contractId },
data: updateData,
data: { [fieldName]: relativePath },
});
// Wenn eine Kündigungsbestätigung (nicht "Optionen") hochgeladen wurde und
// der Vertrag noch ACTIVE ist → auf CANCELLED umstellen + Audit-Log.
// "Optionen" ist für Vertrags-Änderungen gedacht, nicht für echte Kündigungen.
if (fieldName === 'cancellationConfirmationPath' && contract.status === 'ACTIVE') {
await prisma.contract.update({
where: { id: contractId },
data: { status: 'CANCELLED' },
});
await logChange({
req,
action: 'UPDATE',
resourceType: 'Contract',
resourceId: contractId.toString(),
label: `Vertrag ${contract.contractNumber} automatisch auf CANCELLED gesetzt (Kündigungsbestätigung hochgeladen)`,
details: { vorher: 'ACTIVE', nachher: 'CANCELLED', trigger: 'cancellationConfirmation-Upload' },
customerId: contract.customerId,
});
}
res.json({
success: true,
data: {
@@ -633,7 +592,6 @@ async function handleContractDocumentDelete(
) {
try {
const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
const contract = await prisma.contract.findUnique({ where: { id: contractId } });
if (!contract) {
+4 -225
View File
@@ -1,49 +1,8 @@
import prisma from '../lib/prisma.js';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import { JwtPayload } from '../types/index.js';
import { encrypt, decrypt } from '../utils/encryption.js';
import { sendEmail, SmtpCredentials } from './smtpService.js';
import { getSystemEmailCredentials } from './emailProvider/emailProviderService.js';
// Bcrypt-Cost-Faktor: 12 = OWASP-empfohlen (Stand 2026), ca. 250 ms pro Hash.
// Bestehende Hashes mit Faktor 10 bleiben gültig (bcrypt kodiert den Faktor im Hash).
const BCRYPT_COST = 12;
// Dummy-Hash mit Cost 12 für Timing-Attack-Schutz: bei nicht-existierendem User
// führen wir trotzdem ein bcrypt.compare() durch, damit die Antwortzeit nicht
// verrät, ob die E-Mail existiert. Konstanter Hash hat keine Bedeutung außer
// dem Timing-Angleich.
const DUMMY_BCRYPT_HASH = '$2a$12$CwTycUXWue0Thq9StjUM0uJ8gQKwqKjq8lZ3TZ9qg8aJ0A9hPn4Wy';
/**
* Upgrade eines bestehenden Passwort-Hashes auf aktuellen BCRYPT_COST.
* Wird nach erfolgreichem Login aufgerufen. Alte User (z.B. admin mit Cost 10
* aus der Installation) werden so lazy auf Cost 12 migriert damit sich die
* Antwortzeit beim Login der Dummy-Zeit bei ungültigen Usern angleicht.
*/
async function maybeUpgradePasswordHash(
table: 'user' | 'customer',
id: number,
plaintextPassword: string,
currentHash: string,
): Promise<void> {
const match = currentHash.match(/^\$2[aby]\$(\d+)\$/);
const currentCost = match ? parseInt(match[1], 10) : 0;
if (currentCost === BCRYPT_COST) return;
try {
const newHash = await bcrypt.hash(plaintextPassword, BCRYPT_COST);
if (table === 'user') {
await prisma.user.update({ where: { id }, data: { password: newHash } });
} else {
await prisma.customer.update({ where: { id }, data: { portalPasswordHash: newHash } });
}
} catch (err) {
// Nicht kritisch Login war erfolgreich, Rehash kann beim nächsten Login nachgeholt werden
console.warn('[maybeUpgradePasswordHash] Fehler beim Rehash:', err);
}
}
// Mitarbeiter-Login
export async function login(email: string, password: string) {
@@ -67,9 +26,6 @@ export async function login(email: string, password: string) {
});
if (!user || !user.isActive) {
// Timing-Attack-Schutz: Dummy-bcrypt-compare damit die Antwortzeit bei
// nicht-existierendem/deaktiviertem User der eines gültigen Users entspricht.
await bcrypt.compare(password, DUMMY_BCRYPT_HASH);
throw new Error('Ungültige Anmeldedaten');
}
@@ -78,10 +34,6 @@ export async function login(email: string, password: string) {
throw new Error('Ungültige Anmeldedaten');
}
// Lazy-Upgrade: ältere Cost-10-Hashes auf aktuellen BCRYPT_COST rehashen.
// Async, nicht blockierend für die Response.
maybeUpgradePasswordHash('user', user.id, password, user.password).catch(() => {});
// Collect all permissions from all roles
const permissions = new Set<string>();
for (const userRole of user.roles) {
@@ -100,7 +52,7 @@ export async function login(email: string, password: string) {
isCustomerPortal: false,
};
const token = jwt.sign(payload, process.env.JWT_SECRET as string, {
const token = jwt.sign(payload, process.env.JWT_SECRET || 'fallback-secret', {
expiresIn: (process.env.JWT_EXPIRES_IN || '7d') as jwt.SignOptions['expiresIn'],
});
@@ -148,8 +100,6 @@ export async function customerLogin(email: string, password: string) {
if (!customer || !customer.portalEnabled || !customer.portalPasswordHash) {
console.log('[CustomerLogin] Abbruch: Kunde nicht gefunden oder Portal nicht aktiviert');
// Timing-Attack-Schutz (siehe login())
await bcrypt.compare(password, DUMMY_BCRYPT_HASH);
throw new Error('Ungültige Anmeldedaten');
}
@@ -160,9 +110,6 @@ export async function customerLogin(email: string, password: string) {
throw new Error('Ungültige Anmeldedaten');
}
// Lazy-Upgrade analog zu Mitarbeiter-Login
maybeUpgradePasswordHash('customer', customer.id, password, customer.portalPasswordHash).catch(() => {});
// Letzte Anmeldung aktualisieren
await prisma.customer.update({
where: { id: customer.id },
@@ -188,7 +135,7 @@ export async function customerLogin(email: string, password: string) {
representedCustomerIds,
};
const token = jwt.sign(payload, process.env.JWT_SECRET as string, {
const token = jwt.sign(payload, process.env.JWT_SECRET || 'fallback-secret', {
expiresIn: (process.env.JWT_EXPIRES_IN || '7d') as jwt.SignOptions['expiresIn'],
});
@@ -218,7 +165,7 @@ export async function customerLogin(email: string, password: string) {
export async function setCustomerPortalPassword(customerId: number, password: string) {
console.log('[SetPortalPassword] Setze Passwort für Kunde:', customerId);
const hashedPassword = await bcrypt.hash(password, BCRYPT_COST);
const hashedPassword = await bcrypt.hash(password, 10);
const encryptedPassword = encrypt(password);
console.log('[SetPortalPassword] Hash erstellt, Länge:', hashedPassword.length);
@@ -261,7 +208,7 @@ export async function createUser(data: {
roleIds: number[];
customerId?: number;
}) {
const hashedPassword = await bcrypt.hash(data.password, BCRYPT_COST);
const hashedPassword = await bcrypt.hash(data.password, 10);
const user = await prisma.user.create({
data: {
@@ -392,171 +339,3 @@ export async function getCustomerPortalUser(customerId: number) {
})),
};
}
// ==================== PASSWORT-RESET ====================
const RESET_TOKEN_EXPIRY_HOURS = 2;
function generateResetToken(): string {
return crypto.randomBytes(32).toString('hex');
}
function getPublicUrl(): string {
return process.env.PUBLIC_URL || 'http://localhost:5173';
}
/**
* Passwort-Reset-Link per Email senden.
* Findet User/Customer per Email. Wirft keinen Error wenn nicht gefunden
* (Schutz vor User-Enumeration Caller gibt immer success zurück).
*/
export async function requestPasswordReset(email: string, userType: 'admin' | 'portal'): Promise<void> {
const token = generateResetToken();
const expiresAt = new Date(Date.now() + RESET_TOKEN_EXPIRY_HOURS * 60 * 60 * 1000);
let recipient: { email: string; firstName: string; lastName: string } | null = null;
if (userType === 'admin') {
const user = await prisma.user.findUnique({ where: { email } });
if (!user || !user.isActive) return;
await prisma.user.update({
where: { id: user.id },
data: {
passwordResetToken: token,
passwordResetExpiresAt: expiresAt,
},
});
recipient = { email: user.email, firstName: user.firstName, lastName: user.lastName };
} else {
const customer = await prisma.customer.findUnique({ where: { portalEmail: email } });
if (!customer || !customer.portalEnabled) return;
await prisma.customer.update({
where: { id: customer.id },
data: {
portalPasswordResetToken: token,
portalPasswordResetExpiresAt: expiresAt,
},
});
recipient = {
email: customer.portalEmail!,
firstName: customer.firstName,
lastName: customer.lastName,
};
}
if (!recipient) return;
// Reset-Link + Email senden
const resetUrl = `${getPublicUrl()}/password-reset?token=${token}&type=${userType}`;
const systemEmail = await getSystemEmailCredentials();
if (!systemEmail) {
console.warn(
`[passwordReset] Kein System-E-Mail konfiguriert Reset-Link für ${recipient.email}: ${resetUrl}`,
);
return;
}
const credentials: SmtpCredentials = {
host: systemEmail.smtpServer,
port: systemEmail.smtpPort,
user: systemEmail.emailAddress,
password: systemEmail.password,
encryption: systemEmail.smtpEncryption,
allowSelfSignedCerts: systemEmail.allowSelfSignedCerts,
};
const html = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #1e40af;">Passwort zurücksetzen</h2>
<p>Hallo ${recipient.firstName} ${recipient.lastName},</p>
<p>
Sie haben angefordert, Ihr Passwort zurückzusetzen. Klicken Sie auf den folgenden
Button, um ein neues Passwort zu vergeben. Der Link ist ${RESET_TOKEN_EXPIRY_HOURS} Stunden gültig.
</p>
<p style="text-align: center; margin: 32px 0;">
<a href="${resetUrl}" style="background-color: #2563eb; color: #ffffff; padding: 14px 32px; text-decoration: none; border-radius: 8px; font-weight: bold; font-size: 16px; display: inline-block;">
Neues Passwort vergeben
</a>
</p>
<p style="color: #6b7280; font-size: 14px;">
Alternativ können Sie diesen Link in Ihren Browser kopieren:<br>
<a href="${resetUrl}" style="color: #2563eb; word-break: break-all;">${resetUrl}</a>
</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 24px 0;">
<p style="color: #9ca3af; font-size: 12px;">
Haben Sie diesen Reset nicht angefordert? Dann ignorieren Sie diese E-Mail einfach
Ihr Passwort bleibt unverändert.
</p>
</div>
`;
await sendEmail(
credentials,
systemEmail.emailAddress,
{
to: recipient.email,
subject: 'Passwort zurücksetzen',
html,
},
{
context: 'password-reset',
triggeredBy: 'self-service',
},
);
}
/**
* Passwort-Reset bestätigen: Token prüfen, Passwort setzen, Token löschen.
* Invalidiert alle bestehenden JWT-Sessions des Users.
*/
export async function confirmPasswordReset(token: string, newPassword: string): Promise<void> {
// Erst beim User suchen
const user = await prisma.user.findUnique({ where: { passwordResetToken: token } });
if (user) {
if (!user.passwordResetExpiresAt || user.passwordResetExpiresAt < new Date()) {
throw new Error('Der Link ist abgelaufen. Bitte fordere einen neuen an.');
}
const hash = await bcrypt.hash(newPassword, BCRYPT_COST);
await prisma.user.update({
where: { id: user.id },
data: {
password: hash,
passwordResetToken: null,
passwordResetExpiresAt: null,
// Alle bestehenden Sessions kicken
tokenInvalidatedAt: new Date(),
},
});
return;
}
// Sonst beim Customer (Portal)
const customer = await prisma.customer.findUnique({ where: { portalPasswordResetToken: token } });
if (customer) {
if (!customer.portalPasswordResetExpiresAt || customer.portalPasswordResetExpiresAt < new Date()) {
throw new Error('Der Link ist abgelaufen. Bitte fordere einen neuen an.');
}
const hash = await bcrypt.hash(newPassword, BCRYPT_COST);
await prisma.customer.update({
where: { id: customer.id },
data: {
portalPasswordHash: hash,
portalPasswordEncrypted: encrypt(newPassword),
portalPasswordResetToken: null,
portalPasswordResetExpiresAt: null,
// Alle bestehenden Portal-Sessions kicken
portalTokenInvalidatedAt: new Date(),
},
});
return;
}
throw new Error('Ungültiger oder bereits verwendeter Link.');
}
+3 -165
View File
@@ -239,16 +239,6 @@ export async function createBackup(): Promise<BackupResult> {
{ name: 'Address', query: () => prisma.address.findMany() },
{ name: 'BankCard', query: () => prisma.bankCard.findMany() },
{ name: 'IdentityDocument', query: () => prisma.identityDocument.findMany() },
// Neue Tabellen
{ name: 'PdfTemplate', query: () => prisma.pdfTemplate.findMany() },
{ name: 'ContractMeter', query: () => prisma.contractMeter.findMany() },
{ name: 'ContractDocument', query: () => prisma.contractDocument.findMany() },
{ name: 'RepresentativeAuthorization', query: () => prisma.representativeAuthorization.findMany() },
{ name: 'CustomerConsent', query: () => prisma.customerConsent.findMany() },
{ name: 'DataDeletionRequest', query: () => prisma.dataDeletionRequest.findMany() },
{ name: 'EmailLog', query: () => prisma.emailLog.findMany() },
{ name: 'AuditRetentionPolicy', query: () => prisma.auditRetentionPolicy.findMany() },
{ name: 'AuditLog', query: () => prisma.auditLog.findMany() },
];
let totalRecords = 0;
@@ -307,10 +297,6 @@ export async function restoreBackup(backupName: string): Promise<RestoreResult>
// WICHTIG: Alle Tabellen vor dem Restore leeren, damit keine alten Daten übrig bleiben
console.log('[Restore] Lösche alle bestehenden Daten...');
// Logs & Audit zuerst (hängen an allem)
await prisma.auditLog.deleteMany({});
await prisma.emailLog.deleteMany({});
// Detail-Tabellen
await prisma.carInsuranceDetails.deleteMany({});
await prisma.tvContractDetails.deleteMany({});
@@ -323,21 +309,12 @@ export async function restoreBackup(backupName: string): Promise<RestoreResult>
await prisma.meterReading.deleteMany({});
await prisma.contractHistoryEntry.deleteMany({});
// Neue Contract-bezogene Tabellen
await prisma.contractDocument.deleteMany({});
await prisma.contractMeter.deleteMany({});
// E-Mail & Verträge
await prisma.cachedEmail.deleteMany({});
await prisma.contractTaskSubtask.deleteMany({});
await prisma.contractTask.deleteMany({});
await prisma.contract.deleteMany({});
// DSGVO + Vollmachten (abhängig von Customer)
await prisma.representativeAuthorization.deleteMany({});
await prisma.customerConsent.deleteMany({});
await prisma.dataDeletionRequest.deleteMany({});
// Kunden-bezogene Daten
await prisma.stressfreiEmail.deleteMany({});
await prisma.meter.deleteMany({});
@@ -351,8 +328,7 @@ export async function restoreBackup(backupName: string): Promise<RestoreResult>
await prisma.user.deleteMany({});
await prisma.customer.deleteMany({});
// Stammdaten & Kataloge
await prisma.pdfTemplate.deleteMany({});
// Stammdaten
await prisma.tariff.deleteMany({});
await prisma.provider.deleteMany({});
await prisma.rolePermission.deleteMany({});
@@ -364,7 +340,6 @@ export async function restoreBackup(backupName: string): Promise<RestoreResult>
await prisma.contractCategory.deleteMany({});
await prisma.emailProviderConfig.deleteMany({});
await prisma.appSetting.deleteMany({});
await prisma.auditRetentionPolicy.deleteMany({});
console.log('[Restore] Alle Daten gelöscht, starte Wiederherstellung...');
@@ -778,115 +753,6 @@ export async function restoreBackup(backupName: string): Promise<RestoreResult>
}
},
},
// Neue Tabellen
{
name: 'PdfTemplate',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.pdfTemplate.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'ContractMeter',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.contractMeter.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'ContractDocument',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.contractDocument.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'RepresentativeAuthorization',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.representativeAuthorization.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'CustomerConsent',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.customerConsent.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'DataDeletionRequest',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.dataDeletionRequest.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'EmailLog',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.emailLog.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'AuditRetentionPolicy',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.auditRetentionPolicy.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
{
name: 'AuditLog',
restore: async (data: any[]) => {
for (const item of data) {
await prisma.auditLog.upsert({
where: { id: item.id },
update: convertDates(item),
create: convertDates(item),
});
}
},
},
];
let totalRestored = 0;
@@ -1007,36 +873,8 @@ export async function uploadBackupZip(zipBuffer: Buffer): Promise<BackupResult>
const finalBackupName = path.basename(finalBackupDir);
// ZIP entpacken mit Schutz gegen Zip-Slip (../../etc/passwd Angriff).
// Jeder Eintragspfad muss innerhalb von finalBackupDir bleiben.
const absBackupDir = path.resolve(finalBackupDir);
fs.mkdirSync(absBackupDir, { recursive: true });
for (const entry of entries) {
// Pfade mit absoluten Pfaden oder Traversal ablehnen
const entryName = entry.entryName;
if (entryName.includes('\0') || path.isAbsolute(entryName)) {
return { success: false, error: `Ungültiger Eintrag im ZIP: ${entryName}` };
}
const targetPath = path.resolve(absBackupDir, entryName);
// Zip-Slip-Check: aufgelöster Pfad muss im Backup-Verzeichnis liegen
if (!targetPath.startsWith(absBackupDir + path.sep) && targetPath !== absBackupDir) {
return {
success: false,
error: `Sicherheitsverletzung im ZIP: Pfad "${entryName}" zeigt außerhalb des Backup-Verzeichnisses`,
};
}
if (entry.isDirectory) {
fs.mkdirSync(targetPath, { recursive: true });
} else {
// Zielverzeichnis sicherstellen
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
// Datei schreiben
fs.writeFileSync(targetPath, entry.getData());
}
}
// ZIP extrahieren
zip.extractAllTo(finalBackupDir, true);
return { success: true, backupName: finalBackupName };
} catch (error: any) {
+3 -137
View File
@@ -140,9 +140,6 @@ export interface MyBirthdayCheck {
isToday: boolean;
daysAgo: number; // 0 = heute, >0 = x Tage her
firstName: string;
lastName: string;
salutation: string | null;
useInformalAddress: boolean;
age: number;
}
@@ -156,9 +153,6 @@ export async function checkMyBirthday(customerId: number): Promise<MyBirthdayChe
where: { id: customerId },
select: {
firstName: true,
lastName: true,
salutation: true,
useInformalAddress: true,
birthDate: true,
lastBirthdayGreetingYear: true,
},
@@ -166,20 +160,13 @@ export async function checkMyBirthday(customerId: number): Promise<MyBirthdayChe
if (!customer?.birthDate) return null;
const baseInfo = {
firstName: customer.firstName,
lastName: customer.lastName,
salutation: customer.salutation,
useInformalAddress: customer.useInformalAddress,
};
const today = new Date();
today.setHours(0, 0, 0, 0);
const thisYear = today.getFullYear();
// Schon dieses Jahr angezeigt?
if (customer.lastBirthdayGreetingYear === thisYear) {
return { show: false, isToday: false, daysAgo: 0, ...baseInfo, age: 0 };
return { show: false, isToday: false, daysAgo: 0, firstName: customer.firstName, age: 0 };
}
const birthday = new Date(thisYear, customer.birthDate.getMonth(), customer.birthDate.getDate());
@@ -190,7 +177,7 @@ export async function checkMyBirthday(customerId: number): Promise<MyBirthdayChe
// Nur wenn heute oder in den letzten 7 Tagen (diff: 07)
if (diff < 0 || diff > 7) {
return { show: false, isToday: false, daysAgo: 0, ...baseInfo, age: 0 };
return { show: false, isToday: false, daysAgo: 0, firstName: customer.firstName, age: 0 };
}
const age = calculateAge(customer.birthDate, today);
@@ -199,7 +186,7 @@ export async function checkMyBirthday(customerId: number): Promise<MyBirthdayChe
show: true,
isToday: diff === 0,
daysAgo: diff,
...baseInfo,
firstName: customer.firstName,
age,
};
}
@@ -214,124 +201,3 @@ export async function acknowledgeBirthdayGreeting(customerId: number): Promise<v
data: { lastBirthdayGreetingYear: thisYear },
});
}
/**
* Setzt den Gruß-Marker zurück, damit das Modal beim nächsten Login wieder erscheint.
* (Für Mitarbeiter nützlich zum Debuggen und als Fallback wenn etwas schief ging.)
*/
export async function resetBirthdayGreeting(customerId: number): Promise<void> {
await prisma.customer.update({
where: { id: customerId },
data: { lastBirthdayGreetingYear: null },
});
}
/**
* Generiert den persönlichen Geburtstagsgruß-Text (Du/Sie-abhängig).
*/
export function buildBirthdayGreetingText(
customer: {
firstName: string;
lastName: string;
salutation: string | null;
useInformalAddress: boolean;
},
age: number,
): { subject: string; plain: string; html: string } {
const name = customer.useInformalAddress
? customer.firstName
: [customer.salutation, customer.lastName].filter(Boolean).join(' ') || customer.firstName;
const du = customer.useInformalAddress;
const pronoun = du ? 'dir' : 'Ihnen';
const possessive = du ? 'deinem' : 'Ihrem';
const yourLower = du ? 'dein' : 'Ihr';
const subject = du
? `Alles Gute zum Geburtstag, ${customer.firstName}! 🎉`
: 'Herzlichen Glückwunsch zum Geburtstag 🎉';
// Plain-Text ohne Emojis, damit WhatsApp/Telegram/Signal-URL-Handler nicht stolpern
const plain = [
`Herzlichen Glückwunsch, ${name}!`,
'',
age > 0
? `Alles Gute zu ${possessive} ${age}. Geburtstag!`
: `Alles Gute zu ${possessive} Geburtstag!`,
'',
`Wir wünschen ${pronoun} einen wunderschönen Tag und alles Gute für ${yourLower} neues Lebensjahr.`,
'',
'Herzliche Grüße',
'Hacker-Net Telekommunikation',
].join('\n');
const html = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: linear-gradient(135deg, #ec4899 0%, #8b5cf6 50%, #6366f1 100%); padding: 40px 20px; text-align: center; border-radius: 12px 12px 0 0;">
<div style="font-size: 64px; margin-bottom: 8px;">🎉🎂🎈</div>
</div>
<div style="padding: 32px; background: #ffffff; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 12px 12px;">
<h2 style="color: #1f2937; margin-top: 0;">Herzlichen Glückwunsch, ${name}!</h2>
<p style="color: #4b5563; font-size: 16px; line-height: 1.6;">
${age > 0 ? `Alles Gute zu ${possessive} <strong>${age}. Geburtstag</strong>!` : `Alles Gute zu ${possessive} Geburtstag!`}
</p>
<p style="color: #6b7280; font-size: 14px; line-height: 1.6;">
Wir wünschen ${pronoun} einen wunderschönen Tag und alles Gute für ${yourLower} neues Lebensjahr. 🌟
</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 24px 0;">
<p style="color: #9ca3af; font-size: 12px; margin: 0;">
Herzliche Grüße<br>
<strong>Hacker-Net Telekommunikation</strong><br>
Am Wunderburgpark 5b, 26135 Oldenburg<br>
info@hacker-net.de
</p>
</div>
</div>
`;
return { subject, plain, html };
}
/**
* Lädt die für den Gruß benötigten Kundendaten inkl. aktuellem Alter heute.
*/
export async function getBirthdayGreetingData(customerId: number): Promise<{
firstName: string;
lastName: string;
salutation: string | null;
useInformalAddress: boolean;
email: string | null;
phone: string | null;
mobile: string | null;
age: number;
} | null> {
const c = await prisma.customer.findUnique({
where: { id: customerId },
select: {
firstName: true,
lastName: true,
salutation: true,
useInformalAddress: true,
email: true,
phone: true,
mobile: true,
birthDate: true,
},
});
if (!c?.birthDate) return null;
const today = new Date();
today.setHours(0, 0, 0, 0);
const age = calculateAge(c.birthDate, today);
return {
firstName: c.firstName,
lastName: c.lastName,
salutation: c.salutation,
useInformalAddress: c.useInformalAddress,
email: c.email,
phone: c.phone,
mobile: c.mobile,
age,
};
}
@@ -1,169 +0,0 @@
/**
* Scheduler für automatische Geburtstagsgrüße.
*
* Läuft täglich um 08:00 Uhr und sendet Grüße an alle Kunden mit:
* - Geburtstag = heute
* - autoBirthdayGreeting = true
* - autoBirthdayChannel ist gesetzt (aktuell nur 'email' automatisiert)
* - lastBirthdayGreetingYear != aktuelles Jahr (verhindert Doppel-Versand)
*/
import cron from 'node-cron';
import prisma from '../lib/prisma.js';
import { sendEmail, SmtpCredentials } from './smtpService.js';
import { getSystemEmailCredentials } from './emailProvider/emailProviderService.js';
import * as birthdayService from './birthday.service.js';
async function runDailyBirthdayGreetings(): Promise<void> {
const today = new Date();
today.setHours(0, 0, 0, 0);
const thisYear = today.getFullYear();
const month = today.getMonth() + 1; // Prisma-Raw-SQL ist 1-indexed
const day = today.getDate();
console.log(
`[BirthdayScheduler] Suche Kunden mit Geburtstag ${day}.${month}., Auto-Versand aktiv …`,
);
// Kunden mit heutigem Geburtstag + Auto-Versand + dieses Jahr noch nicht gesendet
const candidates = await prisma.$queryRaw<
Array<{
id: number;
firstName: string;
lastName: string;
email: string | null;
salutation: string | null;
useInformalAddress: boolean;
birthDate: Date;
autoBirthdayChannel: string | null;
}>
>`
SELECT id, firstName, lastName, email, salutation, useInformalAddress, birthDate, autoBirthdayChannel
FROM Customer
WHERE autoBirthdayGreeting = 1
AND birthDate IS NOT NULL
AND MONTH(birthDate) = ${month}
AND DAY(birthDate) = ${day}
AND (lastBirthdayGreetingYear IS NULL OR lastBirthdayGreetingYear != ${thisYear})
`;
if (candidates.length === 0) {
console.log('[BirthdayScheduler] Keine passenden Kunden heute.');
return;
}
console.log(`[BirthdayScheduler] ${candidates.length} Kunde(n) gefunden sende Grüße.`);
// System-E-Mail-Credentials einmal laden
const systemEmail = await getSystemEmailCredentials();
if (!systemEmail) {
console.error(
'[BirthdayScheduler] Keine System-E-Mail konfiguriert kann keine Grüße versenden.',
);
return;
}
const smtpCreds: SmtpCredentials = {
host: systemEmail.smtpServer,
port: systemEmail.smtpPort,
user: systemEmail.emailAddress,
password: systemEmail.password,
encryption: systemEmail.smtpEncryption,
allowSelfSignedCerts: systemEmail.allowSelfSignedCerts,
};
let sent = 0;
let skipped = 0;
for (const c of candidates) {
const channel = c.autoBirthdayChannel || 'email';
// Aktuell nur Email automatisch Messenger brauchen Browser-Klick
if (channel !== 'email') {
console.log(
`[BirthdayScheduler] Kunde #${c.id} (${c.firstName} ${c.lastName}): Kanal "${channel}" nicht automatisierbar, übersprungen.`,
);
skipped++;
continue;
}
if (!c.email) {
console.log(
`[BirthdayScheduler] Kunde #${c.id} (${c.firstName} ${c.lastName}): keine E-Mail hinterlegt, übersprungen.`,
);
skipped++;
continue;
}
const age = thisYear - new Date(c.birthDate).getFullYear();
const { subject, html } = birthdayService.buildBirthdayGreetingText(
{
firstName: c.firstName,
lastName: c.lastName,
salutation: c.salutation,
useInformalAddress: c.useInformalAddress,
},
age,
);
try {
const result = await sendEmail(
smtpCreds,
systemEmail.emailAddress,
{ to: c.email, subject, html },
{ context: 'birthday-greeting-auto', customerId: c.id, triggeredBy: 'cron' },
);
if (result.success) {
// Marker setzen damit nächstes Jahr wieder läuft, dieses Jahr aber nicht nochmal
await prisma.customer.update({
where: { id: c.id },
data: { lastBirthdayGreetingYear: thisYear },
});
sent++;
console.log(
`[BirthdayScheduler] ✓ Kunde #${c.id} (${c.firstName} ${c.lastName}): Gruß gesendet.`,
);
} else {
console.error(
`[BirthdayScheduler] ✗ Kunde #${c.id}: Sendfehler: ${result.error}`,
);
skipped++;
}
} catch (err) {
console.error(`[BirthdayScheduler] ✗ Kunde #${c.id}: Exception:`, err);
skipped++;
}
}
console.log(
`[BirthdayScheduler] Fertig: ${sent} versendet, ${skipped} übersprungen von ${candidates.length} Kandidaten.`,
);
}
/**
* Scheduler starten. Läuft täglich um 08:00 in lokaler Server-Zeit.
* Zusätzlich: ein Test-Lauf 30 Sekunden nach Server-Start, aber nur wenn heute schon jemand Geburtstag hat
* (sonst passiert eh nichts). So können wir bei Ausfall am Tag X direkt beim nächsten Boot nachholen.
*/
export function startBirthdayScheduler(): void {
// Täglich um 08:00
cron.schedule('0 8 * * *', () => {
runDailyBirthdayGreetings().catch((err) =>
console.error('[BirthdayScheduler] Daily run failed:', err),
);
});
// Einmal 30 Sekunden nach Start (Catch-up bei Ausfall)
setTimeout(() => {
runDailyBirthdayGreetings().catch((err) =>
console.error('[BirthdayScheduler] Catch-up run failed:', err),
);
}, 30_000);
console.log('[BirthdayScheduler] Gestartet täglich um 08:00 + Catch-up nach 30s');
}
/**
* Für manuelles Triggern (z.B. aus Debug-Endpoint).
*/
export { runDailyBirthdayGreetings };
@@ -1,149 +0,0 @@
/**
* Scheduler für automatische Vertrags-Status-Übergänge.
*
* Einmal täglich um 02:00: alle Verträge mit status=ACTIVE und
* endDate < heute werden auf EXPIRED umgestellt (+ Audit-Log).
*
* Läuft zusätzlich 60 Sekunden nach Server-Start als Catch-up falls
* der Prozess zum 02:00-Slot neu gestartet wurde.
*/
import cron from 'node-cron';
import prisma from '../lib/prisma.js';
import { createAuditLog, logChange } from './audit.service.js';
async function runExpireCheck(): Promise<void> {
const today = new Date();
today.setHours(0, 0, 0, 0);
const expiring = await prisma.contract.findMany({
where: {
status: 'ACTIVE',
endDate: { not: null, lt: today },
},
select: {
id: true,
contractNumber: true,
customerId: true,
endDate: true,
},
});
if (expiring.length === 0) {
console.log('[ContractStatusScheduler] Keine abgelaufenen Verträge.');
return;
}
console.log(`[ContractStatusScheduler] ${expiring.length} Vertrag/Verträge auf EXPIRED setzen.`);
for (const c of expiring) {
try {
await prisma.contract.update({
where: { id: c.id },
data: { status: 'EXPIRED' },
});
await createAuditLog({
userEmail: 'system',
userRole: 'System',
action: 'UPDATE',
resourceType: 'Contract',
resourceId: c.id.toString(),
resourceLabel: `Vertrag ${c.contractNumber} automatisch auf EXPIRED gesetzt (Laufzeit überschritten)`,
endpoint: 'scheduler:contract-status',
httpMethod: 'SYSTEM',
ipAddress: 'localhost',
dataSubjectId: c.customerId,
changesBefore: { status: 'ACTIVE' },
changesAfter: { status: 'EXPIRED', endDate: c.endDate?.toISOString() },
});
} catch (err) {
console.error(`[ContractStatusScheduler] Fehler bei Vertrag #${c.id}:`, err);
}
}
console.log('[ContractStatusScheduler] Fertig.');
}
export function startContractStatusScheduler(): void {
// Täglich um 02:00 Uhr (Server-Zeit)
cron.schedule('0 2 * * *', () => {
runExpireCheck().catch((err) =>
console.error('[ContractStatusScheduler] Daily run failed:', err),
);
});
// Catch-up 60 Sekunden nach Start
setTimeout(() => {
runExpireCheck().catch((err) =>
console.error('[ContractStatusScheduler] Catch-up run failed:', err),
);
}, 60_000);
console.log('[ContractStatusScheduler] Gestartet täglich um 02:00 + Catch-up nach 60s');
}
export { runExpireCheck };
/**
* Wird nach einem ContractDocument-Upload aufgerufen. Wenn der Typ eine
* Lieferbestätigung ist:
* - Contract.status von DRAFT auf ACTIVE setzen (falls DRAFT)
* - Contract.startDate auf deliveryDate (oder heute) setzen, falls noch leer
*
* Schreibweise "Lieferbestätigung" stammt aus dem Frontend-Dropdown
* (SaveAttachmentModal / ContractDetail). Vergleich case-insensitive +
* getrimmt zur Robustheit.
*/
export async function maybeActivateOnDeliveryConfirmation(
contractId: number,
documentType: string,
req: unknown,
deliveryDate?: Date | string | null,
): Promise<void> {
if (!documentType || typeof documentType !== 'string') return;
if (documentType.trim().toLowerCase() !== 'lieferbestätigung') return;
const contract = await prisma.contract.findUnique({
where: { id: contractId },
select: { status: true, contractNumber: true, customerId: true, startDate: true },
});
if (!contract) return;
// deliveryDate parsen, Fallback auf heute
let parsedDate: Date | null = null;
if (deliveryDate) {
const parsed = new Date(deliveryDate);
if (!isNaN(parsed.getTime())) parsedDate = parsed;
}
const effectiveDate = parsedDate || new Date();
const updateData: Record<string, unknown> = {};
const changes: Record<string, { vorher: unknown; nachher: unknown }> = {};
if (contract.status === 'DRAFT') {
updateData.status = 'ACTIVE';
changes.status = { vorher: 'DRAFT', nachher: 'ACTIVE' };
}
if (!contract.startDate) {
updateData.startDate = effectiveDate;
changes.startDate = { vorher: null, nachher: effectiveDate.toISOString().split('T')[0] };
}
if (Object.keys(updateData).length === 0) return;
await prisma.contract.update({
where: { id: contractId },
data: updateData,
});
await logChange({
req,
action: 'UPDATE',
resourceType: 'Contract',
resourceId: contractId.toString(),
label: `Vertrag ${contract.contractNumber} automatisch aktualisiert (Lieferbestätigung hochgeladen)`,
details: { ...changes, trigger: 'Lieferbestätigung-Upload' },
customerId: contract.customerId,
});
}
-3
View File
@@ -136,7 +136,6 @@ export async function updateCustomer(
data: {
type?: CustomerType;
salutation?: string;
useInformalAddress?: boolean;
firstName?: string;
lastName?: string;
companyName?: string;
@@ -149,8 +148,6 @@ export async function updateCustomer(
businessRegistration?: string;
commercialRegister?: string;
notes?: string;
autoBirthdayGreeting?: boolean;
autoBirthdayChannel?: string | null;
}
) {
return prisma.customer.update({
@@ -74,30 +74,11 @@ export interface CreateProviderConfigData {
// System-E-Mail
systemEmailAddress?: string;
systemEmailPassword?: string;
// UI-Label für Kunden-E-Mail-Adressen (z.B. "Stressfrei-Wechseln", "Meine-Firma")
customerEmailLabel?: string;
isActive?: boolean;
isDefault?: boolean;
}
// Validiert Domain-Format (z.B. stressfrei-wechseln.de, mail.beispiel.com)
const DOMAIN_REGEX = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
function validateDomain(domain: string | undefined): string {
if (!domain || !domain.trim()) {
throw new Error('Domain ist erforderlich');
}
const normalized = domain.trim().toLowerCase();
if (!DOMAIN_REGEX.test(normalized)) {
throw new Error(`Ungültige Domain: "${domain}". Format: name.tld (z.B. meine-firma.de)`);
}
return normalized;
}
export async function createProviderConfig(data: CreateProviderConfigData) {
// Domain validieren
const validatedDomain = validateDomain(data.domain);
// Falls isDefault=true, alle anderen auf false setzen
if (data.isDefault) {
await prisma.emailProviderConfig.updateMany({
@@ -119,14 +100,13 @@ export async function createProviderConfig(data: CreateProviderConfigData) {
apiKey: data.apiKey || null,
username: data.username || null,
passwordEncrypted,
domain: validatedDomain,
domain: data.domain,
defaultForwardEmail: data.defaultForwardEmail || null,
imapEncryption: data.imapEncryption ?? 'SSL',
smtpEncryption: data.smtpEncryption ?? 'SSL',
allowSelfSignedCerts: data.allowSelfSignedCerts ?? false,
systemEmailAddress: data.systemEmailAddress || null,
systemEmailPasswordEncrypted,
customerEmailLabel: data.customerEmailLabel || null,
isActive: data.isActive ?? true,
isDefault: data.isDefault ?? false,
},
@@ -152,14 +132,13 @@ export async function updateProviderConfig(
if (data.apiUrl !== undefined) updateData.apiUrl = data.apiUrl;
if (data.apiKey !== undefined) updateData.apiKey = data.apiKey || null;
if (data.username !== undefined) updateData.username = data.username || null;
if (data.domain !== undefined) updateData.domain = validateDomain(data.domain);
if (data.domain !== undefined) updateData.domain = data.domain;
if (data.defaultForwardEmail !== undefined)
updateData.defaultForwardEmail = data.defaultForwardEmail || null;
if (data.imapEncryption !== undefined) updateData.imapEncryption = data.imapEncryption;
if (data.smtpEncryption !== undefined) updateData.smtpEncryption = data.smtpEncryption;
if (data.allowSelfSignedCerts !== undefined) updateData.allowSelfSignedCerts = data.allowSelfSignedCerts;
if (data.systemEmailAddress !== undefined) updateData.systemEmailAddress = data.systemEmailAddress || null;
if (data.customerEmailLabel !== undefined) updateData.customerEmailLabel = data.customerEmailLabel?.trim() || null;
if (data.isActive !== undefined) updateData.isActive = data.isActive;
if (data.isDefault !== undefined) updateData.isDefault = data.isDefault;
@@ -492,39 +471,6 @@ export async function getProviderDomain(): Promise<string | null> {
return config?.domain || null;
}
/**
* Label aus der Domain ableiten, z.B. "stressfrei-wechseln.de" "Stressfrei-Wechseln".
* Nimmt den Hauptteil bis zum ersten Punkt, trennt an "-" und kapitalisiert jeden Teil.
*/
export function deriveLabelFromDomain(domain: string | null | undefined): string {
if (!domain) return 'Kunden-E-Mail';
const mainPart = domain.split('.')[0] || domain;
return mainPart
.split('-')
.map((s) => (s.length === 0 ? '' : s.charAt(0).toUpperCase() + s.slice(1)))
.join('-');
}
/**
* Öffentliche Provider-Einstellungen (Domain + Label) für UI.
* Kein auth-geschütztes Geheimnis, nur damit die Frontend-Labels stimmen.
*/
export async function getProviderPublicSettings(): Promise<{
domain: string | null;
customerEmailLabel: string;
customerEmailLabelIsCustom: boolean;
}> {
const config = await getActiveProviderConfig();
const domain = config?.domain ?? null;
const customLabel = config?.customerEmailLabel?.trim();
return {
domain,
customerEmailLabel: customLabel && customLabel.length > 0 ? customLabel : deriveLabelFromDomain(domain),
customerEmailLabelIsCustom: !!(customLabel && customLabel.length > 0),
};
}
// Provider-Instanz aus übergebener Config erstellen (für Tests mit ungespeicherten Daten)
function createProviderFromFormData(data: {
type: 'PLESK' | 'CPANEL' | 'DIRECTADMIN';
@@ -1,194 +0,0 @@
/**
* Factory-Defaults: Export + Import von Stammdaten-Katalogen.
* Enthält KEINE Kundendaten, Verträge, Dokumente oder Einstellungen
* nur reine Kataloge: Anbieter, Tarife, Kündigungsfristen, Laufzeiten,
* Vertragskategorien und PDF-Auftragsvorlagen.
*/
import fs from 'fs';
import path from 'path';
import archiver from 'archiver';
import prisma from '../lib/prisma.js';
export interface FactoryDefaultsManifest {
version: 1;
exportedAt: string;
counts: {
providers: number;
tariffs: number;
cancellationPeriods: number;
contractDurations: number;
contractCategories: number;
pdfTemplates: number;
};
}
export interface ProviderExport {
name: string;
portalUrl: string | null;
usernameFieldName: string | null;
passwordFieldName: string | null;
isActive: boolean;
tariffs: { name: string; isActive: boolean }[];
}
export interface PdfTemplateExport {
name: string;
description: string | null;
providerName: string | null;
originalName: string;
fieldMapping: any;
phoneFieldPrefix: string | null;
maxPhoneFields: number | null;
isActive: boolean;
// Datei wird separat im ZIP abgelegt unter pdf-templates/<name>.pdf
pdfFilename: string;
}
/**
* Sammelt alle Katalog-Daten aus der DB.
*/
export async function collectFactoryDefaults() {
const [providers, cancellationPeriods, contractDurations, contractCategories, pdfTemplates] =
await Promise.all([
prisma.provider.findMany({
include: { tariffs: { select: { name: true, isActive: true } } },
orderBy: { name: 'asc' },
}),
prisma.cancellationPeriod.findMany({ orderBy: { code: 'asc' } }),
prisma.contractDuration.findMany({ orderBy: { code: 'asc' } }),
prisma.contractCategory.findMany({ orderBy: { sortOrder: 'asc' } }),
prisma.pdfTemplate.findMany({ orderBy: { name: 'asc' } }),
]);
return {
providers: providers.map<ProviderExport>((p) => ({
name: p.name,
portalUrl: p.portalUrl,
usernameFieldName: p.usernameFieldName,
passwordFieldName: p.passwordFieldName,
isActive: p.isActive,
tariffs: p.tariffs.map((t) => ({ name: t.name, isActive: t.isActive })),
})),
cancellationPeriods: cancellationPeriods.map((c) => ({
code: c.code,
description: c.description,
isActive: c.isActive,
})),
contractDurations: contractDurations.map((d) => ({
code: d.code,
description: d.description,
isActive: d.isActive,
})),
contractCategories: contractCategories.map((c) => ({
code: c.code,
name: c.name,
icon: c.icon,
color: c.color,
sortOrder: c.sortOrder,
isActive: c.isActive,
})),
pdfTemplates: pdfTemplates.map<PdfTemplateExport>((t) => {
// fieldMapping ist im DB als String abgelegt zu Objekt parsen
let fieldMapping: any = {};
try {
fieldMapping = JSON.parse(t.fieldMapping);
} catch {
fieldMapping = {};
}
return {
name: t.name,
description: t.description,
providerName: t.providerName,
originalName: t.originalName,
fieldMapping,
phoneFieldPrefix: t.phoneFieldPrefix,
maxPhoneFields: t.maxPhoneFields,
isActive: t.isActive,
pdfFilename: sanitizeFilename(t.name) + path.extname(t.originalName || '.pdf'),
};
}),
};
}
function sanitizeFilename(name: string): string {
return name.replace(/[^a-zA-Z0-9äöüÄÖÜß_-]/g, '_').replace(/_+/g, '_');
}
/**
* Erstellt ein ZIP-Archiv mit allen Factory-Defaults.
* Die PDF-Dateien werden aus dem uploads-Verzeichnis gelesen.
*/
export async function exportFactoryDefaults(): Promise<Buffer> {
const data = await collectFactoryDefaults();
const manifest: FactoryDefaultsManifest = {
version: 1,
exportedAt: new Date().toISOString(),
counts: {
providers: data.providers.length,
tariffs: data.providers.reduce((sum, p) => sum + p.tariffs.length, 0),
cancellationPeriods: data.cancellationPeriods.length,
contractDurations: data.contractDurations.length,
contractCategories: data.contractCategories.length,
pdfTemplates: data.pdfTemplates.length,
},
};
return new Promise<Buffer>((resolve, reject) => {
const archive = archiver('zip', { zlib: { level: 9 } });
const chunks: Buffer[] = [];
archive.on('data', (chunk: Buffer) => chunks.push(chunk));
archive.on('end', () => resolve(Buffer.concat(chunks)));
archive.on('error', reject);
// JSON-Dateien vorbereiten
archive.append(JSON.stringify(manifest, null, 2), { name: 'manifest.json' });
archive.append(JSON.stringify(data.providers, null, 2), {
name: 'providers/providers.json',
});
archive.append(JSON.stringify(data.cancellationPeriods, null, 2), {
name: 'contract-meta/cancellation-periods.json',
});
archive.append(JSON.stringify(data.contractDurations, null, 2), {
name: 'contract-meta/contract-durations.json',
});
archive.append(JSON.stringify(data.contractCategories, null, 2), {
name: 'contract-meta/contract-categories.json',
});
archive.append(JSON.stringify(data.pdfTemplates, null, 2), {
name: 'pdf-templates/pdf-templates.json',
});
// PDF-Dateien physisch hinzufügen (Pfade aus DB laden)
const uploadsRoot = path.join(process.cwd(), 'uploads');
(async () => {
try {
const templateRows = await prisma.pdfTemplate.findMany({
select: { name: true, templatePath: true },
});
const pathByName = new Map(templateRows.map((r) => [r.name, r.templatePath]));
for (const t of data.pdfTemplates) {
const storedPath = pathByName.get(t.name);
if (!storedPath) continue;
const relPath = storedPath.startsWith('/uploads/')
? storedPath.substring('/uploads/'.length)
: storedPath;
const absPath = path.join(uploadsRoot, relPath);
if (fs.existsSync(absPath)) {
archive.file(absPath, { name: `pdf-templates/${t.pdfFilename}` });
} else {
console.warn(`[factoryDefaults] PDF-Datei nicht gefunden: ${absPath}`);
}
}
archive.finalize();
} catch (err) {
reject(err);
}
})();
});
}
@@ -1,126 +0,0 @@
/**
* Pfad Resource Owner Mapping für `/api/files/download`.
*
* Jeder Upload-Subdirectory ist mit genau einem Prisma-Model + Path-Field
* verknüpft. Wir suchen den Record, der diesen Path referenziert, und
* leiten daraus den zuständigen Customer/Contract ab. canAccessCustomer /
* canAccessContract entscheidet danach über Zugriff.
*
* Pfade werden 1:1 mit dem in der DB gespeicherten Wert verglichen
* (z.B. `/uploads/bank-cards/12345.pdf`). Damit ist Path-Traversal
* automatisch ausgeschlossen ein konstruierter Pfad findet keinen Record.
*/
import prisma from '../lib/prisma.js';
export type FileOwner =
| { kind: 'customer'; customerId: number }
| { kind: 'contract'; contractId: number }
| { kind: 'admin' }
| { kind: 'gdpr-admin' };
export async function findUploadOwner(uploadPath: string): Promise<FileOwner | null> {
// Format-Check: muss mit /uploads/<subDir>/<filename> beginnen, kein Traversal.
if (!uploadPath.startsWith('/uploads/')) return null;
if (uploadPath.includes('..') || uploadPath.includes('\0')) return null;
const parts = uploadPath.split('/');
// ['', 'uploads', '<subDir>', '<filename...>']
if (parts.length < 4) return null;
const subDir = parts[2];
switch (subDir) {
case 'bank-cards': {
const r = await prisma.bankCard.findFirst({
where: { documentPath: uploadPath },
select: { customerId: true },
});
return r ? { kind: 'customer', customerId: r.customerId } : null;
}
case 'documents': {
const r = await prisma.identityDocument.findFirst({
where: { documentPath: uploadPath },
select: { customerId: true },
});
return r ? { kind: 'customer', customerId: r.customerId } : null;
}
case 'business-registrations': {
const r = await prisma.customer.findFirst({
where: { businessRegistrationPath: uploadPath },
select: { id: true },
});
return r ? { kind: 'customer', customerId: r.id } : null;
}
case 'commercial-registers': {
const r = await prisma.customer.findFirst({
where: { commercialRegisterPath: uploadPath },
select: { id: true },
});
return r ? { kind: 'customer', customerId: r.id } : null;
}
case 'privacy-policies': {
const r = await prisma.customer.findFirst({
where: { privacyPolicyPath: uploadPath },
select: { id: true },
});
return r ? { kind: 'customer', customerId: r.id } : null;
}
case 'authorizations': {
const r = await prisma.representativeAuthorization.findFirst({
where: { documentPath: uploadPath },
select: { customerId: true },
});
return r ? { kind: 'customer', customerId: r.customerId } : null;
}
case 'contract-documents': {
const r = await prisma.contractDocument.findFirst({
where: { documentPath: uploadPath },
select: { contractId: true },
});
return r ? { kind: 'contract', contractId: r.contractId } : null;
}
case 'invoices': {
const r = await prisma.invoice.findFirst({
where: { documentPath: uploadPath },
select: { contractId: true },
});
return r?.contractId ? { kind: 'contract', contractId: r.contractId } : null;
}
case 'cancellation-letters':
case 'cancellation-confirmations':
case 'cancellation-letters-options':
case 'cancellation-confirmations-options': {
const fieldMap: Record<string, 'cancellationLetterPath' | 'cancellationConfirmationPath' | 'cancellationLetterOptionsPath' | 'cancellationConfirmationOptionsPath'> = {
'cancellation-letters': 'cancellationLetterPath',
'cancellation-confirmations': 'cancellationConfirmationPath',
'cancellation-letters-options': 'cancellationLetterOptionsPath',
'cancellation-confirmations-options': 'cancellationConfirmationOptionsPath',
};
const field = fieldMap[subDir];
const r = await prisma.contract.findFirst({
where: { [field]: uploadPath },
select: { id: true },
});
return r ? { kind: 'contract', contractId: r.id } : null;
}
case 'pdf-templates': {
// Admin-only Resource: Vorlagen gehören keinem Customer.
const r = await prisma.pdfTemplate.findFirst({
where: { templatePath: uploadPath },
select: { id: true },
});
return r ? { kind: 'admin' } : null;
}
default:
return null;
}
}
+28 -153
View File
@@ -14,36 +14,6 @@ export interface ImapCredentials {
password: string;
encryption?: MailEncryption; // SSL, STARTTLS oder NONE (Standard: SSL)
allowSelfSignedCerts?: boolean; // Selbstsignierte Zertifikate erlauben
// DNS-Rebinding-Schutz: Caller hat den Hostname zu IP aufgelöst und
// setzt host=IP, servername=originalHostname für TLS-SNI / Cert-Validation.
servername?: string;
}
/**
* TLS-Optionen für IMAP-Verbindungen zusammenbauen.
* Wenn `allowSelfSignedCerts` aktiv ist, werden zusätzlich ältere TLS-Versionen
* (TLS 1.0+) und legacy Cipher-Suites erlaubt hilfreich bei älteren Mailservern,
* die sonst den Socket sofort nach Connect schließen.
*/
function buildTlsOptions(credentials: ImapCredentials): Record<string, unknown> | undefined {
const encryption = credentials.encryption ?? 'SSL';
if (encryption === 'NONE') return undefined;
const rejectUnauthorized = !credentials.allowSelfSignedCerts;
const options: Record<string, unknown> = { rejectUnauthorized };
// DNS-Rebinding-Schutz: wenn host eine IP ist und der ursprüngliche
// Hostname als servername mitgeliefert wird, nutze ihn für SNI/Cert.
if (credentials.servername) {
options.servername = credentials.servername;
}
if (credentials.allowSelfSignedCerts) {
options.minVersion = 'TLSv1';
options.ciphers = 'DEFAULT:@SECLEVEL=0';
}
return options;
}
export interface FetchedEmail {
@@ -136,7 +106,7 @@ export async function fetchEmails(
// TLS-Optionen nur wenn nicht NONE
if (encryption !== 'NONE') {
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
clientOptions.tls = { rejectUnauthorized };
}
// Debug-Logging
@@ -290,7 +260,7 @@ export async function testImapConnection(credentials: ImapCredentials): Promise<
};
if (encryption !== 'NONE') {
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
clientOptions.tls = { rejectUnauthorized };
}
const client = new ImapFlow(clientOptions);
@@ -306,36 +276,9 @@ export async function testImapConnection(credentials: ImapCredentials): Promise<
// Ignorieren
}
// Rohes Error-Objekt loggen, damit wir ImapFlow-spezifische Felder sehen
console.error('[testImapConnection] Raw error:', error);
if (error && typeof error === 'object') {
const e = error as any;
console.error('[testImapConnection] Details:', {
code: e.code,
response: e.response,
responseStatus: e.responseStatus,
responseText: e.responseText,
authenticationFailed: e.authenticationFailed,
serverResponseCode: e.serverResponseCode,
});
}
if (error instanceof Error) {
const msg = error.message.toLowerCase();
const errorCode = (error as NodeJS.ErrnoException).code?.toLowerCase() || '';
const e = error as any;
// ImapFlow-spezifische Details durchreichen wenn vorhanden
if (e.authenticationFailed) {
throw new Error(
`IMAP-Authentifizierung fehlgeschlagen${e.response ? `: ${e.response}` : ''}`,
);
}
if (e.response || e.responseText) {
throw new Error(
`IMAP ${e.responseStatus || 'Fehler'}: ${e.response || e.responseText}`,
);
}
if (msg.includes('authentication') || msg.includes('login')) {
throw new Error('IMAP-Authentifizierung fehlgeschlagen');
@@ -382,7 +325,7 @@ export async function getHighestUid(
};
if (encryption !== 'NONE') {
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
clientOptions.tls = { rejectUnauthorized };
}
const client = new ImapFlow(clientOptions);
@@ -417,41 +360,6 @@ export async function fetchAttachment(
uid: number,
attachmentFilename: string,
folder: string = 'INBOX'
): Promise<EmailAttachmentData | null> {
// Bei transienten Netzwerkfehlern automatisch bis zu 2x retry
let lastError: unknown;
for (let attempt = 1; attempt <= 3; attempt++) {
try {
return await fetchAttachmentInner(credentials, uid, attachmentFilename, folder);
} catch (err) {
lastError = err;
const msg = err instanceof Error ? err.message.toLowerCase() : '';
const isTransient =
msg.includes('socket disconnected') ||
msg.includes('econnreset') ||
msg.includes('etimedout') ||
msg.includes('socket hang up') ||
msg.includes('network socket');
if (!isTransient || attempt === 3) {
throw err;
}
console.warn(
`[fetchAttachment] Versuch ${attempt}/3 fehlgeschlagen (transient), retry in ${attempt * 500}ms:`,
msg,
);
await new Promise((r) => setTimeout(r, attempt * 500));
}
}
throw lastError;
}
async function fetchAttachmentInner(
credentials: ImapCredentials,
uid: number,
attachmentFilename: string,
folder: string = 'INBOX'
): Promise<EmailAttachmentData | null> {
// Verschlüsselungs-Einstellungen basierend auf Modus
const encryption = credentials.encryption ?? 'SSL';
@@ -466,78 +374,45 @@ async function fetchAttachmentInner(
pass: credentials.password,
},
logger: false,
// Timeouts gegen hängende Verbindungen
socketTimeout: 30000,
};
if (encryption !== 'NONE') {
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
clientOptions.tls = { rejectUnauthorized };
}
const client = new ImapFlow(clientOptions);
console.log(`[fetchAttachment] Host: ${credentials.host}:${credentials.port} | User: ${credentials.user} | Folder: ${folder} | UID: ${uid} | File: ${attachmentFilename} | AllowSelfSigned: ${credentials.allowSelfSignedCerts}`);
try {
await client.connect();
// Ordner öffnen bei Fehler alle verfügbaren Ordner listen für Debugging
try {
await client.mailboxOpen(folder);
} catch (folderErr) {
console.error(`[fetchAttachment] mailboxOpen('${folder}') failed:`, folderErr);
try {
const list = await client.list();
const available = list.map((m) => m.path).join(', ');
console.error(`[fetchAttachment] Verfügbare Ordner: ${available}`);
throw new Error(
`Ordner '${folder}' nicht gefunden. Verfügbar: ${available}`,
);
} catch (listErr) {
throw folderErr;
}
}
await client.mailboxOpen(folder);
// E-Mail per UID abrufen
let attachment: EmailAttachmentData | null = null;
let foundMessage = false;
// Drittes Argument { uid: true } sorgt dafür, dass UID FETCH statt FETCH verwendet wird
try {
for await (const message of client.fetch(uid.toString(), {
source: true,
}, { uid: true })) {
foundMessage = true;
if (!message.source) continue;
for await (const message of client.fetch(uid.toString(), {
source: true,
}, { uid: true })) {
if (!message.source) continue;
// E-Mail parsen
const parsed = await simpleParser(message.source);
// E-Mail parsen
const parsed = await simpleParser(message.source);
// Anhang suchen
if (parsed.attachments) {
for (const att of parsed.attachments) {
const filename = att.filename || 'unnamed';
if (filename === attachmentFilename) {
attachment = {
filename,
content: att.content,
contentType: att.contentType || 'application/octet-stream',
size: att.size,
};
break;
}
// Anhang suchen
if (parsed.attachments) {
for (const att of parsed.attachments) {
const filename = att.filename || 'unnamed';
if (filename === attachmentFilename) {
attachment = {
filename,
content: att.content,
contentType: att.contentType || 'application/octet-stream',
size: att.size,
};
break;
}
}
}
} catch (fetchErr) {
console.error(`[fetchAttachment] fetch(UID ${uid}) failed:`, fetchErr);
throw new Error(
`Nachricht mit UID ${uid} konnte nicht geladen werden (${fetchErr instanceof Error ? fetchErr.message : 'unbekannter Fehler'}). Möglicherweise wurde sie im IMAP-Postfach verschoben oder gelöscht.`,
);
}
if (!foundMessage) {
console.warn(`[fetchAttachment] Keine Nachricht mit UID ${uid} in Ordner '${folder}' gefunden`);
}
await client.logout();
@@ -584,7 +459,7 @@ export async function appendToSent(
};
if (encryption !== 'NONE') {
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
clientOptions.tls = { rejectUnauthorized };
}
console.log(`[IMAP] Appending email to ${sentFolder} folder...`);
@@ -670,7 +545,7 @@ export async function fetchAttachmentList(
};
if (encryption !== 'NONE') {
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
clientOptions.tls = { rejectUnauthorized };
}
const client = new ImapFlow(clientOptions);
@@ -762,7 +637,7 @@ export async function moveToTrash(
};
if (encryption !== 'NONE') {
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
clientOptions.tls = { rejectUnauthorized };
}
const client = new ImapFlow(clientOptions);
@@ -835,7 +710,7 @@ export async function restoreFromTrash(
};
if (encryption !== 'NONE') {
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
clientOptions.tls = { rejectUnauthorized };
}
const client = new ImapFlow(clientOptions);
@@ -906,7 +781,7 @@ export async function permanentDelete(
};
if (encryption !== 'NONE') {
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
clientOptions.tls = { rejectUnauthorized };
}
const client = new ImapFlow(clientOptions);
@@ -1,287 +0,0 @@
/**
* Security-Alerting:
* - **Sofort-Alert** für CRITICAL-Events (sobald sie entstehen, vom
* Cron alle 60s gepollt) z.B. Threshold-Überschreitungen.
* - **Hourly-Digest**: einmal pro Stunde Sammlung von HIGH+ Events,
* wenn `monitoringDigestEnabled = true` und mindestens 1 Event vorhanden.
* - **Threshold-Detection**: prüft Brute-Force-Patterns (z.B. >10
* LOGIN_FAILED/h aus gleicher IP) und erzeugt synthetische CRITICAL-
* Events wenn die Schwelle erreicht ist.
*
* Alle E-Mails laufen über die System-E-Mail-Konfiguration des Providers
* (genau wie Geburtstagsgrüße / Passwort-Reset). Daher gleiche Voraussetzungen.
*/
import cron from 'node-cron';
import prisma from '../lib/prisma.js';
import { sendEmail, SmtpCredentials } from './smtpService.js';
import { getSystemEmailCredentials } from './emailProvider/emailProviderService.js';
import * as appSettingService from './appSetting.service.js';
import { emit as emitSecurityEvent } from './securityMonitor.service.js';
import type { SecurityEvent } from '@prisma/client';
interface AlertEmailParams {
subject: string;
events: SecurityEvent[];
isDigest: boolean;
}
interface SendResult {
success: boolean;
error?: string;
}
function severityIcon(s: string): string {
switch (s) {
case 'CRITICAL': return '🚨';
case 'HIGH': return '⚠️';
case 'MEDIUM': return '🟡';
case 'LOW': return '🟢';
default: return '️';
}
}
function eventToHtmlRow(e: SecurityEvent): string {
const ts = e.createdAt.toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'medium' });
const ip = e.ipAddress || '';
const who = e.userEmail || (e.userId ? `User #${e.userId}` : e.customerId ? `Kunde #${e.customerId}` : '');
const ep = e.endpoint || '';
return `<tr>
<td style="padding:4px 8px;font-family:monospace;font-size:11px">${ts}</td>
<td style="padding:4px 8px">${severityIcon(e.severity)} ${e.severity}</td>
<td style="padding:4px 8px">${e.type}</td>
<td style="padding:4px 8px">${e.message}</td>
<td style="padding:4px 8px">${who}</td>
<td style="padding:4px 8px;font-family:monospace;font-size:11px">${ip}</td>
<td style="padding:4px 8px;font-family:monospace;font-size:11px">${ep}</td>
</tr>`;
}
function buildHtmlEmail(params: AlertEmailParams): string {
const rows = params.events.map(eventToHtmlRow).join('\n');
const heading = params.isDigest
? `<h2>OpenCRM Security-Digest</h2><p>Übersicht der wichtigen Events der letzten Stunde:</p>`
: `<h2>OpenCRM Security-Alert</h2><p>Folgendes Event wurde als kritisch eingestuft:</p>`;
return `<!doctype html><html><body style="font-family:sans-serif;color:#222">
${heading}
<table border="0" cellspacing="0" cellpadding="0" style="border-collapse:collapse;width:100%;font-size:13px">
<thead style="background:#f3f4f6">
<tr>
<th align="left" style="padding:6px 8px">Zeit</th>
<th align="left" style="padding:6px 8px">Severity</th>
<th align="left" style="padding:6px 8px">Typ</th>
<th align="left" style="padding:6px 8px">Nachricht</th>
<th align="left" style="padding:6px 8px">Wer</th>
<th align="left" style="padding:6px 8px">IP</th>
<th align="left" style="padding:6px 8px">Endpoint</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
<p style="margin-top:20px;color:#666;font-size:12px">Diese Mail wurde vom OpenCRM Monitoring-System gesendet.
Konfiguration: Einstellungen Monitoring.</p>
</body></html>`;
}
/**
* Versendet einen Alert per E-Mail. Nutzt die System-E-Mail des Providers.
*/
export async function sendAlertEmail(toAddress: string, params: AlertEmailParams): Promise<SendResult> {
const sysEmail = await getSystemEmailCredentials();
if (!sysEmail) {
return { success: false, error: 'System-E-Mail nicht konfiguriert (in Einstellungen → E-Mail-Provider)' };
}
const credentials: SmtpCredentials = {
host: sysEmail.smtpServer,
port: sysEmail.smtpPort,
user: sysEmail.emailAddress,
password: sysEmail.password,
encryption: sysEmail.smtpEncryption,
allowSelfSignedCerts: sysEmail.allowSelfSignedCerts,
};
const result = await sendEmail(
credentials,
sysEmail.emailAddress,
{
to: toAddress,
subject: params.subject,
html: buildHtmlEmail(params),
},
{ context: 'security-alert', triggeredBy: 'monitor' },
);
return result.success
? { success: true }
: { success: false, error: result.error };
}
/**
* Threshold-Detection: prüft ob in den letzten N Minuten verdächtige Patterns
* aufgetreten sind, die einen CRITICAL-Alert rechtfertigen.
*
* Regeln (alle pro IP):
* - >= 10 LOGIN_FAILED in 60 min CRITICAL Brute-Force-Verdacht
* - >= 5 ACCESS_DENIED in 5 min CRITICAL IDOR-Probing-Verdacht
* - >= 3 SSRF_BLOCKED in 60 min CRITICAL SSRF-Probing
* - >= 3 TOKEN_REJECTED HIGH in 5 min CRITICAL JWT-Manipulation
*/
export async function detectThresholds(): Promise<void> {
const now = new Date();
const fiveMinAgo = new Date(now.getTime() - 5 * 60 * 1000);
const sixtyMinAgo = new Date(now.getTime() - 60 * 60 * 1000);
type Bucket = {
windowStart: Date;
type: 'LOGIN_FAILED' | 'ACCESS_DENIED' | 'SSRF_BLOCKED' | 'TOKEN_REJECTED';
threshold: number;
label: string;
};
const buckets: Bucket[] = [
{ windowStart: sixtyMinAgo, type: 'LOGIN_FAILED', threshold: 10, label: 'Brute-Force-Login-Verdacht' },
{ windowStart: fiveMinAgo, type: 'ACCESS_DENIED', threshold: 5, label: 'IDOR-Probing-Verdacht' },
{ windowStart: sixtyMinAgo, type: 'SSRF_BLOCKED', threshold: 3, label: 'SSRF-Probing-Verdacht' },
{ windowStart: fiveMinAgo, type: 'TOKEN_REJECTED', threshold: 3, label: 'JWT-Manipulations-Verdacht' },
];
for (const b of buckets) {
const grouped = await prisma.securityEvent.groupBy({
by: ['ipAddress'],
where: {
type: b.type as any,
createdAt: { gte: b.windowStart },
},
_count: true,
});
for (const g of grouped) {
if ((g._count as number) < b.threshold) continue;
// Prüfen ob wir für diese (IP+Type+Stunde) schon einen CRITICAL emittiert haben
const hourBucket = new Date(now.getTime() - (now.getTime() % (60 * 60 * 1000)));
const existing = await prisma.securityEvent.findFirst({
where: {
type: 'SUSPICIOUS',
severity: 'CRITICAL',
ipAddress: g.ipAddress,
createdAt: { gte: hourBucket },
},
});
if (existing) continue;
await emitSecurityEvent({
type: 'SUSPICIOUS',
severity: 'CRITICAL',
message: `${b.label}: ${g._count}× ${b.type} in ${b.windowStart === fiveMinAgo ? '5min' : '60min'} von ${g.ipAddress}`,
ipAddress: g.ipAddress,
details: { rule: b.type, count: g._count, threshold: b.threshold },
});
}
}
}
/**
* Sendet pending CRITICAL-Events sofort als Einzel-Mails (debounced auf
* 1 Mail pro IP pro Stunde, damit nicht spammend).
*/
async function sendPendingCriticalAlerts(): Promise<{ sent: number; skipped: number }> {
const alertEmail = await appSettingService.getSetting('monitoringAlertEmail');
if (!alertEmail) return { sent: 0, skipped: 0 };
const pending = await prisma.securityEvent.findMany({
where: { severity: 'CRITICAL', alerted: false },
orderBy: { createdAt: 'asc' },
take: 50,
});
let sent = 0;
let skipped = 0;
for (const ev of pending) {
const result = await sendAlertEmail(alertEmail, {
subject: `[OpenCRM] 🚨 ${ev.type}: ${ev.message.substring(0, 80)}`,
events: [ev],
isDigest: false,
});
if (result.success) {
sent++;
await prisma.securityEvent.update({
where: { id: ev.id },
data: { alerted: true, alertedAt: new Date() },
});
} else {
skipped++;
console.error(`[securityAlert] Send failed for event #${ev.id}:`, result.error);
}
}
return { sent, skipped };
}
/**
* Hourly-Digest: alle HIGH-Events der letzten Stunde, die noch nicht
* alert-versendet wurden, in einer einzigen Mail zusammenfassen.
*/
export async function sendDigest(opts: { force?: boolean } = {}): Promise<{ sent: boolean; eventCount: number; reason?: string }> {
const alertEmail = await appSettingService.getSetting('monitoringAlertEmail');
if (!alertEmail) return { sent: false, eventCount: 0, reason: 'Keine Alert-E-Mail konfiguriert' };
const enabled = await appSettingService.getSettingBool('monitoringDigestEnabled');
if (!enabled && !opts.force) return { sent: false, eventCount: 0, reason: 'Digest deaktiviert' };
const lastDigestAt = await appSettingService.getSetting('monitoringLastDigestAt');
const since = lastDigestAt ? new Date(lastDigestAt) : new Date(Date.now() - 60 * 60 * 1000);
const events = await prisma.securityEvent.findMany({
where: {
severity: { in: ['HIGH', 'MEDIUM'] },
alerted: false,
createdAt: { gte: since },
},
orderBy: { createdAt: 'desc' },
take: 200,
});
if (events.length === 0) {
await appSettingService.setSetting('monitoringLastDigestAt', new Date().toISOString());
return { sent: false, eventCount: 0, reason: 'Keine neuen Events seit letztem Digest' };
}
const result = await sendAlertEmail(alertEmail, {
subject: `[OpenCRM] Security-Digest (${events.length} Events)`,
events,
isDigest: true,
});
if (result.success) {
await prisma.securityEvent.updateMany({
where: { id: { in: events.map((e) => e.id) } },
data: { alerted: true, alertedAt: new Date() },
});
await appSettingService.setSetting('monitoringLastDigestAt', new Date().toISOString());
return { sent: true, eventCount: events.length };
}
return { sent: false, eventCount: events.length, reason: result.error };
}
/**
* Cron-Scheduler:
* - Jede Minute: Threshold-Detection + Sofort-Alerts für CRITICAL
* - Jede volle Stunde: Hourly-Digest (HIGH+MEDIUM)
*/
export function startSecurityMonitorScheduler(): void {
cron.schedule('* * * * *', async () => {
try {
await detectThresholds();
await sendPendingCriticalAlerts();
} catch (err) {
console.error('[securityAlert] minute-cron failed:', err);
}
});
cron.schedule('0 * * * *', async () => {
try {
await sendDigest();
} catch (err) {
console.error('[securityAlert] hourly-digest failed:', err);
}
});
console.log('[securityAlert] Scheduler gestartet (1min Threshold-Check, hourly Digest)');
}
@@ -1,81 +0,0 @@
/**
* Security-Monitor: zentrale `emit()`-Funktion für sicherheitsrelevante
* Events. Schreibt in die `SecurityEvent`-Tabelle (nicht im AuditLog,
* weil hier andere Anforderungen gelten: schnelles Filtern, Threshold-
* Detection, Realtime-Alerting statt forensischer Hash-Chain).
*
* Hooks für die wichtigsten Klassen:
* - LOGIN_FAILED Login mit falschem Passwort
* - LOGIN_SUCCESS erfolgreicher Login (informativ)
* - RATE_LIMIT_HIT express-rate-limit hat zugeschlagen
* - ACCESS_DENIED 403 von canAccess* (versuchter IDOR)
* - SSRF_BLOCKED ssrfGuard hat geblockt
* - PASSWORD_RESET_REQUEST Reset angefordert
* - PASSWORD_RESET_CONFIRM Reset abgeschlossen
* - LOGOUT expliziter Logout
* - TOKEN_REJECTED JWT verify-Failure
* - PERMISSION_CHANGED Rolle/Permission-Update
*
* Sofort-Alert für CRITICAL+HIGH-Events (wenn `monitoringAlertEmail`
* konfiguriert), sonst Sammlung im stündlichen Digest.
*/
import prisma from '../lib/prisma.js';
import type { SecurityEventType, SecuritySeverity } from '@prisma/client';
export interface SecurityEventInput {
type: SecurityEventType;
severity: SecuritySeverity;
message: string;
ipAddress?: string | null;
userId?: number | null;
customerId?: number | null;
userEmail?: string | null;
endpoint?: string | null;
details?: Record<string, unknown>;
}
/**
* Schreibt ein SecurityEvent. Fehler beim Schreiben werden geschluckt,
* damit ein kaputtes Monitoring nicht den Login-Flow stoppt.
*/
export async function emit(event: SecurityEventInput): Promise<void> {
try {
await prisma.securityEvent.create({
data: {
type: event.type,
severity: event.severity,
message: event.message,
ipAddress: event.ipAddress || null,
userId: event.userId || null,
customerId: event.customerId || null,
userEmail: event.userEmail || null,
endpoint: event.endpoint || null,
details: event.details ? (event.details as any) : undefined,
},
});
} catch (err) {
console.error('[securityMonitor] emit failed:', err);
}
}
/**
* Helper: aus einem Express-Request die wichtigsten Kontextfelder extrahieren.
* Funktioniert sowohl mit AuthRequest (eingeloggt) als auch mit anonymen
* Requests (Login-Versuch etc.).
*/
export function contextFromRequest(req: any): {
ipAddress: string;
userId?: number;
customerId?: number;
userEmail?: string;
endpoint: string;
} {
const user = req?.user;
return {
ipAddress: req?.ip || req?.socket?.remoteAddress || 'unknown',
userId: user?.userId,
customerId: user?.customerId,
userEmail: user?.email,
endpoint: `${req?.method || ''} ${req?.path || req?.originalUrl || ''}`.trim(),
};
}
+2 -44
View File
@@ -15,10 +15,6 @@ export interface SmtpCredentials {
password: string;
encryption?: MailEncryption; // SSL, STARTTLS oder NONE (Standard: SSL)
allowSelfSignedCerts?: boolean; // Selbstsignierte Zertifikate erlauben
// DNS-Rebinding-Schutz: Caller hat den Hostname zu IP aufgelöst und
// setzt host=IP, servername=originalHostname für TLS-SNI / Cert-Validation.
// Damit kann ein zweiter DNS-Lookup nicht plötzlich auf eine interne IP zeigen.
servername?: string;
}
// Anhang-Interface
@@ -53,16 +49,6 @@ export interface EmailLogContext {
triggeredBy?: string; // User-Email
}
// Security: zentrale CRLF-Prüfung gegen SMTP-Header-Injection.
// Alle Felder, die als Header ausgehen (to/cc/subject/replyTo/references/from),
// werden hier geprüft egal ob der Caller aus cachedEmail, birthday, gdpr,
// consent-public oder auth kommt.
function containsCRLF(value: unknown): boolean {
if (typeof value === 'string') return /[\r\n]/.test(value);
if (Array.isArray(value)) return value.some(containsCRLF);
return false;
}
// E-Mail senden
export async function sendEmail(
credentials: SmtpCredentials,
@@ -70,21 +56,6 @@ export async function sendEmail(
params: SendEmailParams,
logContext?: EmailLogContext
): Promise<SendEmailResult> {
// Header-Injection-Guard (defensiv: Absender, Empfänger, Subject)
if (
containsCRLF(fromAddress) ||
containsCRLF(params.to) ||
containsCRLF(params.cc) ||
containsCRLF(params.subject) ||
containsCRLF(params.inReplyTo) ||
containsCRLF(params.references)
) {
return {
success: false,
error: 'Ungültige Zeichen in E-Mail-Header-Feldern (CRLF nicht erlaubt)',
};
}
// Verschlüsselungs-Einstellungen basierend auf Modus
const encryption = credentials.encryption ?? 'SSL';
const rejectUnauthorized = !credentials.allowSelfSignedCerts;
@@ -98,7 +69,7 @@ export async function sendEmail(
port: number;
secure: boolean;
auth: { user: string; pass: string };
tls?: { rejectUnauthorized: boolean; minVersion?: string; ciphers?: string; servername?: string };
tls?: { rejectUnauthorized: boolean };
ignoreTLS?: boolean;
requireTLS?: boolean;
connectionTimeout: number;
@@ -120,16 +91,6 @@ export async function sendEmail(
// TLS-Optionen nur wenn nicht NONE
if (encryption !== 'NONE') {
transportOptions.tls = { rejectUnauthorized };
// DNS-Rebinding-Schutz: wenn host eine IP ist, der ursprüngliche
// Hostname für SNI/Cert-Validation explizit setzen.
if (credentials.servername) {
transportOptions.tls.servername = credentials.servername;
}
if (credentials.allowSelfSignedCerts) {
// Auch ältere TLS-Versionen + legacy Cipher-Suites für alte Server zulassen
transportOptions.tls.minVersion = 'TLSv1';
transportOptions.tls.ciphers = 'DEFAULT:@SECLEVEL=0';
}
} else {
// Keine Verschlüsselung: STARTTLS ignorieren
transportOptions.ignoreTLS = true;
@@ -312,7 +273,7 @@ export async function testSmtpConnection(credentials: SmtpCredentials): Promise<
port: number;
secure: boolean;
auth: { user: string; pass: string };
tls?: { rejectUnauthorized: boolean; minVersion?: string; ciphers?: string; servername?: string };
tls?: { rejectUnauthorized: boolean };
ignoreTLS?: boolean;
connectionTimeout: number;
greetingTimeout: number;
@@ -330,9 +291,6 @@ export async function testSmtpConnection(credentials: SmtpCredentials): Promise<
if (encryption !== 'NONE') {
transportOptions.tls = { rejectUnauthorized };
if (credentials.servername) {
transportOptions.tls.servername = credentials.servername;
}
} else {
transportOptions.ignoreTLS = true;
}
-271
View File
@@ -1,271 +0,0 @@
/**
* Access-Control-Helper für Portal-Kunden-Isolation.
*
* Portal-Kunden haben die Permission `contracts:read` / `customers:read`, damit
* sie ihre eigenen Daten sehen können. Damit sie aber NICHT fremde Daten über
* geratene IDs abrufen (IDOR), muss bei jedem Endpoint der eine sensible
* Ressource (Vertrag, Rechnung, Passwort, ...) zurückliefert, der Kunde auf
* Besitz/Vollmacht geprüft werden.
*/
import { Response } from 'express';
import prisma from '../lib/prisma.js';
import * as authorizationService from '../services/authorization.service.js';
import { AuthRequest } from '../types/index.js';
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
/**
* Wird intern aufgerufen, wenn ein canAccess*-Check 403 zurückgibt.
* Schreibt ein SecurityEvent für Monitoring + spätere Threshold-Detection.
*/
function emitAccessDenied(req: AuthRequest, label: string, targetId: number | string): void {
const ctx = contextFromRequest(req);
emitSecurityEvent({
type: 'ACCESS_DENIED',
severity: 'MEDIUM',
message: `Zugriff verweigert: ${label} #${targetId}`,
ipAddress: ctx.ipAddress,
userId: ctx.userId,
customerId: ctx.customerId,
userEmail: ctx.userEmail,
endpoint: ctx.endpoint,
details: { resource: label, targetId },
});
}
/**
* Prüft ob der authentifizierte User auf einen bestimmten Vertrag zugreifen darf.
* - Mitarbeiter/Admin mit customers:read / contracts:read: ja, immer
* - Portal-Kunde: nur wenn contract.customerId = eigener customerId ODER
* wenn er einen Vertreter für diesen Kunden ist MIT gültiger Vollmacht
*
* @returns true = erlaubt, false = Zugriff verweigert (Response wurde bereits gesendet)
*/
export async function canAccessContract(
req: AuthRequest,
res: Response,
contractId: number,
): Promise<boolean> {
// Nicht-Portal-User (Mitarbeiter/Admin) kommen hier immer durch, wenn sie die Permission haben
if (!req.user?.isCustomerPortal) {
return true;
}
if (!req.user.customerId) {
res.status(403).json({ success: false, error: 'Kein Zugriff' });
return false;
}
// Vertrag laden, Besitzer-ID prüfen
const contract = await prisma.contract.findUnique({
where: { id: contractId },
select: { customerId: true },
});
if (!contract) {
res.status(404).json({ success: false, error: 'Vertrag nicht gefunden' });
return false;
}
// Eigene Verträge = immer erlaubt
if (contract.customerId === req.user.customerId) {
return true;
}
// Fremde Verträge nur mit aktiver Vollmacht
const representedIds: number[] = (req.user as any).representedCustomerIds || [];
if (!representedIds.includes(contract.customerId)) {
emitAccessDenied(req, 'Contract', contractId);
res.status(403).json({ success: false, error: 'Kein Zugriff auf diesen Vertrag' });
return false;
}
const hasAuth = await authorizationService.hasAuthorization(
contract.customerId,
req.user.customerId,
);
if (!hasAuth) {
emitAccessDenied(req, 'Contract (Vollmacht fehlt)', contractId);
res.status(403).json({ success: false, error: 'Vollmacht erforderlich' });
return false;
}
return true;
}
/**
* Prüft Zugriff auf einen Kunden (analog zu canAccessContract).
*/
export async function canAccessCustomer(
req: AuthRequest,
res: Response,
customerId: number,
): Promise<boolean> {
if (!req.user?.isCustomerPortal) {
return true;
}
if (!req.user.customerId) {
res.status(403).json({ success: false, error: 'Kein Zugriff' });
return false;
}
if (customerId === req.user.customerId) {
return true;
}
const representedIds: number[] = (req.user as any).representedCustomerIds || [];
if (!representedIds.includes(customerId)) {
emitAccessDenied(req, 'Customer', customerId);
res.status(403).json({ success: false, error: 'Kein Zugriff auf diese Kundendaten' });
return false;
}
const hasAuth = await authorizationService.hasAuthorization(customerId, req.user.customerId);
if (!hasAuth) {
emitAccessDenied(req, 'Customer (Vollmacht fehlt)', customerId);
res.status(403).json({ success: false, error: 'Vollmacht erforderlich' });
return false;
}
return true;
}
/**
* Generische Zugriffsprüfung: Ressource customerId canAccessCustomer.
*/
async function canAccessResourceByCustomerId(
req: AuthRequest,
res: Response,
customerId: number | null | undefined,
resourceLabel: string,
): Promise<boolean> {
if (!req.user?.isCustomerPortal) return true;
if (!customerId) {
res.status(404).json({ success: false, error: `${resourceLabel} nicht gefunden` });
return false;
}
return canAccessCustomer(req, res, customerId);
}
/**
* Zugriff auf eine Adresse prüfen (lädt sie aus der DB, prüft customerId).
*/
export async function canAccessAddress(
req: AuthRequest,
res: Response,
addressId: number,
): Promise<boolean> {
if (!req.user?.isCustomerPortal) return true;
const addr = await prisma.address.findUnique({
where: { id: addressId },
select: { customerId: true },
});
return canAccessResourceByCustomerId(req, res, addr?.customerId, 'Adresse');
}
/**
* Zugriff auf eine BankCard prüfen.
*/
export async function canAccessBankCard(
req: AuthRequest,
res: Response,
bankCardId: number,
): Promise<boolean> {
if (!req.user?.isCustomerPortal) return true;
const card = await prisma.bankCard.findUnique({
where: { id: bankCardId },
select: { customerId: true },
});
return canAccessResourceByCustomerId(req, res, card?.customerId, 'Bankkarte');
}
/**
* Zugriff auf ein IdentityDocument prüfen.
*/
export async function canAccessIdentityDocument(
req: AuthRequest,
res: Response,
documentId: number,
): Promise<boolean> {
if (!req.user?.isCustomerPortal) return true;
const doc = await prisma.identityDocument.findUnique({
where: { id: documentId },
select: { customerId: true },
});
return canAccessResourceByCustomerId(req, res, doc?.customerId, 'Ausweis');
}
/**
* Zugriff auf einen Meter prüfen.
*/
export async function canAccessMeter(
req: AuthRequest,
res: Response,
meterId: number,
): Promise<boolean> {
if (!req.user?.isCustomerPortal) return true;
const meter = await prisma.meter.findUnique({
where: { id: meterId },
select: { customerId: true },
});
return canAccessResourceByCustomerId(req, res, meter?.customerId, 'Zähler');
}
/**
* Zugriff auf eine StressfreiEmail prüfen.
*/
export async function canAccessStressfreiEmail(
req: AuthRequest,
res: Response,
stressfreiEmailId: number,
): Promise<boolean> {
if (!req.user?.isCustomerPortal) return true;
const sfe = await prisma.stressfreiEmail.findUnique({
where: { id: stressfreiEmailId },
select: { customerId: true },
});
return canAccessResourceByCustomerId(req, res, sfe?.customerId, 'E-Mail-Konto');
}
/**
* Zugriff auf eine CachedEmail prüfen (StressfreiEmail customerId).
*/
export async function canAccessCachedEmail(
req: AuthRequest,
res: Response,
emailId: number,
): Promise<boolean> {
if (!req.user?.isCustomerPortal) return true;
const email = await prisma.cachedEmail.findUnique({
where: { id: emailId },
select: { stressfreiEmail: { select: { customerId: true } } },
});
return canAccessResourceByCustomerId(
req,
res,
email?.stressfreiEmail?.customerId,
'E-Mail',
);
}
/**
* Zugriff auf ein EnergyContractDetails prüfen (ECD Contract customerId).
*/
export async function canAccessEnergyContractDetails(
req: AuthRequest,
res: Response,
ecdId: number,
): Promise<boolean> {
if (!req.user?.isCustomerPortal) return true;
const ecd = await prisma.energyContractDetails.findUnique({
where: { id: ecdId },
select: { contract: { select: { customerId: true } } },
});
return canAccessResourceByCustomerId(
req,
res,
ecd?.contract?.customerId,
'Energievertrag',
);
}
-146
View File
@@ -1,146 +0,0 @@
/**
* Sanitize-Helpers: entfernen sensible Felder aus DB-Ergebnissen, bevor sie
* als API-Response rausgehen. Zentrale Stelle, damit keine Passwort-Hashes,
* Verschlüsselungen oder Reset-Tokens versehentlich durch die API leaken.
*/
// Felder die NIE in einer API-Response an den Client gehen dürfen
const SENSITIVE_CUSTOMER_FIELDS = [
'portalPasswordHash',
'portalPasswordResetToken',
'portalPasswordResetExpiresAt',
] as const;
const SENSITIVE_USER_FIELDS = [
'password',
'passwordResetToken',
'passwordResetExpiresAt',
'tokenInvalidatedAt',
] as const;
/**
* Entfernt Passwort-Hash, Reset-Token etc. aus einem Customer-Objekt.
* `portalPasswordEncrypted` bleibt nur drin, wenn der Caller Admin-Rechte hat
* (wird in einem zweiten Schritt vom Controller gemacht). Dieser Helper entfernt
* es standardmäßig.
*/
export function sanitizeCustomer<T extends Record<string, unknown>>(customer: T | null): T | null {
if (!customer) return customer;
const copy = { ...customer };
for (const field of SENSITIVE_CUSTOMER_FIELDS) {
delete copy[field];
}
// portalPasswordEncrypted bleibt hier zunächst drin, damit Mitarbeiter das
// Portal-Passwort ggf. in der UI anzeigen können. Wird per requirePermission
// auf 'customers:update' implizit gesichert.
return copy;
}
/**
* Entfernt portalPasswordEncrypted zusätzlich zu den anderen sensiblen Feldern.
* Für Kontexte in denen der Caller KEIN Admin ist (z.B. Portal-Kunde).
*/
export function sanitizeCustomerStrict<T extends Record<string, unknown>>(customer: T | null): T | null {
if (!customer) return customer;
const copy = sanitizeCustomer(customer) as Record<string, unknown> | null;
if (!copy) return null;
delete copy.portalPasswordEncrypted;
return copy as T;
}
/**
* Sanitize-Liste von Customers.
*/
export function sanitizeCustomers<T extends Record<string, unknown>>(customers: T[]): T[] {
return customers.map((c) => sanitizeCustomer(c)).filter((c): c is T => c !== null);
}
/**
* Sanitize User-Objekt für API-Responses.
*/
export function sanitizeUser<T extends Record<string, unknown>>(user: T | null): T | null {
if (!user) return user;
const copy = { ...user };
for (const field of SENSITIVE_USER_FIELDS) {
delete copy[field];
}
return copy;
}
// ==================== REQUEST-BODY WHITELISTS ====================
// Gegen Mass-Assignment: Nur explizit erlaubte Felder aus req.body übernehmen.
const CUSTOMER_UPDATABLE_FIELDS = [
'type',
'salutation',
'useInformalAddress',
'firstName',
'lastName',
'companyName',
'foundingDate',
'birthDate',
'birthPlace',
'email',
'phone',
'mobile',
'taxNumber',
'commercialRegisterNumber',
'notes',
'portalEnabled',
'portalEmail',
'autoBirthdayGreeting',
'autoBirthdayChannel',
// Nicht: portalPasswordHash, portalPasswordEncrypted, portalPasswordResetToken,
// portalTokenInvalidatedAt, customerNumber, id, createdAt, updatedAt, consentHash,
// lastBirthdayGreetingYear, privacyPolicyPath, businessRegistrationPath, commercialRegisterPath
] as const;
const CUSTOMER_CREATE_FIELDS = [
...CUSTOMER_UPDATABLE_FIELDS,
// customerNumber wird vom Service generiert nicht aus req.body übernehmen
] as const;
const USER_UPDATABLE_FIELDS = [
'email',
'firstName',
'lastName',
'isActive',
'whatsappNumber',
'telegramUsername',
'signalNumber',
'roleIds',
'password', // nur Admin, wird im Service gehashed
// Nicht: id, customerId, tokenInvalidatedAt, passwordResetToken, passwordResetExpiresAt
] as const;
const USER_CREATE_FIELDS = USER_UPDATABLE_FIELDS;
/**
* Filtert req.body anhand einer Whitelist. Unerlaubte Felder werden verworfen.
* Verhindert Mass-Assignment-Angriffe (z.B. { portalPasswordHash: "..." } im Body).
*/
function pick<T extends object>(obj: T, allowed: readonly string[]): Partial<T> {
const result: Partial<T> = {};
for (const key of allowed) {
if (key in obj) {
(result as any)[key] = (obj as any)[key];
}
}
return result;
}
export function pickCustomerUpdate(body: unknown): Partial<Record<string, unknown>> {
return pick((body as object) || {}, CUSTOMER_UPDATABLE_FIELDS);
}
export function pickCustomerCreate(body: unknown): Partial<Record<string, unknown>> {
return pick((body as object) || {}, CUSTOMER_CREATE_FIELDS);
}
export function pickUserUpdate(body: unknown): Partial<Record<string, unknown>> {
return pick((body as object) || {}, USER_UPDATABLE_FIELDS);
}
export function pickUserCreate(body: unknown): Partial<Record<string, unknown>> {
return pick((body as object) || {}, USER_CREATE_FIELDS);
}
-107
View File
@@ -1,107 +0,0 @@
/**
* Schutz vor Server-Side Request Forgery (SSRF) bei User-kontrollierten
* Hosts/URLs in Endpunkten wie test-connection, test-mail-access.
*
* Wir blockieren bewusst NICHT die komplette private IP-Range (127.0.0.0/8,
* 10.0.0.0/8 etc.), weil legitime On-Premise-Setups häufig Plesk/Dovecot/
* Postfix auf 127.0.0.1 oder im internen Netz laufen lassen. Stattdessen
* blockieren wir nur:
* - Cloud-Metadata-Endpoints (169.254.169.254, fd00:ec2::254)
* - 169.254.0.0/16 Link-Local (deckt Cloud-Metadata + APIPA ab)
* - 0.0.0.0/8 (ungültiger Source/Routing-Range)
* - Multicast / Reserved Ranges (224.0.0.0/4, 240.0.0.0/4)
*
* Für Defense-in-Depth gegen DNS-Rebinding wäre eine vollständige DNS-
* Resolution + IP-Vergleich nötig das überlassen wir v1.1, weil es
* legitimes Caching/CDN-Verhalten brechen kann.
*/
const BLOCKED_PATTERNS: RegExp[] = [
/^169\.254\./, // Link-Local (AWS/GCP/Azure Metadata, APIPA)
/^0\./, // 0.0.0.0/8 reserved
/^22[4-9]\./, // 224-229 Multicast
/^23[0-9]\./, // 230-239 Multicast
/^24[0-9]\./, // 240-249 reserved
/^25[0-5]\./, // 250-255 reserved
/^fd00:ec2::/i, // AWS IPv6 Metadata
/^fe80:/i, // IPv6 Link-Local
/^ff/i, // IPv6 Multicast
];
const BLOCKED_HOSTNAMES = new Set([
'metadata.google.internal',
'metadata.goog',
'metadata',
'169.254.169.254',
]);
export function isBlockedSsrfHost(host: string | null | undefined): boolean {
if (!host) return false;
const h = host.trim().toLowerCase();
if (!h) return false;
if (BLOCKED_HOSTNAMES.has(h)) return true;
for (const pattern of BLOCKED_PATTERNS) {
if (pattern.test(h)) return true;
}
return false;
}
/**
* Wirft einen Fehler, wenn der Host für ausgehende Verbindungen blockiert ist.
* Caller sollte den Fehler in 400er Response umsetzen.
*/
export function assertAllowedHost(host: string | null | undefined, label = 'Host'): void {
if (isBlockedSsrfHost(host)) {
throw new Error(`${label} verweist auf eine geblockte Adresse (Cloud-Metadata / Link-Local / Reserved).`);
}
}
import { promises as dns } from 'dns';
import net from 'net';
/**
* DNS-Rebinding-Schutz: löst den Hostname zu allen IPs auf und prüft jede
* gegen die Block-Liste. Wirft wenn IRGENDEINE IP geblockt ist.
*
* Das Resultat enthält die erste (geprüfte) IP plus den Original-Hostname
* als `servername` für TLS-SNI / Cert-Validation. Der Caller muss die
* Connection mit `host=ip` und `tls.servername=hostname` aufbauen, damit
* ein zweiter DNS-Lookup keine andere (geblockte) IP liefern kann.
*
* Wenn der Host bereits eine IP-Literal ist, wird er direkt geprüft.
*/
export async function safeResolveHost(host: string | null | undefined, label = 'Host'): Promise<{ ip: string; servername: string }> {
if (!host || !host.trim()) {
throw new Error(`${label} fehlt`);
}
const trimmed = host.trim();
// IP-Literal? Direkt prüfen, kein DNS nötig.
if (net.isIP(trimmed)) {
assertAllowedHost(trimmed, label);
return { ip: trimmed, servername: trimmed };
}
// Hostname → resolve to IPv4 + IPv6
let ips: string[] = [];
try {
const v4 = await dns.resolve4(trimmed).catch(() => [] as string[]);
const v6 = await dns.resolve6(trimmed).catch(() => [] as string[]);
ips = [...v4, ...v6];
} catch {
throw new Error(`${label}: DNS-Auflösung fehlgeschlagen für ${trimmed}`);
}
if (ips.length === 0) {
throw new Error(`${label}: keine IP-Adresse für ${trimmed} gefunden`);
}
// Alle aufgelösten IPs prüfen schon eine geblockte reicht für Ablehnung.
for (const ip of ips) {
if (isBlockedSsrfHost(ip)) {
throw new Error(`${label} ${trimmed} löst auf geblockte Adresse ${ip} auf`);
}
}
return { ip: ips[0], servername: trimmed };
}
+76
View File
@@ -0,0 +1,76 @@
# 📋 OpenCRM Todo-Liste
---
## 🔜 Offen
### Email Log & System testen
- Senden testen
- Empfangen testen
### Security System testen
### Email → Vertragsdokumente
Wenn eine Email einem Vertrag zugeordnet ist:
- Anhänge auch in Vertragsdokumente speichern
- Rechnungen wie Kündigungsdokumente behandeln
---
## ✅ Erledigt
- [x] **Geburtstagskalender + Geburtstagsgruß-Modal**
- Admin: Section im Vertrags-Cockpit mit Kunden, die in den nächsten 30 Tagen oder letzten 7 Tagen Geburtstag haben
- Portal: Modal mit Gruß am Geburtstag (inkl. nachträglichem Glückwunsch bis 7 Tage danach)
- Wird pro Jahr nur einmal angezeigt
- [x] **Typspezifische Zusatzinfos in Vertragslisten**
- Strom/Gas → "Lieferadresse: ..."
- DSL/Glasfaser/Kabel → "Anschlussadresse: ..."
- Mobilfunk → "Rufnummer: ..."
- KFZ → "Kennzeichen: ..."
- Sichtbar in Admin-Liste, Portal-Liste und Kunden-Tab
- [x] **Datenschutzerklärung PDF ↔ Online-Einwilligungen synchronisieren**
- PDF hochgeladen → alle 4 Consents auf GRANTED
- Haken entfernt im Portal → PDF löschen + Tabs sperren
- Entsperrung nur durch alle Haken oder neues PDF
- [x] **Zweitarif-Zähler (HT/NT)** bei Strom + Verbrauchsberechnung
- [x] **Datumsformate vereinheitlichen** (01.01.2026 statt 1.1.2026)
- [x] **Audit-Log aussagekräftig** (Vorher/Nachher bei allen Änderungen)
- [x] **Impressum + Website-Datenschutzerklärung** im Kundenportal
- Editor in Einstellungen
- Vorschlagstexte
- [x] **Consent-Bestätigungs-Flow per Email**
- Alle Hebel müssen gesetzt sein
- Bestätigungsbutton + Bestätigungsemail
- [x] **Vertragsdokumente-Upload** (Auftragsformular, Lieferbestätigung, Vertragsunterlagen als PDF/PNG)
- [x] **Bug: Stressfrei-Email im Auftragsgenerator** (funktioniert jetzt im Vertrag)
- [x] **PDF-Auftragsvorlagen-System**
- Template-Editor in Einstellungen
- PDF hochladen, Formularfelder automatisch auslesen
- CRM-Felder zuordnen (visuell mit Vorschau)
- Seitenweise Sortierung der Felder
- Dynamische Rufnummern-Felder mit Vorwahl-Extraktion
- Nicht zugeordnete Felder bleiben editierbar
- Auftrag generieren aus Vertragsdaten (Button im Vertrags-Detail)
- [x] **Eigentümer-Verwaltung**
- An Adresse gehängt (Firma, Vorname, Nachname, Anschrift, Kontakt)
- Fallback auf Kundendaten wenn leer
- Nur bei Liefer-/Meldeadressen (nicht Rechnung)
- Namens-Kombinationen (Firma + Vorname + Nachname etc.)
- [x] **Gruppenauswahl Liefer-/Rechnungs-/Eigentümer-Adresse** im Auftragsgenerator
- [x] **Objekttyp + Lage + Lage des Anschlusses** bei Festnetz-Verträgen (DSL/Glasfaser/Kabel)
- [x] **Bankverbindung-Fallback** im PDF-Generator (neueste aktive Bankverbindung des Kunden)
-352
View File
@@ -1,352 +0,0 @@
# 🛡️ Security-Hardening die ganze Geschichte
Dokumentiert die acht Hardening-Runden, die OpenCRM zwischen erster
Code-Review und öffentlichem Deployment durchlaufen hat.
Format pro Runde: **Was war kaputt****Wie es gefixt wurde** → wo möglich
**Live-Test-Resultate**.
> Die ersten beiden Runden gibt es zusätzlich als ausführlicheren Review in
> [SECURITY-REVIEW.md](./SECURITY-REVIEW.md).
---
## 📊 Live-verifizierte Tests im Überblick
Die wichtigsten Schwachstellen wurden mit echten HTTP-Requests gegen den Dev-Server
durchgespielt statisches Code-Review fand ca. 70 % der Findings, die letzten 30 %
brauchten Live-Tests.
### Runde 4 IDOR an Customer-Sub-Resourcen (Live als Portal-Kunde)
| Endpoint | Vorher | Nachher |
| -------------------------------------------- | ------------------------------- | ---------------------------- |
| `GET /api/customers/4` | 🚨 **200 mit Daten** | ✅ 403 |
| `GET /api/customers/4/addresses` | 🚨 200 | ✅ 403 |
| `GET /api/customers/4/bank-cards` | 🚨 200 | ✅ 403 |
| `GET /api/customers/4/documents` | 🚨 200 | ✅ 403 |
| `GET /api/customers/4/meters` | 🚨 200 | ✅ 403 |
| `GET /api/customers/4/representatives` | 🚨 200 | ✅ 403 |
| `GET /api/gdpr/customer/4/consents` | 🚨 200 mit Consent-Daten | ✅ 403 |
| `GET /api/gdpr/customer/4/authorizations` | 🚨 200 | ✅ 403 |
| `GET /api/gdpr/customer/4/consent-status` | 🚨 200 | ✅ 403 |
| Eigene Daten `/api/customers/1` | ✅ 200 | ✅ 200 (unverändert) |
| 12 MB Body | 500 „Interner Serverfehler" | ✅ 413 „Anfrage zu groß" |
| Malformed JSON | 500 „Interner Serverfehler" | ✅ 400 „Ungültiges JSON" |
### Runde 5 DSGVO-GAU + Timing-Side-Channel
| Test | Vorher | Nachher |
| ------------------------------------------------- | --------------------------------------- | ---------------------------------- |
| `/api/uploads/cancellation-confirmations/*.pdf` | 🚨 **HTTP 200 mit echtem Kunden-PDF** | ✅ 401 ohne Token |
| `/api/uploads/...?token=<jwt>` | n/a | ✅ 200 |
| Login `admin@admin.com` (falsches Passwort) | 110 ms | 423 ms |
| Login `not-existent@x.de` | 10 ms (verräterisch) | 422 ms (matcht admin) |
| Portal-Lieferbestätigung-Upload auf fremden Vertrag | (per-Permission abgewehrt) | ✅ 403 |
### Runde 6 Customer-Liste-Leak + XFF-Bypass
| Test | Vorher | Nachher |
| --------------------------------------------- | --------------------------------------- | ---------------------------------------- |
| `GET /api/customers` als Portal | 🚨 **alle Kunden mit Namen/E-Mail** | ✅ nur eigene + vertretene |
| 12× Login mit rotierendem `X-Forwarded-For` | 🚨 alle 401, kein 429 | ✅ XFF nur von Loopback akzeptiert |
| Self-Grant (`representativeId == customerId`) | 🚨 DB-Eintrag erstellt | ✅ 400 |
| Authorization für non-existent Customer 9999 | 🚨 Prisma-Stack mit Pfaden geleakt | ✅ 403 generisch |
| Customer-Existence via 404-vs-403 | 🟡 enumerierbar | ✅ alle 403 uniform |
| Listen-Adresse (Production) | `0.0.0.0` (extern erreichbar) | `127.0.0.1` (nur via Reverse-Proxy) |
### Runde 7 SSRF + Logout
| Test | Vorher | Nachher |
| ----------------------------------------------------------- | --------------------- | ---------------------------------------- |
| `test-connection` mit `apiUrl=http://169.254.169.254` | 8 s Timeout (SSRF) | ✅ 400 „geblockte Adresse" |
| `test-mail-access` mit `smtpServer=metadata.google.internal`| Connection-Versuch | ✅ 400 |
| `test-mail-access` mit `0.0.0.0` | Connection-Versuch | ✅ 400 |
| `test-mail-access` mit `127.0.0.1` (Plesk-Fall) | OK | ✅ OK (weiter erlaubt) |
| `POST /api/auth/logout` | 404 (Endpoint fehlte) | ✅ 200 |
| `GET /me` nach Logout | weiter 200 (bis 7 d) | ✅ 401 „Sitzung ungültig" |
### Runde 8 DNS-Rebinding + Per-File-Ownership
| Test | Resultat |
| ----------------------------------------------------- | --------------------------------------------- |
| Admin lädt eigene Datei | ✅ HTTP 200, PDF |
| Portal lädt eigene Contract-Datei | ✅ HTTP 200, PDF |
| Portal lädt random Pfad ohne DB-Resource | ✅ HTTP 404 |
| Path-Traversal `..` im Pfad | ✅ HTTP 400 |
| URL-encoded Traversal `%2F..%2F` | ✅ HTTP 400 |
| Ohne Token | ✅ HTTP 401 |
| Backwards-Compat `/api/uploads/<path>` | ✅ HTTP 200 (intern derselbe Owner-Check) |
| Legitimer Hostname (gmail.com) | ✅ DNS-Resolve OK, normaler SMTP-Auth-Fail |
| Hostname mit interner Target-IP | ✅ HTTP 400 geblockt |
### Runde 9 Vorher überprüft, Dependency-Audit, Audit-Chain
| Test | Resultat |
| ---------------------------------------------------------- | ------------------------------------------------------- |
| `From`-Address-Header-Injection (CRLF in fromAddress) | ✅ bereits in Stage 3 abgefangen (`containsCRLF`) |
| `npm audit` (initial) | 9 Vulns (4× high) |
| `npm audit fix` | ✅ 8 transitive Vulns gefixt |
| nodemailer breaking-update auf 8.x | 📋 als v1.1-Item dokumentiert |
| Audit-Log Hash-Chain vor `rehashAll` | ⚠️ ~350 historische Einträge invalid (Schema-Migrationen) |
| Audit-Log Hash-Chain nach `rehashAll` | ✅ 4139 von 4140 valid (1 Race mit Verify-Aufruf selbst) |
| Authenticated Rate-Limit (50 parallele Requests) | 🟡 keiner DoS-Schutz vom Reverse-Proxy übernehmen |
| Frontend `localStorage` Token-Stealing-Vektor | 🟡 Standard-SPA-Pattern; DOMPurify schützt vor XSS-Klau |
---
## 🗂️ Runde-für-Runde
### Runde 1 Erste kritische Findings (statisches Review)
- CORS komplett offen → `CORS_ORIGINS` explizit
- Keine Security-Headers → Helmet aktiviert (HSTS, X-Frame-Options, nosniff …)
- JWT-Fallback-Secret entfernt → Fail-Fast beim Start (≥ 32 Zeichen JWT_SECRET, 64-Hex ENCRYPTION_KEY)
- IDOR bei 7 Contract-Endpoints (`canAccessContract`)
- XSS via Email-Body → DOMPurify mit strikter Config
- Customer-API: Passwort-Hashes in API-Responses → Sanitizer
- Portal-JWT-Invalidation nach Passwort-Reset (`portalTokenInvalidatedAt`)
- Body-Size-Limit 5 MB
### Runde 2 Deep-Dive (parallele Audit-Agents)
- **Zip-Slip im Backup-Upload** (Arbitrary File Write) → Pfad-Validation
- **Mass Assignment bei Customer/User** (Privilege Escalation via `roleIds`!) → Whitelist-Picker
- 13 weitere IDOR-Stellen (Meter-Readings, Email-Anhänge, StressfreiEmail-Credentials …)
- Path-Traversal bei Backup-Name und GDPR-Proof-Download → Regex/Safelist
### Runde 3 Tiefer Dive (8 weitere Hardenings)
- JWT algorithm confusion: `jwt.verify(..., { algorithms: ['HS256'] })`
- `trust proxy = 1` für Rate-Limiter hinter Reverse-Proxy
- IDOR Invoice (`/api/energy-details/:ecdId/invoices`) → `canAccessEnergyContractDetails`
- IDOR PDF-Template-Generator → `canAccessContract`
- Email-Anhang-Download: Content-Type-Safelist (HTML/SVG nie inline) + `X-Content-Type-Options: nosniff` + Filename-CRLF-Sanitizing
- Provider/Tariff-GETs: `requirePermission('providers:read')` (Portal-Kunden sehen Provider-Liste nicht mehr)
- SMTP-Header-Injection: zentrale CRLF-Validierung in `smtpService.sendEmail`
- bcrypt cost 10 → 12 (OWASP 2026)
### Runde 4 Live-Tests gegen Dev-Server (Tabelle oben)
`getCustomer`, alle Customer-Sub-Resources (addresses/bank-cards/…) und die
GDPR-Endpoints hatten nur Daten-Sanitizer, aber keinen `canAccessCustomer`-Check.
Portal-Kunde konnte live `GET /api/customers/<fremde-id>` machen → **9 IDORs**.
Plus Error-Handler: `err.status` wird respektiert (413/400 statt pauschalem 500).
### Runde 5 Hack-Das-Ding-Audit
- 🚨 **`/api/uploads/*` ohne Auth** (DSGVO-GAU) → `authenticate`-Middleware,
Frontend-Helper `fileUrl(path)` hängt Token an, 24 URLs migriert.
- **Login-Timing-Side-Channel**: 110 ms vs 10 ms → Dummy-bcrypt-compare
(Cost 12) bei invalid user + Lazy-Rehash alter Cost-10-Hashes beim Login.
- **XSS via Privacy Policy / Imprint** in 4 Frontend-Seiten → DOMPurify.
- IDOR-Härtung an 5 weiteren Upload/Delete/Email-Save-Stellen
(`canAccessContract`).
### Runde 6 Tiefer Live-Pentest (Tabelle oben)
- 🚨 **`GET /api/customers` Customer-Liste-Leak** → Portal-Filter
- 🚨 **Rate-Limit-Bypass via X-Forwarded-For**`trust proxy = 'loopback'`
+ `LISTEN_ADDR=127.0.0.1` in Production
- Self-Grant + Existence-Disclosure in `toggleMyAuthorization` → Self-Grant
400, Existenz + aktive `CustomerRepresentative`-Beziehung in einem Query,
beide Fehlfälle uniform 403.
- Prisma-Error-Leaks generisch ersetzt.
### Runde 7 Letzter Schliff
- **SSRF-Schutz** in `test-connection` und `test-mail-access`
`utils/ssrfGuard.ts` blockiert 169.254.0.0/16, 0.0.0.0/8,
Multicast/Reserved-Ranges, AWS-IPv6-Metadata, IPv6-Link-Local und
Cloud-Metadata-Hostnames. Loopback bleibt erlaubt für Plesk/Postfix.
- **Logout-Endpoint** `POST /api/auth/logout` setzt `tokenInvalidatedAt`
/ `portalTokenInvalidatedAt` auf jetzt.
### Runde 8 Loose Ends
- **DNS-Rebinding-Schutz**: `safeResolveHost()` löst Hostnames vor Connect
zu IPs auf, prüft jede gegen die Block-Liste, gibt `{ ip, servername }`
zurück. Connection läuft gegen IP, der Hostname als TLS-SNI ein
zweiter DNS-Lookup kann keine geblockte IP unterschieben.
- **Per-File-Ownership-Check**: `app.use('/api/uploads', authenticate,
express.static)` ersetzt durch `GET /api/files/download?path=...` mit
DB-Lookup (`fileDownload.service.ts`). 12 subDir-Mappings → Customer
oder Contract → `canAccessCustomer`/`canAccessContract`. Backwards-
Compat-Shim für `/api/uploads/*` ruft denselben Owner-Check.
### Runde 10 Security-Monitoring + Alerting
Defense-in-Depth: was nicht durch Code-Härtung verhindert wurde, soll jetzt
zumindest **gesehen** werden. Ergänzt:
- **Neues Modell `SecurityEvent`** (Prisma) mit Type/Severity/IP/User/Endpoint
+ Indexen für schnelles Filter+Threshold-Detection.
- **Service `securityMonitor.service.ts`** mit zentraler `emit()`-Funktion.
Hooks an: Login (Success/Failed), Logout, Rate-Limit-Hit, IDOR-403
(`canAccessCustomer`/`canAccessContract`), SSRF-Block, Password-Reset
(Request + Confirm), JWT-Reject (`alg=none`, expired etc.).
- **Service `securityAlert.service.ts`** mit:
- **Threshold-Detection** (jede Minute via Cron): >10 LOGIN_FAILED/h aus
gleicher IP, >5 ACCESS_DENIED/5min, >3 SSRF_BLOCKED/h, >3 TOKEN_REJECTED
HIGH/5min → erzeugt synthetische CRITICAL-Events.
- **Sofort-Alert**: CRITICAL-Events werden binnen 1 Minute per Email versendet
(debounced, max. 1× pro Stunde + IP).
- **Hourly-Digest**: HIGH+MEDIUM-Events der letzten Stunde gesammelt
in einer Mail (wenn `monitoringDigestEnabled = true`).
- **Settings-Page „Sicherheits-Monitoring"** in Einstellungen:
Alert-E-Mail-Feld, Digest-Toggle, Test-Alert-Button, Digest-jetzt-Button,
Stats-Cards pro Severity, Filter (Type/Severity/Search/IP), Pagination,
Auto-Refresh alle 30s.
- **API-Routes** unter `/api/monitoring/{events,settings,test-alert,run-digest}`
alle hinter `settings:read` / `settings:update`.
Live-verifiziert (1. Mai 2026):
| Test | Resultat |
| --------------------------------------------------- | --------------------------------------------------- |
| Login-Fehlversuch | ✅ `LOW LOGIN_FAILED` Event erzeugt |
| Login-Erfolg | ✅ `INFO LOGIN_SUCCESS` Event |
| Portal-User probiert 4× fremde Customer-IDs | ✅ 4× `MEDIUM ACCESS_DENIED` Events |
| Admin SSRF-Probe (169.254.169.254) | ✅ `HIGH SSRF_BLOCKED` Event |
| 12× LOGIN_FAILED von gleicher IP innerhalb 60 min | ✅ Cron erzeugt `CRITICAL SUSPICIOUS` Event nach ≤60s |
| CRITICAL-Sofort-Alert per E-Mail | ✅ binnen 30 s zugestellt |
| Test-Alert-Button | ✅ E-Mail mit Test-Marker zugestellt |
| Hourly-Digest mit 5 Events | ✅ E-Mail mit Tabellen-Übersicht zugestellt |
### Runde 9 Diminishing-Returns-Runde
Nichts Kritisches mehr gefunden. Liefert noch:
- **Dependency-Update**: `npm audit fix` reduziert von 9 auf 1 Vulnerability
(lodash, path-to-regexp, undici, minimatch transitiv geupdatet). Verbliebene
nodemailer-Vuln braucht Major-Update (v6 → v8) v1.1-Item.
- **Audit-Log-Hash-Chain**: war historisch invalid (~350 Einträge) durch
frühere Schema-Migrationen, nicht durch Manipulation. `rehashAll`
repariert; integrity-check verifiziert die Chain wieder. Verfahren
funktioniert also wäre eine echte Manipulation, würde sie auffallen.
- **From-Header-Injection** (Stage 3 hatte to/cc/subject geprüft): die
zentrale `containsCRLF`-Prüfung deckt auch `fromAddress` ab. ✅
- **Concurrent Password-Reset Race**: Token wird nach erstem Confirm
atomar gelöscht zweiter Versuch findet keinen Token. ✅
---
## 🔧 Geprüft + sauber (kein Bug, aber explizit getestet)
- Prototype Pollution beim Login (Body mit `__proto__` → kein Effekt)
- HTTP-Method-Override-Header (X-HTTP-Method-Override: DELETE → ignoriert)
- Path-Traversal in Backup-Name (Regex blockiert)
- Developer-Routes existieren nicht (`/api/developer/*` → 404)
- Email-Endpoints (Send/Sync/Read mit fremder StressfreiEmail-ID) → 403
- Self-grant Vollmacht via `customers/X/representatives` → 403
- `/api/customers/:id` GET liefert 403 für fremde, kein 404-Existence-Leak
- Public Consent Endpoint: 122-bit Random-UUID, nicht brute-force-bar
- Magic-Bytes-Bypass beim Upload: HTML als image/png → blockiert
- PDF-Generation mit injizierten manualValues: kein XSS-Vektor (PDFs sind keine Web-Renderer)
- Audit-Logs / Email-Config / Backup-Endpoints als Portal: 403
- Query-Filter-Override (`?customerId=X`) → vom Portal-Filter ignoriert
---
## 📋 Bewusst NICHT gemacht (Trade-off, aber dokumentiert)
- **Signierte URLs mit kurzlebigen Download-Tokens** statt JWT-im-Query
(verhindert Token-Leak via Logs/Referrer). Nicht trivial wegen
`<a href>`-Downloads ohne JS v1.2-Item.
- **`/api/contracts/:id` GET liefert 404 für nicht-existente IDs**
(Existence-Probing). Vereinheitlichung auf 403 wäre sauberer; da
Contract-IDs aber nicht direkt mit personenbezogenen Daten korrelieren,
niedrig-Prio.
- **Prisma-Error-Leaks in anderen Admin-Endpoints** (z. B. `addInvoice`
bei Validation-Fehler) Defense-in-Depth-Kandidat, aber nur Admin-
erreichbar.
- **TipTap-Link-Tool**: `javascript:`-Protokoll blockieren (Admin-only
erreichbar, niedrig-Prio).
- **Authenticated Rate-Limit** auf alle GET-Endpoints: aktuell sind nur
Login + Password-Reset rate-limited. Eingeloggte User können theoretisch
hunderte Requests/sec fahren. Schutz ist Aufgabe des Reverse-Proxy
(Nginx/Plesk haben eigene Limits) nicht im App-Layer. Wenn nötig,
später `express-rate-limit` für `/api/*` mit hohem Limit (~600/min/IP).
- **JWT in `localStorage`** statt HttpOnly-Cookie: Standard-SPA-Pattern,
XSS-resistent durch DOMPurify in allen Render-Stellen + CSP via
Helmet. HttpOnly-Cookie wäre stärker, brauchte aber CSRF-Token-System.
- **nodemailer 6 → 8 Major-Update**: ein npm-audit-Vuln-Fix offen
(SMTP-CRLF in `envelope.size` / Transport-Name). Wir setzen diese
Felder nicht aus User-Input Risiko gering, Update breaking.
---
## 🚀 Production-Deployment-Checkliste
Vor dem öffentlichen Schalten muss in der Production-`.env`:
- `JWT_SECRET` rotieren: `openssl rand -hex 64`
- `ENCRYPTION_KEY` rotieren: `openssl rand -hex 32` (genau 64 Hex-Zeichen)
- `NODE_ENV=production`
- `CORS_ORIGINS=https://deine-domain.de` (oder leer für Same-Origin)
- `LISTEN_ADDR=127.0.0.1` (nur lokaler Reverse-Proxy darf connecten)
- Reverse-Proxy (Nginx/Plesk) so konfigurieren, dass `X-Forwarded-For`
hart auf die echte Client-IP gesetzt wird (nicht angefügt) sonst
Rate-Limit-Bypass möglich.
- Manuelle Test-Checkliste aus [TESTING.md](./TESTING.md) einmal komplett
durchklicken.
---
## 🔄 Lazy Password-Hash-Upgrade
Bestandsuser mit bcrypt-Cost 10 (aus der Installation) werden beim ersten
Login transparent auf Cost 12 rehashed. Damit gleicht sich die
Antwortzeit beim Login automatisch der Dummy-bcrypt-Zeit (Cost 12) an
Login-Timing-Side-Channels schließen sich von alleine im Lauf der ersten
Wochen nach Deployment.
---
## 🗨️ Lehre aus der Session
Statische Audit-Agents finden ca. 70 % der Findings, die letzten ~30 %
brauchten Live-Tests gegen den laufenden Server. Sie kennen den exakten
Permission-State der DB nicht (raten z. B., dass `gdpr:export` Portal-
User-zugänglich sei war's nicht), übersehen aber, dass ein
Daten-Sanitizer einen Permission-Check vortäuschen kann (Runde 4 / 6).
**Take-away:** „Code sieht sicher aus" ≠ „Server verhält sich sicher".
Vor jedem Launch mit echten Tokens probieren.
---
## 📑 Commit-Historie
| Commit | Runde | Hauptthema |
| --------- | ------- | -------------------------------------------------------------- |
| (mehrere) | 1 + 2 | Erste Review-Welle, dokumentiert in SECURITY-REVIEW.md |
| (mehrere) | 3 | JWT alg, trust-proxy, Invoice/PDF IDOR, Attachment, Provider, SMTP-CRLF, bcrypt |
| `334c408` | 4 | 9 Live-IDORs (customer.* + gdpr.*) + Error-Handler |
| `8be9bae` | 5 | Uploads-Auth + Login-Timing + XSS |
| `4e91d96` | 6 | Customer-List-Leak + XFF-Bypass + Auth-Toggle |
| `12b9abe` | 7 | SSRF-Schutz + Logout |
| `d063d67` | 8 | DNS-Rebinding + Per-File-Ownership |
| `c9a2b9f` | 9 | `npm audit fix` + Audit-Chain-Rehash + Doku |
| (folgt) | 10 | Security-Monitoring (SecurityEvent + Hooks + Alerts + UI) |
---
## 🧭 Wann ist „dicht" dicht?
100 % gibt es nicht. Erreicht ist:
1. **Mehrere Audit-Methoden durch** statisches Code-Review, parallele
Audit-Agents, dynamischer Live-Pentest mit echten Tokens. ✓
2. **OWASP-Top-10 explizit getestet** Auth, Access-Control, Injection,
Crypto-Failures, SSRF, XSS, IDOR, Logging, Misconfig, Vulnerable Deps. ✓
3. **Diminishing returns** Runde 9 fand keine kritischen Findings mehr,
nur Dependency-Updates und Doku-Updates. ✓
4. **Production-Deployment-Checkliste klar.**
5. **Audit-Log + Hash-Chain** falls trotz allem etwas durchrutscht,
sieht man's hinterher. ✓
Was bleibt: zero-days in Dependencies (deshalb regelmäßiges `npm audit`),
neue Angriffsklassen, Server-Misconfig in Production, Social Engineering.
Dafür gibt's keine Code-Lösung nur Monitoring und Rotation der Secrets.
-231
View File
@@ -1,231 +0,0 @@
# Security-Review vor 1.0.0
> 📌 **Diese Datei dokumentiert nur die ersten 2 Runden ausführlich.**
> Die vollständige Hardening-Story über alle **8 Runden** inkl. Live-Test-
> Tabellen findest du in **[SECURITY-HARDENING.md](./SECURITY-HARDENING.md)**.
> **Version 2** dieser Review wurde in 2 Runden durchgeführt.
> Runde 1: erste kritische Findings (CORS, Helmet, JWT-Fallback, grobes IDOR, XSS, Data Exposure).
> Runde 2 (weiter unten): **Deep-Dive** mit parallelen Audit-Agents fand weitere IDOR-Stellen, Mass Assignment, Zip-Slip, Path-Traversal.
Systematischer Review des Codebase mit Fokus auf Produktions-Hardening
vor öffentlichem Deployment (hinter HTTPS-Proxy).
## Gefundene Probleme & Fixes
### 🔴 KRITISCH (sofort gefixt)
#### 1. CORS komplett offen
**Vorher:** `app.use(cors())` jede Origin darf Requests senden.
**Risiko:** Fremde Websites können bei eingeloggtem User Requests mit dessen
JWT durchführen (wenn Token in Cookies wäre bei localStorage weniger relevant,
aber trotzdem schlechte Praxis).
**Fix:** CORS nur für explizit konfigurierte Origins (via `CORS_ORIGINS` ENV),
in Production per Default komplett aus (Frontend läuft unter gleicher Origin).
#### 2. Keine Security-Headers (Helmet fehlt)
**Vorher:** Keine HTTP-Security-Headers gesetzt.
**Risiko:** XSS, Clickjacking, MIME-Sniffing, Missing HSTS.
**Fix:** `helmet`-Middleware aktiviert setzt automatisch:
X-Frame-Options, X-Content-Type-Options, Referrer-Policy, HSTS (in HTTPS),
Cross-Origin-Resource-Policy.
#### 3. JWT-Fallback-Secret
**Vorher:** `jwt.verify(token, process.env.JWT_SECRET || 'fallback-secret')`
**Risiko:** Wenn `.env` kaputt ist oder Secret leer → bekannter String
"fallback-secret" → **Tokens können gefälscht werden!**
**Fix:** Beim Server-Start wird geprüft, dass JWT_SECRET mindestens 32 Zeichen lang
und ENCRYPTION_KEY exakt 64 Hex-Zeichen hat. Sonst Abbruch mit klarer Fehlermeldung.
Fallback wurde aus dem Code entfernt.
#### 4. IDOR bei sensiblen Contract-Endpoints
**Vorher:** Portal-Kunden haben `contracts:read` Permission → können über
geratene IDs auf **fremde** Daten zugreifen:
- `GET /contracts/:id/password` → Passwort im Klartext
- `GET /contracts/simcard/:id/credentials` → PIN/PUK
- `GET /contracts/:id/internet-credentials` → Internet-Passwort
- `GET /contracts/phonenumber/:id/sip-credentials` → SIP-Passwort
- `GET /contracts/:id/documents` → Vertragsdokumente
- `GET /contracts/:id/invoices` → Rechnungen
- `POST /contracts/:id/invoices` → Rechnung zu fremdem Vertrag hinzufügen
**Fix:** Neuer Helper `canAccessContract()` in `backend/src/utils/accessControl.ts`.
Wird in allen sensiblen Endpoints aufgerufen und prüft:
- Mitarbeiter/Admin → OK
- Portal-Kunde + eigener Vertrag → OK
- Portal-Kunde + vertretener Kunde MIT gültiger Vollmacht → OK
- Sonst 403 Forbidden
#### 5. XSS via Email-Body
**Vorher:** `<div dangerouslySetInnerHTML={{ __html: email.htmlBody }} />`
**Risiko:** Ein Angreifer sendet Mail mit `<script>fetch('/api/...')`
wird im Browser des Mitarbeiters ausgeführt → JWT-Token-Diebstahl möglich.
**Fix:** DOMPurify sanitized `htmlBody` vor dem Rendern:
- Verbietet: script, style, iframe, object, embed, form, inline-handler
- Erlaubt: normale Formatierung, Bilder, Links
- Zusätzlich: target=_blank damit Links neue Tabs öffnen
#### 6. Customer-API leakt Passwort-Hashes + Reset-Tokens
**Vorher:** `getCustomer` / `getCustomers` gab alle Felder zurück inklusive:
- `portalPasswordHash` (bcrypt)
- `portalPasswordEncrypted` (symmetrisch, entschlüsselbar mit Key)
- `portalPasswordResetToken` (gültig 2h, damit könnte man das Passwort zurücksetzen)
**Fix:** Zentrale Sanitizer-Helper in `backend/src/utils/sanitize.ts`:
- `sanitizeCustomer` → entfernt Hash + Reset-Token
- `sanitizeCustomerStrict` → zusätzlich ohne Encrypted-Passwort
(für Nicht-Admin-Rollen)
- Im `getCustomer`/`getCustomers` angewendet: Admins sehen encrypted
(um Passwort in UI anzeigen zu können), alle anderen nicht.
### 🟡 WICHTIG (gefixt)
#### 7. Portal-JWT-Invalidation fehlte
**Vorher:** Nach einem Portal-Passwort-Reset blieben alte JWTs bis zum Ablauf (7d) gültig.
**Risiko:** Wenn ein Angreifer einen Token geklaut hat, konnte der Kunde das
Passwort zwar ändern, aber der Angreifer blieb eingeloggt.
**Fix:** Neues Feld `Customer.portalTokenInvalidatedAt` analog zu
`User.tokenInvalidatedAt`. Wird bei Portal-Passwort-Reset auf `now()` gesetzt.
Auth-Middleware prüft bei Portal-Sessions diesen Timestamp gegen `token.iat`.
#### 8. express.json() ohne Size-Limit
**Vorher:** Default 100KB aber unklar und nicht explizit.
**Fix:** `express.json({ limit: '5mb' })` deckt normale API-Bodies mit
eingebetteten Base64-Attachments ab, blockt aber DoS-Versuche mit 100MB-Payloads.
## Runde 2: Deep-Dive mit Audit-Agents (alle kritischen gefixt)
### 🔴 Kritisch
#### 9. Zip-Slip im Backup-Upload
**Vorher:** `zip.extractAllTo(finalBackupDir, true)` in
`backup.service.ts` extrahiert ZIP-Dateien ohne Validierung der Entry-Pfade.
**Risiko:** Ein Angreifer lädt ein bösartiges ZIP hoch mit Entries wie
`../../../etc/crontab` → Server-Filesystem-Overwrite, Root-Escalation möglich.
**Fix:** ZIP-Entries werden jetzt einzeln durchlaufen. Jeder `entry.entryName`
wird `path.resolve`-normalisiert und geprüft ob der Zielpfad innerhalb des
Backup-Verzeichnisses bleibt. Absolute Pfade + Null-Bytes werden abgelehnt.
#### 10. Mass Assignment bei Customer/User
**Vorher:** `updateCustomer`, `createCustomer`, `updateUser`, `createUser`
haben `req.body` direkt oder via Spread an Prisma-`update/create` gereicht.
**Risiko:**
- Ein Angreifer mit `customers:update`-Permission konnte `portalPasswordHash`
(bcrypt-Hash!), `portalPasswordResetToken`, `consentHash`, `customerNumber`
direkt setzen
- Bei User-Update: `roleIds: [1]` übergeben → **Privilege Escalation** zum Admin
- `isActive: false` → andere User deaktivieren
**Fix:** Neue Whitelist-Helper `pickCustomerUpdate/Create`, `pickUserUpdate/Create`
in `utils/sanitize.ts`. Nur explizit erlaubte Felder gehen an Prisma durch.
Kritische Felder wie `portalPasswordHash`, `customerNumber`, `id`, `createdAt`,
`consentHash` sind **nicht** übernehmbar.
#### 11. IDOR bei weiteren sensiblen Endpoints
Nach der ersten Runde kam der Agent auf **13 weitere IDOR-Stellen**:
- `GET /meters/:meterId/readings` → fremde Zählerstände
- `GET /emails/:emailId/attachments/*` → fremde Email-Anhänge
- `GET /customers/:customerId/emails` → fremde Email-Inhalte (CachedEmail)
- `GET /contracts/:contractId/emails` → fremde Email-Inhalte per Vertrag
- `GET /emails/:id` → einzelne Email lesen
- `GET /stressfrei-emails/:id` → leakt `emailPasswordEncrypted`
- weitere…
**Fix:** `utils/accessControl.ts` deutlich ausgebaut um:
- `canAccessAddress`
- `canAccessBankCard`
- `canAccessIdentityDocument`
- `canAccessMeter`
- `canAccessStressfreiEmail`
- `canAccessCachedEmail`
Diese Helper laden die Ressource, prüfen die customerId und delegieren an
`canAccessCustomer` (Portal-Isolation + Vollmachten). In allen kritischen
Endpoints vor dem eigentlichen Datenzugriff aufgerufen.
Zusätzlich: `getEmail` (StressfreiEmail) filtert `emailPasswordEncrypted`
für Portal-Kunden explizit raus, selbst wenn sie zufällig Zugriff haben.
### 🟡 Wichtig
#### 12. Path-Traversal bei Backup-Namen
**Vorher:** `req.params.name` wurde direkt an `fs.readFile(path.join(backupDir, name))`
weitergereicht. `../` würde aus dem Backup-Verzeichnis ausbrechen.
**Fix:** Neuer `isValidBackupName()`-Guard: nur `[A-Za-z0-9_-]+`, kein `..`.
#### 13. Path-Traversal bei GDPR-Proof-Download
**Vorher:** `path.join(uploads, request.proofDocument)` ohne Validation.
Wenn ein Angreifer den `proofDocument`-Pfad in der DB manipulieren könnte
(z.B. über Mass-Assignment das haben wir aber oben gefixt), wäre arbitrary
file download möglich.
**Fix:** `path.resolve` auf den Pfad anwenden, prüfen dass das Ergebnis im
uploads-Verzeichnis liegt.
---
## Nicht kritische Findings (Empfehlungen für später)
### 🟢 Token in Query-Parameter
Für Attachment-Downloads/iframes wird das JWT als `?token=...` mitgegeben.
**Risiko:** Token landet in Server-Access-Logs, Browser-History, Referer-Headers.
**Mitigation aktuell:** JWT läuft nach 7d ab, und bei `password-reset` werden
alle Sessions gekickt.
**Bessere Lösung (später):** Kurzlebige Download-Tokens (5 Min) statt JWT direkt.
### 🟢 Upload: nur Browser-MIME-Check
Multer prüft nur den vom Browser gesendeten Content-Type. Ein Angreifer könnte
eine Shell mit `application/pdf` hochladen.
**Mitigation aktuell:**
- Uploads-Ordner hat keine Execute-Rechte (Linux-Standard)
- Dateien werden mit uniquem Namen + Original-Extension gespeichert
- Apache/Caddy served Uploads mit `Content-Disposition: attachment` inline (keine Ausführung)
**Besser (später):** Magic-Byte-Check via `file-type` npm-Paket.
### 🟢 `.env` in git history
Die initiale `.env` mit Demo-Secrets ist im ersten Commit eingecheckt.
**Risiko:** Wenn das Repo öffentlich wird, sind die Demo-Keys bekannt.
**Action:** Vor Öffentlich-Machen: `openssl rand -hex 64` für neuen JWT_SECRET
und `openssl rand -hex 32` für neuen ENCRYPTION_KEY in `.env.production`.
Optional: `git filter-repo` um `.env` aus History zu löschen.
## Deployment-Checkliste vor Go-Live
- [ ] **ENV-Vars setzen:**
- `JWT_SECRET` neu generiert (`openssl rand -hex 64`)
- `ENCRYPTION_KEY` neu generiert (`openssl rand -hex 32`)
- `NODE_ENV=production`
- `CORS_ORIGINS=https://crm.meinedomain.de` (oder leer wenn SPA unter gleicher Origin)
- `PUBLIC_URL=https://crm.meinedomain.de` (für Reset-Links in E-Mails)
- [ ] **Helmet HSTS aktiv** (automatisch mit helmet + HTTPS hinter Caddy)
- [ ] **Dependencies aktuell:** `npm audit fix` lauen lassen
- [ ] **DB-User minimal:** Prod-User darf nur INSERT/UPDATE/DELETE/SELECT auf opencrm DB,
nicht DROP/ALTER/CREATE
- [ ] **Uploads-Ordner:** chmod 750, keine Execute-Rechte
- [ ] **Backup-Job:** Crontab mit täglichem `npm run db:backup`
- [ ] **Log-Rotation:** logrotate für Node-Process-Logs
- [ ] **Monitoring:** uptime-kuma o.Ä. auf `/api/health`
- [ ] **Reverse-Proxy (Caddy) setzt:**
- HSTS (mindestens 1 Jahr)
- automatisches SSL via Let's Encrypt
- Body-Size-Limit (Caddy-Config)
## Was getestet werden MUSS (vor öffentlichem Deployment)
1. **IDOR-Tests:** Als Portal-Kunde A einloggen, fremde IDs per URL/API probieren
→ alle müssen 403 geben (siehe TESTING.md)
2. **XSS-Tests:** Test-Mail mit `<script>alert(1)</script>` in HTML-Body senden,
im Email-Client öffnen → kein Alert
3. **Rate-Limit-Tests:** 11x falsch einloggen → muss blocken
4. **Password-Reset-Tests:** Reset-Link 2x nutzen → zweites Mal fehlschlägt
## Übersicht der Code-Änderungen
| Datei | Änderung |
|---|---|
| `backend/src/index.ts` | Helmet, CORS-Config, Body-Limit, ENV-Check beim Start |
| `backend/src/middleware/auth.ts` | JWT-Fallback raus, Portal-Token-Invalidation |
| `backend/src/services/auth.service.ts` | JWT-Fallback raus, `portalTokenInvalidatedAt` setzen |
| `backend/src/utils/accessControl.ts` | **NEU** `canAccessContract`, `canAccessCustomer` |
| `backend/src/utils/sanitize.ts` | **NEU** Sanitizer für Customer/User |
| `backend/src/controllers/contract.controller.ts` | IDOR-Schutz in 5 Endpoints |
| `backend/src/controllers/invoice.controller.ts` | IDOR-Schutz in 2 Endpoints |
| `backend/src/controllers/customer.controller.ts` | Sanitizer in getCustomer/getCustomers |
| `backend/prisma/schema.prisma` | `Customer.portalTokenInvalidatedAt` |
| `frontend/src/components/email/EmailDetail.tsx` | DOMPurify für htmlBody |
-172
View File
@@ -1,172 +0,0 @@
# Manueller Test-Katalog (v1.0.0)
Checklisten für manuelle Abnahmetests vor einem Release. Durchläuft die kritischen
Features Schritt für Schritt. Geschätzte Dauer für einen kompletten Durchlauf: ~60 Minuten.
---
## 🔐 Security-System
### 1. Login & Rate-Limiting
- [ ] **Mitarbeiter-Login** mit korrekten Credentials → Erfolgreich
- [ ] **Mitarbeiter-Login** mit falschem Passwort → Fehlermeldung "Ungültige Anmeldedaten"
- [ ] **Portal-Login** mit Kunden-E-Mail + Passwort → Erfolgreich ins Portal
- [ ] **Rate-Limit Login**: 10× falsch nacheinander versuchen → Nach 10. Versuch: "Zu viele
Login-Versuche. Bitte in 15 Minuten erneut versuchen."
- [ ] **Rate-Limit zählt erfolgreiche Logins nicht**: 5× falsch, dann 1× korrekt, dann wieder
5× falsch → immer noch erlaubt (weil erfolgreiche nicht zählen)
### 2. Passwort-Reset-Flow
- [ ] Auf Login-Seite: Link **"Passwort vergessen?"** sichtbar
- [ ] Klick öffnet `/password-reset/request`
- [ ] **Unbekannte E-Mail** eingeben → Trotzdem "E-Mail gesendet"-Bestätigung
(User-Enumeration-Schutz: Backend verrät nicht, ob Email existiert)
- [ ] **Bekannte Mitarbeiter-E-Mail** eingeben, Typ "Mitarbeiter" wählen → Reset-Mail geht raus
- [ ] Reset-Link aus Mail öffnen → Formular "Neues Passwort"
- [ ] **Passwörter stimmen nicht überein** → Fehlermeldung
- [ ] **Passwort < 6 Zeichen** → Fehlermeldung
- [ ] Gültiges Passwort setzen → "Passwort geändert", Redirect zu /login
- [ ] **Neuer Login** mit dem neuen Passwort → Funktioniert
- [ ] **Alter Session** (falls vorher eingeloggt) → Wurde gekickt, muss neu einloggen
- [ ] **Reset-Link ein zweites Mal nutzen** → Fehlermeldung "Ungültiger oder bereits verwendeter Link"
- [ ] **Reset-Link nach 2h+** nutzen → Fehlermeldung "Der Link ist abgelaufen"
- [ ] Gleicher Flow für **Portal-Kunden** (Typ "Kunde" wählen)
### 3. Rate-Limiting Passwort-Reset
- [ ] 5× Reset-Anfrage für dieselbe E-Mail senden → OK
- [ ] 6. Anfrage innerhalb einer Stunde → "Zu viele Passwort-Reset-Anfragen"
### 4. Berechtigungen (RBAC)
- [ ] **Admin**-User: kann Benutzer, Rollen, Einstellungen verwalten
- [ ] **Mitarbeiter**-User: kann Kunden/Verträge bearbeiten, **keine** User-Verwaltung
- [ ] **Mitarbeiter (Lesen)**: alles sichtbar, aber Buttons "Bearbeiten/Löschen" fehlen
- [ ] **Portal-Kunde**: sieht **nur eigene** Verträge + Daten, nicht die anderer Kunden
### 5. Portal-Isolation (wichtig für DSGVO)
- [ ] Als Portal-Kunde A einloggen
- [ ] In der URL manuell `/customers/999` eintippen (anderer Kunde) → Zugriff verweigert
- [ ] `/contracts/999` (fremder Vertrag) → Zugriff verweigert
- [ ] API-Call via Browser-Devtools `GET /api/customers/999` → 403 Forbidden
- [ ] Nur mit **Vollmacht** (RepresentativeAuthorization) kann Kunde A die Daten von B sehen
### 6. Session-Invalidation
- [ ] Eingeloggt als Mitarbeiter, in 2 Browser-Tabs
- [ ] Admin ändert Rolle des Mitarbeiters (User-Verwaltung)
- [ ] Nächster Request im Tab → wird zum Login redirectet (tokenInvalidatedAt greift)
### 7. Audit-Log
- [ ] Einstellungen → Audit-Protokoll öffnen
- [ ] Kunde anlegen → Eintrag mit Typ CREATE erscheint
- [ ] Kunden-Feld ändern (z.B. Geburtsort) → UPDATE-Eintrag mit Vorher/Nachher-Details
- [ ] Kunde löschen → DELETE-Eintrag
- [ ] DSGVO-Export herunterladen → EXPORT-Eintrag
- [ ] Filter nach Benutzer funktioniert
- [ ] Filter nach Aktion funktioniert
- [ ] Details-Modal zeigt Vorher/Nachher-Werte
### 8. DSGVO-Features
- [ ] Kunde einlegen → DSGVO-Tab
- [ ] Datenexport ausführen → JSON-Datei mit allen Daten des Kunden
- [ ] Löschanfrage erstellen → erscheint im DSGVO-Dashboard (Admin)
- [ ] Anonymisierung ausführen → Kundendaten werden anonymisiert, aktive Verträge bleiben
- [ ] Einwilligungen (alle 4 Typen) können pro Kunde gesetzt/widerrufen werden
- [ ] PDF-Upload als Alternative zu Online-Einwilligungen → Haken werden auf GRANTED gesetzt
- [ ] PDF löschen → Haken werden auf WITHDRAWN gesetzt
### 9. Verschlüsselte Credentials
- [ ] Ein Portal-Passwort (z.B. eines Anbieter-Zugangs) speichern
- [ ] In der DB (z.B. via Prisma Studio oder DB-GUI) nachschauen:
`portalPasswordEncrypted` darf **nicht im Klartext** sichtbar sein
- [ ] Portal-Passwort in der UI anzeigen → wird korrekt entschlüsselt
### 10. DSGVO-Einwilligung Mitarbeiter
- [ ] Als Mitarbeiter Kunde öffnen OHNE Einwilligung → Tabs Zähler/Verträge/Bankkarten/Ausweise/Email gesperrt
- [ ] Nach Einwilligung (alle 4 Haken oder PDF) → Tabs wieder zugänglich
---
## ✉ Email-Log-System
### 1. Email-Log-Seite öffnen
- [ ] Einstellungen → **Email-Protokoll** öffnen
- [ ] Statistik-Cards werden angezeigt: Gesamt, Erfolgreich, Fehlgeschlagen, Letzte 24h
- [ ] Log-Liste zeigt vergangene Versendungen
- [ ] Filter: **Erfolgreich/Fehlgeschlagen**
- [ ] Filter: **Kontext** (Datenschutz-Link, Vollmacht-Anfrage, Kunden-E-Mail, Geburtstagsgruß, Passwort-Reset)
- [ ] Suche nach Empfänger-E-Mail oder Betreff
- [ ] Pagination (Seite 1, 2, 3 …)
- [ ] Klick auf Eintrag → Details-Modal mit SMTP-Info, Message-ID, ggf. Fehlermeldung
### 2. Erfolgreicher Versand loggen alle Kontexte durchspielen
Für jeden Kontext: Aktion im CRM durchführen → danach im Email-Log prüfen,
ob der Eintrag erstellt wurde.
#### Datenschutz-Link
- [ ] Kunde öffnen → Einwilligungen/Datenschutz-Tab → "Link per Email senden"
- [ ] Log-Eintrag mit Kontext "Datenschutz-Link", Empfänger = Kunden-E-Mail, Status ✓
#### Vollmacht-Anfrage
- [ ] Kunde A mit Vertreter B → Vollmachten-Tab → "Anfrage per Email senden"
- [ ] Log-Eintrag mit Kontext "Vollmacht-Anfrage"
#### Kunden-E-Mail (via Email-Client)
- [ ] Kunde öffnen → Email-Tab → Mail verfassen und senden
- [ ] Log-Eintrag mit Kontext "Kunden-E-Mail"
#### Geburtstagsgruß (manuell)
- [ ] Kunde mit Geburtsdatum → Cake-Button → "Gruß jetzt senden" → Email
- [ ] Log-Eintrag mit Kontext "Geburtstagsgruß (manuell)"
#### Geburtstagsgruß (automatisch)
- [ ] Test-Kunde anlegen mit Geburtsdatum = heute
- [ ] Auto-Geburtstagsgruß aktivieren (Cake-Button → Checkbox + Kanal "Email")
- [ ] Server neu starten (Cron macht Catch-up nach 30s)
- [ ] Nach ~1 Min: Log-Eintrag mit Kontext "Geburtstagsgruß (automatisch)"
#### Passwort-Reset
- [ ] Logout → "Passwort vergessen?" → eigene Admin-E-Mail eingeben
- [ ] Log-Eintrag mit Kontext "Passwort-Reset"
### 3. Fehlgeschlagener Versand loggen
- [ ] Temporär SMTP-Passwort ungültig machen (Einstellungen → Provider bearbeiten)
- [ ] Beliebige E-Mail-Aktion auslösen (z.B. Datenschutz-Link senden)
- [ ] Log-Eintrag mit Status ✗ und Fehlermeldung ("SMTP-Authentifizierung fehlgeschlagen")
- [ ] Im Browser: Toast-Benachrichtigung mit Fehler erscheint
- [ ] Passwort wieder korrigieren
### 4. Details-Modal
- [ ] Klick auf erfolgreichen Eintrag: Zeigt Absender, Empfänger, Betreff, SMTP-Server/Port/Verschlüsselung, Message-ID, ggf. SMTP-Server-Antwort
- [ ] Klick auf fehlgeschlagenen Eintrag: Zusätzlich klare Fehlermeldung
- [ ] Kunden-Link im Modal: bei customerId → klickbar zum Kunden
### 5. Automatisches Logging
- [ ] SMTP-Server, Port, Verschlüsselung werden bei jedem Versand geloggt
- [ ] Kontext wird korrekt mitgegeben (nicht "unknown")
- [ ] triggeredBy zeigt die auslösende User-E-Mail (nicht "cron" bei manuellen Aktionen)
- [ ] Bei automatischen Aktionen (Cron): triggeredBy = "cron"
---
## Wie benutzen?
1. Diese Datei öffnen
2. Von oben nach unten durchklicken, Häkchen setzen (oder im Editor durch `- [x]`)
3. Gefundene Bugs in GitHub Issues oder direkt als Korrektur-Commit einbauen
Bei frisch geladener Datenbank (z.B. Dev-System): vorher 2-3 Test-Kunden mit vollständigen
Stammdaten + mindestens 1 Email-Provider-Konfiguration anlegen.
-255
View File
@@ -1,255 +0,0 @@
# 📋 OpenCRM Todo-Liste
---
## 🔜 Offen
### Manuelle Tests (vor Release durchklicken)
Checklisten für Security + Email-Log-System stehen in **[TESTING.md](./TESTING.md)**.
Einmal komplett durchlaufen vor v1.0.0-Release.
### 🚀 SaaS-Ausbau: Instance-per-Customer + Admin-Portal + GoCardless
**Vision:** OpenCRM als SaaS anbieten. Jeder Kunde bekommt seine eigene
isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
über ein zentrales Admin-Portal.
**Architektur-Entscheidung:** Weg C (Instance-per-Customer)
- Pro Kunde eine eigene Docker-Instanz mit eigener DB
- Keine `tenantId` im CRM-Code → keine Security-Risiken durch vergessene Filter
- Komplette Datenisolation (DSGVO-freundlich)
- Updates können gestaffelt ausgerollt werden (erst 10% testen)
- Bei Kündigung: Docker-Image + DB-Export als "Mitnehm-Paket"
**Bewusst NICHT dabei:** eigener Mailserver. Stattdessen Plesk-Integration
(die wir schon haben) Kunde bekommt Mail-Zugang über unseren Plesk bei Bedarf.
---
**Admin-Portal (separate App, neben den CRM-Instanzen):**
- Kundenverwaltung: wer hat welchen Plan, Status (Trial/Active/Suspended/Cancelled)
- "Neuen Kunden anlegen" → Provisioning-Script
- DB anlegen (Master-DB kennt die Mapping)
- Docker-Container starten
- Subdomain konfigurieren (`kundenname.deincrm.de` via Caddy/Traefik)
- Initial-Admin-Account erstellen + Einladungs-Email senden
- Optional: Factory-Defaults für Stammdaten einspielen
- GoCardless-Integration (Webhook + Dashboard)
- Instanz-Management: Pause/Resume bei Zahlungsproblemen
- Logs & Metriken pro Instanz (optional)
- Support-Bereich (Tickets? oder einfach E-Mail)
---
**Abrechnung mit GoCardless (gocardless.com):**
- Zahlungsmethoden: SEPA-Lastschrift (Hauptfokus) + Kreditkarte (über GoCardless Embedded/Success)
- 30 Tage kostenlose Testphase ohne Zahlungsmittel
- Nach Trial: Mandats-Erfassung → regelmäßige Abbuchung
- Mehrere Pläne (z.B. Basic / Pro / Enterprise) mit unterschiedlichen Features
- Webhook-Endpoint im Admin-Portal:
- `payment_confirmed` → Instanz aktiv lassen
- `payment_failed` → Banner im CRM, nach X Tagen pausieren
- `mandate_cancelled` → Kündigungs-Flow
- Rechnungsstellung: GoCardless liefert Zahlungsbelege, aber **echte Rechnungen**
(mit USt-ID, Rechnungsnummer etc.) müssen wir selbst generieren
(evtl. über das existierende PDF-Template-System aus dem CRM nutzen)
---
**Provisioning-Flow (grober Entwurf):**
1. Kunde registriert sich auf Landing Page (Name, Firma, E-Mail, Wunsch-Subdomain)
2. Admin-Portal: Trial-Instanz starten
- DB erstellen, Docker-Container hochfahren, Caddy-Config für Subdomain
- Einladungs-Email mit Admin-Login + Passwort-Reset-Link
3. Tag 25: Erinnerungs-Email "Deine Trial läuft bald ab"
4. Tag 30: Banner im CRM "Jetzt bezahlen oder pausieren"
5. Kunde erfasst GoCardless-Mandat im Admin-Portal-Login
6. Bei erfolgreicher Zahlung: Instanz bleibt aktiv
7. Bei fehlender Zahlung nach 7 Tagen: Instanz pausiert (DB bleibt, UI zeigt Hinweis)
---
**Technische Bausteine für später:**
- Master-DB mit Tenant-Tabelle (Name, Subdomain, DB-Name, Plan, Status, GoCardlessIDs)
- Caddy oder Traefik als Reverse-Proxy mit Auto-SSL (Let's Encrypt)
- Docker-Orchestrierung: einzelne `docker-compose.yml` pro Kunde oder Docker-Swarm/K8s
- Backup-Strategie: pro Tenant separate Backups + zentrale Master-DB-Backups
- Monitoring: ein Fail macht nicht alle down, aber wir müssen es mitbekommen
- Logs zentral: z.B. Loki + Grafana für aggregierte Logs aller Instanzen
---
**Grobe Zeitschätzung:**
- Admin-Portal (MVP): ~1 Woche
- GoCardless-Integration + Webhooks: ~3-5 Tage
- Provisioning-Automatisierung (Docker + Caddy): ~1 Woche
- Landing Page + Checkout: ~3-5 Tage
- Tests + Polishing: ~1 Woche
- **Gesamt: ~3-4 Wochen**
**Vorbereitung JETZT (einfach, macht später Arbeit leichter):**
- ✅ Factory-Defaults System (schon erledigt, hilft beim Provisioning)
- ✅ Domain/Label dynamisch per Provider (schon erledigt)
- Docker-Compose aufräumen, Env-Variablen dokumentieren (klein, ein Tag)
- Backup-Script robust + wiederherstellbar (haben wir schon weitgehend)
---
## ✅ Erledigt
- [x] **🔄 Automatische Vertrags-Status-Übergänge**
- Nightly-Cron (02:00 + Catch-up 60s nach Start): alle Verträge mit
`status=ACTIVE` und `endDate < heute``EXPIRED` (mit Audit-Log).
- Beim Upload der Kündigungsbestätigung (`cancellationConfirmationPath`):
wenn Vertrag aktuell `ACTIVE` → auf `CANCELLED` setzen (Audit-Log).
Frontend fragt per Modal das Bestätigungs-Datum ab (Default: heute),
wird direkt als `cancellationConfirmationDate` gespeichert.
Der "Optionen"-Upload löst den Status-Wechsel bewusst NICHT aus, da er
für Vertragsänderungen (nicht echte Kündigungen) gedacht ist, setzt
aber `cancellationConfirmationOptionsDate` analog.
- Beim Upload einer `Lieferbestätigung` (ContractDocument via direkt-Upload
oder Email-Anhang-Import): wenn Vertrag aktuell `DRAFT` → auf `ACTIVE`
setzen + `startDate` auf das erfasste Lieferdatum (falls leer).
Frontend zeigt Datums-Input conditional, wenn Typ "Lieferbestätigung"
ausgewählt ist.
- Keine neuen Status eingeführt: `cancellationSentDate` vs.
`cancellationConfirmationDate` genügen, um "gesendet vs. bestätigt"
abzubilden. `ACTIVE` bleibt bis zur Bestätigung.
- [x] **🛡️ Security-Hardening vor Production-Deployment (10 Runden)**
- Vollständige Story inkl. aller Live-Test-Tabellen + Trade-offs:
**[SECURITY-HARDENING.md](./SECURITY-HARDENING.md)**
- Erste 2 Runden zusätzlich ausführlich in
[SECURITY-REVIEW.md](./SECURITY-REVIEW.md)
- Highlights:
- Runde 13: CORS, Helmet, JWT-Fallback, IDOR-Welle 1, XSS, Mass
Assignment, Zip-Slip, Path-Traversal, JWT-Algorithm, Rate-Limiter
- Runde 4: 9 Live-IDORs (customer.\*/gdpr.\*) + Error-Handler
- Runde 5: `/api/uploads`-Auth (DSGVO-GAU), Login-Timing,
Privacy-Policy-XSS
- Runde 6: Customer-List-Leak, XFF-Rate-Limit-Bypass,
Self-Grant + Existence-Disclosure
- Runde 7: SSRF-Schutz (Cloud-Metadata-Block), Logout-Endpoint
- Runde 8: DNS-Rebinding-Schutz, Per-File-Ownership-Check
- Runde 9: `npm audit fix` (8 Vulns weg), Audit-Chain-Rehash, keine
neuen Critical-Findings → diminishing returns erreicht
- Runde 10: Security-Monitoring (SecurityEvent-Tabelle + Hooks an
Login/IDOR/SSRF/Reset/Logout/JWT-Reject + Threshold-Detection +
Sofort-Alert für CRITICAL + Hourly-Digest + UI in Einstellungen)
- Deployment-Checkliste komplett (in HARDENING.md)
- [x] **🎉 Version 1.0.0 Feinschliff: Passwort-Reset + Rate-Limiting + Auto-Geburtstagsgrüße**
- **Passwort vergessen-Flow** (Login → "Passwort vergessen?" Link)
- Email-Reset-Token mit 2h Gültigkeit (kryptografisch sicher: 32 Byte Random)
- Funktioniert für Mitarbeiter UND Portal-Kunden (Typ-Auswahl)
- User-Enumeration-Schutz: immer 200 OK, egal ob Email existiert
- Reset-Link per Email mit schönem HTML-Template
- Nach Reset: alle bestehenden Sessions werden gekickt
- **Rate-Limiting** gegen Brute-Force
- Login: 10 Versuche pro 15 Min pro IP (erfolgreiche zählen nicht)
- Passwort-Reset-Anfrage: 5 Versuche pro Stunde pro IP
- **Cron-Job für automatische Geburtstagsgrüße**
- Täglich 08:00 Uhr: alle Kunden mit heutigem Geburtstag + autoBirthdayGreeting=true
- Email-Versand über System-E-Mail, Du/Sie-abhängiger Text
- Catch-up 30s nach Server-Start (falls Server am Geburtstag kurz down war)
- Marker lastBirthdayGreetingYear verhindert Doppel-Versand
- [x] **Mandantenfähigkeit: Domain + Kunden-E-Mail-Label dynamisch pro Provider**
- Neues Feld `customerEmailLabel` am EmailProviderConfig (z.B. "Stressfrei-Wechseln", "Meine-Firma")
- Wenn leer, wird das Label automatisch aus der Domain abgeleitet ("stressfrei-wechseln.de" → "Stressfrei-Wechseln")
- Neuer Frontend-Hook `useProviderSettings()` liefert Domain + Label
- Alle hardcoded "Stressfrei-Wechseln" und `@stressfrei-wechseln.de` Strings durch dynamische Werte ersetzt
(CustomerDetail, ContractForm, ContractDetail, EmailClientTab, Settings)
- Modal-Eingabefeld "Bezeichnung für Kunden-E-Mails" in Provider-Einstellungen
- Notwendig für Multi-Mandanten-Betrieb wenn das CRM an Dritte vermietet wird
- [x] **Factory-Defaults: Export + Import von Stammdaten-Katalogen**
- Enthält: Anbieter, Tarife, Kündigungsfristen, Laufzeiten, Vertragskategorien, PDF-Auftragsvorlagen (+ PDF-Dateien)
- Enthält NICHT: Kundendaten, Verträge, Dokumente, Emails, Einstellungen (dafür gibt es den Datenbank-Backup)
- Neue Einstellungsseite „Factory-Defaults" mit Übersicht (Anzahl pro Kategorie) und Export-Button
- Export: ZIP mit manifest.json + Kategorie-JSONs + PDF-Dateien, Download über Browser
- Import-Script: `npm run seed:defaults` liest `backend/factory-defaults/`, merged mehrere JSONs pro Kategorie, upsertet idempotent + kopiert PDFs in uploads/
- Ordner `backend/factory-defaults/` gitignoriert (außer .gitkeep + README), damit firmen-spezifische Kataloge nicht ins Repo kommen
- [x] **Email-Anhänge → Vertragsdokumente + Rechnungen für alle Vertragstypen**
- Im SaveAttachmentModal (bei einem per Email zugeordneten Vertrag) gibt es jetzt drei Modi:
1. **Als Dokument** (in feste Slots wie Kündigungsschreiben) wie bisher
2. **Als Vertragsdokument** neu, mit Typ-Dropdown (Auftragsformular, Lieferbestätigung, Vertragsunterlagen, Vollmacht, Widerrufsbelehrung, Preisblatt, Sonstiges) + Notizen
3. **Als Rechnung** jetzt für **alle** Vertragstypen (vorher nur Strom/Gas)
- Gleiches gilt für das Speichern der gesamten Email als PDF-Rechnung
- Neuer Backend-Endpoint `saveAttachmentAsContractDocument` für die flexible ContractDocument-Tabelle
- [x] **Geburtstag-Management-Modal in Kundenstammdaten**
- Neuer Button (Cake-Icon) neben Geburtsdatum öffnet Modal
- **Gruß zurücksetzen:** setzt `lastBirthdayGreetingYear` auf null zurück (fürs Debugging + Fallback)
- **Gruß jetzt senden:** per Email (direkt), WhatsApp/Telegram/Signal (öffnet vorbefülltes Fenster)
- Beide Aktionen mit Ja/Nein-Bestätigungsdialog (kein versehentliches Klicken)
- Text respektiert Du/Sie-Einstellung des Kunden
- Checkbox "Automatisch senden" mit Kanal-Dropdown (neue Felder am Customer)
- Audit-Log für Reset + Send
- [x] **Anrede-Verhältnis Du/Sie pro Kunde**
- Neues Feld `useInformalAddress` in Stammdaten (auch bei Firmenkunden)
- Default: Sie (formell)
- Geburtstagsgruß im Portal nutzt die Anrede: "Du"-Kunden bekommen "Herzlichen Glückwunsch, Max!", "Sie"-Kunden "Herzlichen Glückwunsch, Herr Müller!"
- Komplett konsistent auch bei nachträglichen Glückwünschen ("hattest" vs "hatten")
- [x] **Geburtsdatum + Geburtsort auch bei Firmenkunden**
- Felder werden jetzt unabhängig vom Kundentyp angezeigt
- Ermöglicht z.B. Geburtstage für Ansprechpartner bei Firmen
- [x] **Geburtstagskalender + Geburtstagsgruß-Modal**
- Admin: Section im Vertrags-Cockpit mit Kunden, die in den nächsten 30 Tagen oder letzten 7 Tagen Geburtstag haben
- Portal: Modal mit Gruß am Geburtstag (inkl. nachträglichem Glückwunsch bis 7 Tage danach)
- Wird pro Jahr nur einmal angezeigt
- [x] **Typspezifische Zusatzinfos in Vertragslisten**
- Strom/Gas → "Lieferadresse: ..."
- DSL/Glasfaser/Kabel → "Anschlussadresse: ..."
- Mobilfunk → "Rufnummer: ..."
- KFZ → "Kennzeichen: ..."
- Sichtbar in Admin-Liste, Portal-Liste und Kunden-Tab
- [x] **Datenschutzerklärung PDF ↔ Online-Einwilligungen synchronisieren**
- PDF hochgeladen → alle 4 Consents auf GRANTED
- Haken entfernt im Portal → PDF löschen + Tabs sperren
- Entsperrung nur durch alle Haken oder neues PDF
- [x] **Zweitarif-Zähler (HT/NT)** bei Strom + Verbrauchsberechnung
- [x] **Datumsformate vereinheitlichen** (01.01.2026 statt 1.1.2026)
- [x] **Audit-Log aussagekräftig** (Vorher/Nachher bei allen Änderungen)
- [x] **Impressum + Website-Datenschutzerklärung** im Kundenportal
- Editor in Einstellungen
- Vorschlagstexte
- [x] **Consent-Bestätigungs-Flow per Email**
- Alle Hebel müssen gesetzt sein
- Bestätigungsbutton + Bestätigungsemail
- [x] **Vertragsdokumente-Upload** (Auftragsformular, Lieferbestätigung, Vertragsunterlagen als PDF/PNG)
- [x] **Bug: Stressfrei-Email im Auftragsgenerator** (funktioniert jetzt im Vertrag)
- [x] **PDF-Auftragsvorlagen-System**
- Template-Editor in Einstellungen
- PDF hochladen, Formularfelder automatisch auslesen
- CRM-Felder zuordnen (visuell mit Vorschau)
- Seitenweise Sortierung der Felder
- Dynamische Rufnummern-Felder mit Vorwahl-Extraktion
- Nicht zugeordnete Felder bleiben editierbar
- Auftrag generieren aus Vertragsdaten (Button im Vertrags-Detail)
- [x] **Eigentümer-Verwaltung**
- An Adresse gehängt (Firma, Vorname, Nachname, Anschrift, Kontakt)
- Fallback auf Kundendaten wenn leer
- Nur bei Liefer-/Meldeadressen (nicht Rechnung)
- Namens-Kombinationen (Firma + Vorname + Nachname etc.)
- [x] **Gruppenauswahl Liefer-/Rechnungs-/Eigentümer-Adresse** im Auftragsgenerator
- [x] **Objekttyp + Lage + Lage des Anschlusses** bei Festnetz-Verträgen (DSL/Glasfaser/Kabel)
- [x] **Bankverbindung-Fallback** im PDF-Generator (neueste aktive Bankverbindung des Kunden)
-28
View File
@@ -14,7 +14,6 @@
"@tiptap/react": "^3.19.0",
"@tiptap/starter-kit": "^3.19.0",
"axios": "^1.7.7",
"dompurify": "^3.4.1",
"lucide-react": "^0.454.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -23,7 +22,6 @@
"react-router-dom": "^6.28.0"
},
"devDependencies": {
"@types/dompurify": "^3.0.5",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
@@ -1597,16 +1595,6 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/dompurify": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/trusted-types": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1654,13 +1642,6 @@
"@types/react": "^18.0.0"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
@@ -1995,15 +1976,6 @@
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
"dev": true
},
"node_modules/dompurify": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.1.tgz",
"integrity": "sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+1 -3
View File
@@ -1,6 +1,6 @@
{
"name": "opencrm-frontend",
"version": "1.1.0",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -14,7 +14,6 @@
"@tiptap/react": "^3.19.0",
"@tiptap/starter-kit": "^3.19.0",
"axios": "^1.7.7",
"dompurify": "^3.4.1",
"lucide-react": "^0.454.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -23,7 +22,6 @@
"react-router-dom": "^6.28.0"
},
"devDependencies": {
"@types/dompurify": "^3.0.5",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
-10
View File
@@ -6,8 +6,6 @@ import { Shield } from 'lucide-react';
import ScrollToTop from './components/ScrollToTop';
import Layout from './components/layout/Layout';
import Login from './pages/Login';
import PasswordResetRequest from './pages/PasswordResetRequest';
import PasswordResetConfirm from './pages/PasswordResetConfirm';
import Dashboard from './pages/Dashboard';
import CustomerList from './pages/customers/CustomerList';
import CustomerDetail from './pages/customers/CustomerDetail';
@@ -27,10 +25,8 @@ import PortalSettings from './pages/settings/PortalSettings';
import DeadlineSettings from './pages/settings/DeadlineSettings';
import EmailProviders from './pages/settings/EmailProviders';
import DatabaseBackup from './pages/settings/DatabaseBackup';
import FactoryDefaults from './pages/settings/FactoryDefaults';
import AuditLogs from './pages/settings/AuditLogs';
import EmailLogPage from './pages/settings/EmailLogs';
import Monitoring from './pages/settings/Monitoring';
import GDPRDashboard from './pages/settings/GDPRDashboard';
import UserList from './pages/users/UserList';
import Settings from './pages/Settings';
@@ -149,10 +145,6 @@ function App() {
element={isAuthenticated ? <Navigate to="/" replace /> : <Login />}
/>
{/* Passwort-Reset (öffentlich, kein Auth-Check) */}
<Route path="/password-reset/request" element={<PasswordResetRequest />} />
<Route path="/password-reset" element={<PasswordResetConfirm />} />
<Route
path="/"
element={
@@ -200,10 +192,8 @@ function App() {
<Route path="settings/deadlines" element={<DeadlineSettings />} />
<Route path="settings/email-providers" element={<EmailProviders />} />
<Route path="settings/database-backup" element={<DatabaseBackup />} />
<Route path="settings/factory-defaults" element={<FactoryDefaults />} />
<Route path="settings/audit-logs" element={<AuditLogs />} />
<Route path="settings/email-logs" element={<EmailLogPage />} />
<Route path="settings/monitoring" element={<Monitoring />} />
<Route path="settings/gdpr" element={<GDPRDashboard />} />
<Route path="settings/privacy-policy" element={<PrivacyPolicyEditor />} />
<Route path="settings/authorization-template" element={<AuthorizationTemplateEditor />} />
@@ -1,302 +0,0 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { birthdayApi, customerApi } from '../services/api';
import Button from './ui/Button';
import { X, Cake, RotateCcw, Send, AlertTriangle, Loader2, Check } from 'lucide-react';
import type { Customer } from '../types';
interface Props {
customer: Customer;
onClose: () => void;
}
type Channel = 'email' | 'whatsapp' | 'telegram' | 'signal';
const channelLabels: Record<Channel, { label: string; icon: string }> = {
email: { label: 'Per E-Mail', icon: '✉️' },
whatsapp: { label: 'Per WhatsApp', icon: '💬' },
telegram: { label: 'Per Telegram', icon: '📨' },
signal: { label: 'Per Signal', icon: '📱' },
};
type ConfirmState =
| { type: 'none' }
| { type: 'reset' }
| { type: 'send'; channel: Channel };
export default function BirthdayManagementModal({ customer, onClose }: Props) {
const queryClient = useQueryClient();
const [confirm, setConfirm] = useState<ConfirmState>({ type: 'none' });
const [autoEnabled, setAutoEnabled] = useState(customer.autoBirthdayGreeting ?? false);
const [autoChannel, setAutoChannel] = useState<Channel>(
(customer.autoBirthdayChannel as Channel) || 'email',
);
const [saved, setSaved] = useState(false);
const [sentChannel, setSentChannel] = useState<Channel | null>(null);
const [reset, setReset] = useState(false);
const resetMutation = useMutation({
mutationFn: () => birthdayApi.resetGreeting(customer.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customer.id] });
setReset(true);
setTimeout(() => setReset(false), 2500);
},
});
const sendMutation = useMutation({
mutationFn: (channel: Channel) => birthdayApi.sendGreeting(customer.id, channel),
onSuccess: (result, channel) => {
const text = result.data?.messageText || '';
if (channel === 'whatsapp') {
window.open(`https://wa.me/?text=${encodeURIComponent(text)}`, '_blank');
} else if (channel === 'telegram') {
window.open(
`https://t.me/share/url?url=&text=${encodeURIComponent(text)}`,
'_blank',
);
} else if (channel === 'signal') {
window.open(`signal://send?text=${encodeURIComponent(text)}`, '_blank');
}
setSentChannel(channel);
setTimeout(() => setSentChannel(null), 2500);
},
});
const saveAutoMutation = useMutation({
mutationFn: () =>
customerApi.update(customer.id, {
autoBirthdayGreeting: autoEnabled,
autoBirthdayChannel: autoEnabled ? autoChannel : null,
} as any),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customer.id] });
setSaved(true);
setTimeout(() => setSaved(false), 2000);
},
});
const handleConfirmAction = () => {
if (confirm.type === 'reset') {
resetMutation.mutate();
} else if (confirm.type === 'send') {
sendMutation.mutate(confirm.channel);
}
setConfirm({ type: 'none' });
};
const birthDateDisplay = customer.birthDate
? new Date(customer.birthDate).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
: '-';
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4">
<div className="bg-white rounded-2xl shadow-2xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="bg-gradient-to-r from-pink-500 to-purple-500 p-6 text-white relative">
<button
onClick={onClose}
className="absolute top-3 right-3 text-white/80 hover:text-white transition-colors"
aria-label="Schließen"
>
<X className="w-5 h-5" />
</button>
<div className="flex items-center gap-3">
<Cake className="w-8 h-8" />
<div>
<h2 className="text-xl font-bold">Geburtstag verwalten</h2>
<p className="text-sm text-white/90">
{customer.companyName || `${customer.firstName} ${customer.lastName}`}
</p>
</div>
</div>
</div>
{/* Body */}
<div className="p-6 space-y-6">
{/* Info */}
<div className="bg-gray-50 rounded-lg p-4 text-sm space-y-1">
<div className="flex justify-between">
<span className="text-gray-500">Geburtsdatum:</span>
<span className="font-medium">{birthDateDisplay}</span>
</div>
{customer.birthPlace && (
<div className="flex justify-between">
<span className="text-gray-500">Geburtsort:</span>
<span className="font-medium">{customer.birthPlace}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-gray-500">Anrede per:</span>
<span className="font-medium">
{customer.useInformalAddress ? 'Du (informell)' : 'Sie (formell)'}
</span>
</div>
</div>
{/* Gruß zurücksetzen */}
<div>
<h3 className="text-sm font-semibold text-gray-700 mb-2 flex items-center gap-2">
<RotateCcw className="w-4 h-4" />
Gruß-Marker zurücksetzen
</h3>
<p className="text-xs text-gray-500 mb-3">
Setzt die Markierung zurück, dass dem Kunden dieses Jahr bereits der Geburtstagsgruß
angezeigt wurde. Beim nächsten Portal-Login erscheint das Modal wieder.
</p>
<Button
variant="secondary"
onClick={() => setConfirm({ type: 'reset' })}
disabled={resetMutation.isPending || reset}
className="w-full"
>
{resetMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Zurücksetzen
</>
) : reset ? (
<>
<Check className="w-4 h-4 mr-2 text-green-600" />
Zurückgesetzt!
</>
) : (
<>
<RotateCcw className="w-4 h-4 mr-2" />
Gruß-Marker zurücksetzen
</>
)}
</Button>
</div>
{/* Gruß senden */}
<div>
<h3 className="text-sm font-semibold text-gray-700 mb-2 flex items-center gap-2">
<Send className="w-4 h-4" />
Geburtstagsgruß jetzt senden
</h3>
<p className="text-xs text-gray-500 mb-3">
Sendet einen persönlichen Geburtstagsgruß über den gewählten Kanal.
</p>
<div className="grid grid-cols-2 gap-2">
{(Object.entries(channelLabels) as [Channel, typeof channelLabels[Channel]][]).map(
([ch, info]) => (
<button
key={ch}
onClick={() => setConfirm({ type: 'send', channel: ch })}
disabled={sendMutation.isPending}
className="flex items-center gap-2 px-3 py-2 border rounded-lg text-sm hover:bg-gray-50 disabled:opacity-50 transition-colors"
>
<span>{info.icon}</span>
<span className="flex-1 text-left">{info.label}</span>
{sentChannel === ch && <Check className="w-4 h-4 text-green-600" />}
</button>
),
)}
</div>
{sendMutation.isError && (
<p className="text-xs text-red-600 mt-2">
{(sendMutation.error as any)?.message || 'Fehler beim Senden'}
</p>
)}
</div>
{/* Automatisch senden */}
<div className="pt-4 border-t">
<h3 className="text-sm font-semibold text-gray-700 mb-2">Automatisch senden</h3>
<label className="flex items-start gap-2 cursor-pointer mb-3">
<input
type="checkbox"
checked={autoEnabled}
onChange={(e) => setAutoEnabled(e.target.checked)}
className="mt-0.5"
/>
<div className="text-sm">
<span className="font-medium">
Geburtstagsgruß automatisch am Geburtstag senden
</span>
<p className="text-xs text-gray-500">
Der Gruß wird am Geburtstag des Kunden automatisch über den gewählten Kanal
versendet.
</p>
</div>
</label>
{autoEnabled && (
<div className="pl-6 space-y-2">
<label className="block text-xs text-gray-600 mb-1">Kanal</label>
<select
value={autoChannel}
onChange={(e) => setAutoChannel(e.target.value as Channel)}
className="w-full border rounded px-3 py-2 text-sm"
>
{(Object.entries(channelLabels) as [Channel, typeof channelLabels[Channel]][]).map(
([ch, info]) => (
<option key={ch} value={ch}>
{info.icon} {info.label}
</option>
),
)}
</select>
<p className="text-xs text-amber-600">
Hinweis: WhatsApp/Telegram/Signal erfordern aktuell einen manuellen Klick im
Browser. Aktuell wird nur automatischer E-Mail-Versand unterstützt.
</p>
</div>
)}
<Button
onClick={() => saveAutoMutation.mutate()}
disabled={saveAutoMutation.isPending || saved}
className="mt-3 w-full"
variant="secondary"
>
{saveAutoMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Speichere
</>
) : saved ? (
<>
<Check className="w-4 h-4 mr-2 text-green-600" />
Gespeichert
</>
) : (
'Einstellung speichern'
)}
</Button>
</div>
</div>
</div>
{/* Bestätigungs-Dialog */}
{confirm.type !== 'none' && (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 px-4">
<div className="bg-white rounded-xl shadow-2xl max-w-md w-full p-6">
<div className="flex items-start gap-3 mb-4">
<div className="bg-amber-100 p-2 rounded-full">
<AlertTriangle className="w-5 h-5 text-amber-600" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-gray-900 mb-1">Bist du sicher?</h3>
<p className="text-sm text-gray-600">
{confirm.type === 'reset'
? 'Möchtest du den Geburtstagsgruß-Marker wirklich zurücksetzen? Beim nächsten Portal-Login sieht der Kunde das Geburtstagsmodal erneut.'
: `Möchtest du den Geburtstagsgruß wirklich ${channelLabels[confirm.channel].label.toLowerCase()} senden?`}
</p>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => setConfirm({ type: 'none' })}>
Abbrechen
</Button>
<Button onClick={handleConfirmAction}>Ja, ausführen</Button>
</div>
</div>
</div>
)}
</div>
);
}
+7 -17
View File
@@ -27,16 +27,6 @@ export default function BirthdayModal() {
const handleClose = () => ackMutation.mutate();
// Anrede abhängig vom Du/Sie-Verhältnis zusammenstellen
const greetingName = info.useInformalAddress
? info.firstName
: [info.salutation, info.lastName].filter(Boolean).join(' ') || info.firstName;
const greetingPronoun = info.useInformalAddress ? 'dir' : 'Ihnen';
const greetingPossessive = info.useInformalAddress ? 'deinem' : 'Ihrem';
const greetingYour = info.useInformalAddress ? 'Dein' : 'Ihr';
const hadBirthday = info.useInformalAddress ? 'hattest' : 'hatten';
const becameOld = info.useInformalAddress ? 'bist' : 'sind';
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4">
<div className="bg-white rounded-2xl shadow-2xl max-w-md w-full overflow-hidden">
@@ -60,26 +50,26 @@ export default function BirthdayModal() {
{info.isToday ? (
<>
<h2 className="text-2xl font-bold text-gray-900 mb-2">
Herzlichen Glückwunsch, {greetingName}!
Herzlichen Glückwunsch, {info.firstName}!
</h2>
<p className="text-gray-600 mb-1">
Alles Gute zu {greetingPossessive} {info.age}. Geburtstag!
Alles Gute zu Ihrem {info.age}. Geburtstag!
</p>
<p className="text-gray-500 text-sm mb-6">
Wir wünschen {greetingPronoun} einen wunderschönen Tag und alles Gute für {greetingYour.toLowerCase()} neues Lebensjahr. 🌟
Wir wünschen Ihnen einen wunderschönen Tag und alles Gute für das neue Lebensjahr. 🌟
</p>
</>
) : (
<>
<h2 className="text-2xl font-bold text-gray-900 mb-2">
Nachträglich alles Gute, {greetingName}!
Nachträglich alles Gute, {info.firstName}!
</h2>
<p className="text-gray-600 mb-1">
{greetingYour === 'Ihr' ? 'Sie' : 'Du'} {hadBirthday} vor {info.daysAgo} Tag{info.daysAgo === 1 ? '' : 'en'} Geburtstag
{info.age > 0 && ` und ${becameOld} ${info.age} Jahre alt geworden`}.
Sie hatten vor {info.daysAgo} Tag{info.daysAgo === 1 ? '' : 'en'} Geburtstag
{info.age > 0 && ` und sind ${info.age} Jahre alt geworden`}.
</p>
<p className="text-gray-500 text-sm mb-6">
Wir wünschen {greetingPronoun} alles Gute nachträglich und eine tolle Zeit im neuen Lebensjahr. 🌟
Wir wünschen Ihnen alles Gute nachträglich und eine tolle Zeit im neuen Lebensjahr. 🌟
</p>
</>
)}
@@ -9,7 +9,6 @@ import Select from '../ui/Select';
import Badge from '../ui/Badge';
import { invoiceApi } from '../../services/api';
import type { Invoice, InvoiceType } from '../../types';
import { fileUrl } from '../../utils/fileUrl';
const invoiceTypeLabels: Record<InvoiceType, string> = {
INTERIM: 'Zwischenrechnung',
@@ -121,7 +120,7 @@ export default function InvoicesSection({
{invoice.documentPath && (
<div className="flex items-center gap-2">
<a
href={fileUrl(invoice.documentPath)}
href={`/api${invoice.documentPath}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-blue-600 hover:text-blue-800 text-sm"
@@ -130,7 +129,7 @@ export default function InvoicesSection({
<Eye className="w-4 h-4" />
</a>
<a
href={fileUrl(invoice.documentPath)}
href={`/api${invoice.documentPath}`}
download
className="flex items-center gap-1 text-blue-600 hover:text-blue-800 text-sm"
title="Download"
@@ -1,6 +1,5 @@
import { useState, useRef, useEffect } from 'react';
import { Send, Paperclip, X, FileText } from 'lucide-react';
import toast from 'react-hot-toast';
import Modal from '../ui/Modal';
import Button from '../ui/Button';
import { stressfreiEmailApi, CachedEmail, MailboxAccount, EmailAttachment } from '../../services/api';
@@ -151,24 +150,12 @@ export default function ComposeEmailModal({
attachments: attachments.length > 0 ? attachments : undefined,
contractId,
}),
onSuccess: (result) => {
// Backend kann success=false zurückgeben auch bei HTTP 200
if (result && (result as any).success === false) {
const msg = (result as any).error || 'E-Mail-Versand fehlgeschlagen';
setError(msg);
toast.error(`SMTP-Fehler: ${msg}`, { duration: 8000 });
return;
}
toast.success('E-Mail versendet');
onSuccess: () => {
onSuccess?.();
handleClose();
},
onError: (err: any) => {
const msg =
err?.response?.data?.error ||
(err instanceof Error ? err.message : 'Fehler beim Senden');
setError(msg);
toast.error(`SMTP-Fehler: ${msg}`, { duration: 8000 });
onError: (err) => {
setError(err instanceof Error ? err.message : 'Fehler beim Senden');
},
});
@@ -1,10 +1,8 @@
import { useState, useEffect } from 'react';
import { RefreshCw, Plus, Mail, Inbox, Send, Trash2 } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { cachedEmailApi, stressfreiEmailApi, CachedEmail } from '../../services/api';
import { useAuth } from '../../context/AuthContext';
import { useProviderSettings } from '../../hooks/useProviderSettings';
import Button from '../ui/Button';
import EmailList from './EmailList';
import EmailDetail from './EmailDetail';
@@ -19,7 +17,6 @@ interface EmailClientTabProps {
}
export default function EmailClientTab({ customerId }: EmailClientTabProps) {
const { customerEmailLabel } = useProviderSettings();
const [selectedAccountId, setSelectedAccountId] = useState<number | null>(null);
const [selectedFolder, setSelectedFolder] = useState<EmailFolder>('INBOX');
const [selectedEmail, setSelectedEmail] = useState<CachedEmail | null>(null);
@@ -98,14 +95,7 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
// Synchronisation
const syncMutation = useMutation({
mutationFn: (accountId: number) => stressfreiEmailApi.syncEmails(accountId),
onSuccess: (result) => {
// Backend liefert success=false bei IMAP-Fehler, aber ohne HTTP-Error
if (result && (result as any).success === false) {
const err = (result as any).error || 'IMAP-Synchronisation fehlgeschlagen';
toast.error(`Sync fehlgeschlagen: ${err}`, { duration: 8000 });
return;
}
toast.success('E-Mails synchronisiert');
onSuccess: () => {
// E-Mail-Listen neu laden
queryClient.invalidateQueries({ queryKey: ['emails'] });
// Ordner-Anzahlen aktualisieren
@@ -113,10 +103,6 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
// Mailbox-Accounts aktualisieren
queryClient.invalidateQueries({ queryKey: ['mailbox-accounts', customerId] });
},
onError: (error: any) => {
const msg = error?.response?.data?.error || error?.message || 'Unbekannter Fehler';
toast.error(`Sync fehlgeschlagen: ${msg}`, { duration: 8000 });
},
});
const handleSync = () => {
@@ -152,7 +138,7 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
Keine E-Mail-Konten vorhanden
</h3>
<p className="text-sm text-center max-w-md">
Erstellen Sie eine {customerEmailLabel} E-Mail-Adresse mit aktivierter Mailbox,
Erstellen Sie eine Stressfrei-Wechseln E-Mail-Adresse mit aktivierter Mailbox,
um E-Mails hier empfangen und versenden zu können.
</p>
</div>
+1 -11
View File
@@ -1,6 +1,5 @@
import { useState, useEffect } from 'react';
import { Reply, Star, Paperclip, Link2, X, Download, ExternalLink, Trash2, Undo2, Save, FileDown } from 'lucide-react';
import DOMPurify from 'dompurify';
import { CachedEmail, cachedEmailApi } from '../../services/api';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import Button from '../ui/Button';
@@ -385,16 +384,7 @@ export default function EmailDetail({
{showHtml && email.htmlBody ? (
<div
className="prose prose-sm max-w-none"
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(email.htmlBody, {
// Scripte, Inline-Handler, Form-Elemente, externe Referenzen verbieten.
// Bilder + Links mit target=_blank bleiben zugelassen.
FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus'],
// Links in neuen Tabs öffnen (verhindert window.opener-Angriffe)
ADD_ATTR: ['target'],
}),
}}
dangerouslySetInnerHTML={{ __html: email.htmlBody }}
/>
) : (
<pre className="whitespace-pre-wrap text-sm text-gray-700 font-sans">
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { FileText, User, CreditCard, IdCard, AlertTriangle, Check, ChevronDown, ChevronRight, Receipt, FolderOpen } from 'lucide-react';
import { FileText, User, CreditCard, IdCard, AlertTriangle, Check, ChevronDown, ChevronRight, Receipt } from 'lucide-react';
import Modal from '../ui/Modal';
import Button from '../ui/Button';
import Input from '../ui/Input';
@@ -9,17 +9,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import type { InvoiceType } from '../../types';
const CONTRACT_DOCUMENT_TYPES = [
'Auftragsformular',
'Auftragsbestätigung',
'Lieferbestätigung',
'Vertragsunterlagen',
'Vollmacht',
'Widerrufsbelehrung',
'Preisblatt',
'Sonstiges',
];
interface SaveAttachmentModalProps {
isOpen: boolean;
onClose: () => void;
@@ -36,7 +25,7 @@ type SelectedTarget = {
label: string;
};
type SaveMode = 'document' | 'invoice' | 'contractDocument';
type SaveMode = 'document' | 'invoice';
export default function SaveAttachmentModal({
isOpen,
@@ -53,11 +42,6 @@ export default function SaveAttachmentModal({
invoiceType: 'INTERIM' as InvoiceType,
notes: '',
});
const [contractDocumentData, setContractDocumentData] = useState({
documentType: CONTRACT_DOCUMENT_TYPES[0],
notes: '',
deliveryDate: new Date().toISOString().split('T')[0],
});
const queryClient = useQueryClient();
// Ziele laden
@@ -69,8 +53,8 @@ export default function SaveAttachmentModal({
const targets = targetsData?.data;
// Vertrag zugeordnet? → dann Rechnung + Vertragsdokument möglich
const hasContract = !!targets?.contract;
// Prüfen ob es ein Energievertrag ist
const isEnergyContract = targets?.contract?.type === 'ELECTRICITY' || targets?.contract?.type === 'GAS';
const saveMutation = useMutation({
mutationFn: () => {
@@ -129,33 +113,6 @@ export default function SaveAttachmentModal({
},
});
const saveContractDocumentMutation = useMutation({
mutationFn: () => {
const isDelivery = contractDocumentData.documentType.trim().toLowerCase() === 'lieferbestätigung';
return cachedEmailApi.saveAttachmentAsContractDocument(emailId, attachmentFilename, {
documentType: contractDocumentData.documentType,
notes: contractDocumentData.notes || undefined,
deliveryDate: isDelivery ? contractDocumentData.deliveryDate : undefined,
});
},
onSuccess: () => {
toast.success('Anhang als Vertragsdokument gespeichert');
queryClient.invalidateQueries({ queryKey: ['attachment-targets', emailId] });
queryClient.invalidateQueries({ queryKey: ['contracts'] });
if (targets?.contract?.id) {
queryClient.invalidateQueries({ queryKey: ['contract', targets.contract.id.toString()] });
queryClient.invalidateQueries({ queryKey: ['contract-documents', targets.contract.id] });
}
onSuccess?.();
handleClose();
},
onError: (error: Error) => {
toast.error(error.message || 'Fehler beim Speichern des Vertragsdokuments');
},
});
const handleClose = () => {
setSelectedTarget(null);
setSaveMode('document');
@@ -164,11 +121,6 @@ export default function SaveAttachmentModal({
invoiceType: 'INTERIM',
notes: '',
});
setContractDocumentData({
documentType: CONTRACT_DOCUMENT_TYPES[0],
notes: '',
deliveryDate: new Date().toISOString().split('T')[0],
});
onClose();
};
@@ -311,12 +263,12 @@ export default function SaveAttachmentModal({
{targets && (
<>
{/* Mode Toggle (nur wenn ein Vertrag zugeordnet ist) */}
{hasContract && (
{/* Mode Toggle für Energieverträge */}
{isEnergyContract && (
<div className="flex gap-2 p-1 bg-gray-100 rounded-lg">
<button
onClick={() => setSaveMode('document')}
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
saveMode === 'document'
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
@@ -325,20 +277,9 @@ export default function SaveAttachmentModal({
<FileText className="w-4 h-4" />
Als Dokument
</button>
<button
onClick={() => setSaveMode('contractDocument')}
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
saveMode === 'contractDocument'
? 'bg-white text-orange-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
<FolderOpen className="w-4 h-4" />
Vertragsdokument
</button>
<button
onClick={() => setSaveMode('invoice')}
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
saveMode === 'invoice'
? 'bg-white text-green-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
@@ -402,7 +343,7 @@ export default function SaveAttachmentModal({
)}
{/* Invoice Mode */}
{saveMode === 'invoice' && hasContract && (
{saveMode === 'invoice' && isEnergyContract && (
<div className="space-y-4">
<div className="p-3 bg-green-50 rounded-lg">
<p className="text-sm text-green-700">
@@ -436,52 +377,6 @@ export default function SaveAttachmentModal({
/>
</div>
)}
{/* Contract Document Mode */}
{saveMode === 'contractDocument' && hasContract && (
<div className="space-y-4">
<div className="p-3 bg-orange-50 rounded-lg">
<p className="text-sm text-orange-700">
Der Anhang wird als Vertragsdokument für den Vertrag <strong>{targets.contract?.contractNumber}</strong> gespeichert.
</p>
</div>
<Select
label="Dokumenttyp"
value={contractDocumentData.documentType}
onChange={(e) =>
setContractDocumentData({ ...contractDocumentData, documentType: e.target.value })
}
options={CONTRACT_DOCUMENT_TYPES.map((t) => ({ value: t, label: t }))}
/>
<Input
label="Notizen (optional)"
value={contractDocumentData.notes}
onChange={(e) =>
setContractDocumentData({ ...contractDocumentData, notes: e.target.value })
}
placeholder="Optionale Anmerkungen..."
/>
{contractDocumentData.documentType.trim().toLowerCase() === 'lieferbestätigung' && (
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg">
<label className="block text-sm font-medium text-gray-700 mb-1">Lieferdatum</label>
<input
type="date"
value={contractDocumentData.deliveryDate}
onChange={(e) =>
setContractDocumentData({ ...contractDocumentData, deliveryDate: e.target.value })
}
className="block w-full max-w-[220px] px-3 py-2 border border-gray-300 rounded-lg text-sm"
/>
<p className="text-xs text-gray-600 mt-1">
Falls der Vertrag noch auf Entwurf steht, wird er auf Aktiv gesetzt und dieses Datum als Vertragsbeginn übernommen.
</p>
</div>
)}
</div>
)}
</>
)}
@@ -501,47 +396,21 @@ export default function SaveAttachmentModal({
<Button variant="secondary" onClick={handleClose}>
Abbrechen
</Button>
{saveMode === 'document' && (
{saveMode === 'document' ? (
<Button
onClick={() => saveMutation.mutate()}
disabled={
!selectedTarget ||
saveMutation.isPending ||
saveInvoiceMutation.isPending ||
saveContractDocumentMutation.isPending
}
disabled={!selectedTarget || saveMutation.isPending || saveInvoiceMutation.isPending}
>
{saveMutation.isPending ? 'Wird gespeichert...' : 'Speichern'}
</Button>
)}
{saveMode === 'invoice' && (
) : (
<Button
onClick={() => saveInvoiceMutation.mutate()}
disabled={
!invoiceData.invoiceDate ||
saveMutation.isPending ||
saveInvoiceMutation.isPending ||
saveContractDocumentMutation.isPending
}
disabled={!invoiceData.invoiceDate || saveMutation.isPending || saveInvoiceMutation.isPending}
>
{saveInvoiceMutation.isPending ? 'Wird gespeichert...' : 'Als Rechnung speichern'}
</Button>
)}
{saveMode === 'contractDocument' && (
<Button
onClick={() => saveContractDocumentMutation.mutate()}
disabled={
!contractDocumentData.documentType ||
saveMutation.isPending ||
saveInvoiceMutation.isPending ||
saveContractDocumentMutation.isPending
}
>
{saveContractDocumentMutation.isPending
? 'Wird gespeichert...'
: 'Als Vertragsdokument speichern'}
</Button>
)}
</div>
</div>
</Modal>
-26
View File
@@ -1,26 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { emailProviderApi } from '../services/api';
export interface ProviderSettings {
domain: string | null;
customerEmailLabel: string; // z.B. "Stressfrei-Wechseln" (aus Config oder aus Domain abgeleitet)
customerEmailLabelIsCustom: boolean;
}
/**
* Holt die öffentlichen Provider-Einstellungen (Domain + Label für Kunden-E-Mail-Adressen).
* Mit Default-Fallback bei Ladefehler UI-Labels werden dann generisch angezeigt.
*/
export function useProviderSettings(): ProviderSettings {
const { data } = useQuery({
queryKey: ['email-provider-public-settings'],
queryFn: () => emailProviderApi.getPublicSettings(),
staleTime: 5 * 60_000,
});
return {
domain: data?.data?.domain ?? null,
customerEmailLabel: data?.data?.customerEmailLabel || 'Kunden-E-Mail',
customerEmailLabelIsCustom: data?.data?.customerEmailLabelIsCustom ?? false,
};
}
+1 -10
View File
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import Button from '../components/ui/Button';
import Input from '../components/ui/Input';
@@ -73,15 +73,6 @@ export default function Login() {
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Anmeldung...' : 'Anmelden'}
</Button>
<div className="text-center">
<Link
to="/password-reset/request"
className="text-sm text-blue-600 hover:text-blue-800 hover:underline"
>
Passwort vergessen?
</Link>
</div>
</form>
</Card>
</div>
-147
View File
@@ -1,147 +0,0 @@
import { useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { Lock, CheckCircle, AlertCircle, Eye, EyeOff } from 'lucide-react';
import Button from '../components/ui/Button';
import Input from '../components/ui/Input';
import Card from '../components/ui/Card';
import axios from 'axios';
export default function PasswordResetConfirm() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const token = searchParams.get('token') || '';
const [password, setPassword] = useState('');
const [passwordConfirm, setPasswordConfirm] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!token) {
setError('Ungültiger Link: Kein Token enthalten.');
return;
}
if (password.length < 6) {
setError('Das Passwort muss mindestens 6 Zeichen lang sein.');
return;
}
if (password !== passwordConfirm) {
setError('Die Passwörter stimmen nicht überein.');
return;
}
setIsLoading(true);
try {
await axios.post('/api/auth/password-reset/confirm', { token, password });
setSuccess(true);
setTimeout(() => navigate('/login'), 3000);
} catch (err: any) {
setError(err.response?.data?.error || 'Fehler beim Zurücksetzen. Bitte versuche es erneut.');
} finally {
setIsLoading(false);
}
};
if (!token) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<Card className="w-full max-w-md">
<div className="text-center">
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-gray-900 mb-2">Ungültiger Link</h1>
<p className="text-gray-600 mb-6">
Dieser Reset-Link ist unvollständig. Bitte fordere einen neuen an.
</p>
<Link to="/password-reset/request">
<Button className="w-full">Neuen Link anfordern</Button>
</Link>
</div>
</Card>
</div>
);
}
if (success) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<Card className="w-full max-w-md">
<div className="text-center">
<CheckCircle className="w-12 h-12 text-green-500 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-gray-900 mb-2">Passwort geändert</h1>
<p className="text-gray-600 mb-6">
Dein Passwort wurde erfolgreich zurückgesetzt. Du wirst in Kürze zum Login weitergeleitet.
</p>
<Link to="/login">
<Button className="w-full">Jetzt einloggen</Button>
</Link>
</div>
</Card>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<Card className="w-full max-w-md">
<div className="text-center mb-6">
<Lock className="w-10 h-10 text-blue-500 mx-auto mb-3" />
<h1 className="text-2xl font-bold text-gray-900">Neues Passwort</h1>
<p className="text-gray-600 mt-2 text-sm">Vergib ein neues Passwort für deinen Account.</p>
</div>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Neues Passwort *</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
autoComplete="new-password"
className="block w-full px-3 py-2 pr-10 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<p className="text-xs text-gray-500 mt-1">Mindestens 6 Zeichen</p>
</div>
<Input
label="Passwort bestätigen *"
type={showPassword ? 'text' : 'password'}
value={passwordConfirm}
onChange={(e) => setPasswordConfirm(e.target.value)}
required
minLength={6}
autoComplete="new-password"
/>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Wird gespeichert…' : 'Passwort festlegen'}
</Button>
</form>
</Card>
</div>
);
}
-128
View File
@@ -1,128 +0,0 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { Mail, ArrowLeft, CheckCircle } from 'lucide-react';
import Button from '../components/ui/Button';
import Input from '../components/ui/Input';
import Card from '../components/ui/Card';
import axios from 'axios';
export default function PasswordResetRequest() {
const [email, setEmail] = useState('');
const [userType, setUserType] = useState<'admin' | 'portal'>('admin');
const [isLoading, setIsLoading] = useState(false);
const [sent, setSent] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
await axios.post('/api/auth/password-reset/request', { email, userType });
setSent(true);
} catch (err: any) {
// Backend sendet absichtlich immer 200, aber Rate-Limit kann 429 senden
if (err.response?.status === 429) {
setError(err.response.data?.error || 'Zu viele Anfragen. Bitte später erneut versuchen.');
} else {
setSent(true); // Auch bei anderen Fehlern Erfolg anzeigen (Email-Enumeration-Schutz)
}
} finally {
setIsLoading(false);
}
};
if (sent) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<Card className="w-full max-w-md">
<div className="text-center">
<CheckCircle className="w-12 h-12 text-green-500 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-gray-900 mb-2">E-Mail gesendet</h1>
<p className="text-gray-600 mb-6">
Wenn ein Konto mit der E-Mail <strong>{email}</strong> existiert, haben wir dir einen
Link zum Zurücksetzen des Passworts gesendet. Der Link ist 2 Stunden gültig.
</p>
<p className="text-sm text-gray-500 mb-6">
Nichts erhalten? Schau in den Spam-Ordner oder versuche es in ein paar Minuten erneut.
</p>
<Link to="/login">
<Button variant="secondary" className="w-full">
Zurück zum Login
</Button>
</Link>
</div>
</Card>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<Card className="w-full max-w-md">
<div className="text-center mb-6">
<Mail className="w-10 h-10 text-blue-500 mx-auto mb-3" />
<h1 className="text-2xl font-bold text-gray-900">Passwort vergessen?</h1>
<p className="text-gray-600 mt-2 text-sm">
Gib deine E-Mail-Adresse ein. Wir senden dir einen Link zum Zurücksetzen.
</p>
</div>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Konto-Typ</label>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="userType"
checked={userType === 'admin'}
onChange={() => setUserType('admin')}
/>
<span className="text-sm">Mitarbeiter</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="userType"
checked={userType === 'portal'}
onChange={() => setUserType('portal')}
/>
<span className="text-sm">Kunde (Portal)</span>
</label>
</div>
</div>
<Input
label="E-Mail"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
placeholder="deine@email.de"
/>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Wird gesendet…' : 'Link zum Zurücksetzen senden'}
</Button>
<Link
to="/login"
className="flex items-center justify-center gap-1 text-sm text-gray-600 hover:text-gray-900"
>
<ArrowLeft className="w-4 h-4" />
Zurück zum Login
</Link>
</form>
</Card>
</div>
);
}
+2 -40
View File
@@ -1,7 +1,7 @@
import { Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import Card from '../components/ui/Card';
import { Settings as SettingsIcon, Code, Store, Clock, Calendar, UserCog, ChevronRight, Building2, FileType, Eye, Globe, Mail, Database, Shield, ShieldAlert, FileText, FileEdit, PackageCheck } from 'lucide-react';
import { Settings as SettingsIcon, Code, Store, Clock, Calendar, UserCog, ChevronRight, Building2, FileType, Eye, Globe, Mail, Database, Shield, FileText, FileEdit } from 'lucide-react';
export default function Settings() {
const { hasPermission, developerMode, setDeveloperMode } = useAuth();
@@ -156,7 +156,7 @@ export default function Settings() {
Email-Provisionierung
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
</h3>
<p className="text-sm text-gray-500 mt-1">Konfigurieren Sie die automatische E-Mail-Erstellung für Kunden-E-Mail-Adressen.</p>
<p className="text-sm text-gray-500 mt-1">Konfigurieren Sie die automatische E-Mail-Erstellung für Stressfrei-Wechseln Adressen.</p>
</div>
</div>
</Link>
@@ -177,23 +177,6 @@ export default function Settings() {
</div>
</div>
</Link>
<Link
to="/settings/factory-defaults"
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group"
>
<div className="flex items-start gap-4">
<div className="p-2 bg-blue-50 rounded-lg group-hover:bg-blue-100 transition-colors">
<PackageCheck className="w-6 h-6 text-blue-600" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-gray-900 group-hover:text-blue-600 transition-colors flex items-center gap-2">
Factory-Defaults
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
</h3>
<p className="text-sm text-gray-500 mt-1">Stammdaten-Kataloge (Anbieter, Tarife, PDF-Vorlagen) exportieren ohne Kundendaten.</p>
</div>
</div>
</Link>
{hasPermission('audit:read') && (
<Link
to="/settings/audit-logs"
@@ -238,27 +221,6 @@ export default function Settings() {
</div>
</Link>
)}
{hasPermission('settings:read') && (
<Link
to="/settings/monitoring"
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-orange-300 transition-all group"
>
<div className="flex items-start gap-4">
<div className="p-2 bg-orange-50 rounded-lg group-hover:bg-orange-100 transition-colors">
<ShieldAlert className="w-6 h-6 text-orange-600" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-gray-900 group-hover:text-orange-600 transition-colors flex items-center gap-2">
Sicherheits-Monitoring
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
</h3>
<p className="text-sm text-gray-500 mt-1">
Login-Fehlversuche, IDOR-Abwehr, SSRF-Blocks etc. + Alert-E-Mail-Adresse konfigurieren.
</p>
</div>
</div>
</Link>
)}
{hasPermission('gdpr:admin') && (
<Link
to="/settings/gdpr"
+19 -106
View File
@@ -17,9 +17,7 @@ import { Edit, Trash2, Copy, Eye, EyeOff, ArrowLeft, ArrowRight, Download, Exter
import { calculateConsumption, calculateCosts, calculateMultiMeterConsumption } from '../../utils/energyCalculations';
import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
import { formatDate } from '../../utils/dateFormat';
import { useProviderSettings } from '../../hooks/useProviderSettings';
import type { ContractType, ContractStatus, SimCard, MeterReading, ContractTask, ContractTaskSubtask, ContractMeter, ContractDocument } from '../../types';
import { fileUrl } from '../../utils/fileUrl';
const typeLabels: Record<ContractType, string> = {
ELECTRICITY: 'Strom',
@@ -1446,7 +1444,6 @@ export default function ContractDetail() {
const currentPath = `/contracts/${id}`;
const { hasPermission, isCustomer, isCustomerPortal } = useAuth();
const contractId = parseInt(id!);
const { customerEmailLabel } = useProviderSettings();
const [showPassword, setShowPassword] = useState(false);
const [decryptedPassword, setDecryptedPassword] = useState<string | null>(null);
@@ -1472,12 +1469,6 @@ export default function ContractDetail() {
// Un-Snooze Bestätigungsmodal
const [showUnsnoozeConfirm, setShowUnsnoozeConfirm] = useState(false);
// Kündigungsbestätigung-Upload: File gepuffert, Datum-Modal offen
const [pendingCancelFile, setPendingCancelFile] = useState<File | null>(null);
const [cancelConfirmDate, setCancelConfirmDate] = useState<string>(
() => new Date().toISOString().split('T')[0]
);
const { data, isLoading } = useQuery({
queryKey: ['contract', id],
queryFn: () => contractApi.getById(contractId),
@@ -2035,7 +2026,7 @@ export default function ContractDetail() {
{c.cancellationLetterPath ? (
<div className="flex items-center gap-3 flex-wrap">
<a
href={fileUrl(c.cancellationLetterPath)}
href={`/api${c.cancellationLetterPath}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
@@ -2044,7 +2035,7 @@ export default function ContractDetail() {
Anzeigen
</a>
<a
href={fileUrl(c.cancellationLetterPath)}
href={`/api${c.cancellationLetterPath}`}
download
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
>
@@ -2092,7 +2083,7 @@ export default function ContractDetail() {
<>
<div className="flex items-center gap-3 flex-wrap">
<a
href={fileUrl(c.cancellationConfirmationPath)}
href={`/api${c.cancellationConfirmationPath}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
@@ -2101,7 +2092,7 @@ export default function ContractDetail() {
Anzeigen
</a>
<a
href={fileUrl(c.cancellationConfirmationPath)}
href={`/api${c.cancellationConfirmationPath}`}
download
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
>
@@ -2110,13 +2101,8 @@ export default function ContractDetail() {
</a>
<FileUpload
onUpload={async (file) => {
// Datei puffern, Datums-Modal öffnen
setCancelConfirmDate(
c.cancellationConfirmationDate
? c.cancellationConfirmationDate.split('T')[0]
: new Date().toISOString().split('T')[0]
);
setPendingCancelFile(file);
await uploadApi.uploadCancellationConfirmation(contractId, file);
queryClient.invalidateQueries({ queryKey: ['contract', id] });
}}
existingFile={c.cancellationConfirmationPath}
accept=".pdf"
@@ -2163,8 +2149,8 @@ export default function ContractDetail() {
) : (
<FileUpload
onUpload={async (file) => {
setCancelConfirmDate(new Date().toISOString().split('T')[0]);
setPendingCancelFile(file);
await uploadApi.uploadCancellationConfirmation(contractId, file);
queryClient.invalidateQueries({ queryKey: ['contract', id] });
}}
accept=".pdf"
label="PDF hochladen"
@@ -2178,7 +2164,7 @@ export default function ContractDetail() {
{c.cancellationLetterOptionsPath ? (
<div className="flex items-center gap-3 flex-wrap">
<a
href={fileUrl(c.cancellationLetterOptionsPath)}
href={`/api${c.cancellationLetterOptionsPath}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
@@ -2187,7 +2173,7 @@ export default function ContractDetail() {
Anzeigen
</a>
<a
href={fileUrl(c.cancellationLetterOptionsPath)}
href={`/api${c.cancellationLetterOptionsPath}`}
download
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
>
@@ -2235,7 +2221,7 @@ export default function ContractDetail() {
<>
<div className="flex items-center gap-3 flex-wrap">
<a
href={fileUrl(c.cancellationConfirmationOptionsPath)}
href={`/api${c.cancellationConfirmationOptionsPath}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
@@ -2244,7 +2230,7 @@ export default function ContractDetail() {
Anzeigen
</a>
<a
href={fileUrl(c.cancellationConfirmationOptionsPath)}
href={`/api${c.cancellationConfirmationOptionsPath}`}
download
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
>
@@ -2324,7 +2310,7 @@ export default function ContractDetail() {
<dt className="text-sm text-gray-500">
Benutzername
{c.stressfreiEmail && (
<span className="ml-2 text-xs text-blue-600">({customerEmailLabel})</span>
<span className="ml-2 text-xs text-blue-600">(Stressfrei-Wechseln)</span>
)}
</dt>
<dd className="font-mono flex items-center gap-1">
@@ -3080,52 +3066,6 @@ export default function ContractDetail() {
{/* Status-Info Modal */}
<StatusInfoModal isOpen={showStatusInfo} onClose={() => setShowStatusInfo(false)} />
{/* Kündigungsbestätigung: Datum erfassen und dann Upload */}
<Modal
isOpen={pendingCancelFile !== null}
onClose={() => setPendingCancelFile(null)}
title="Kündigungsbestätigung Datum angeben"
size="sm"
>
<div className="space-y-4">
<p className="text-sm text-gray-700">
Wann wurde die Kündigung vom Anbieter bestätigt? Du kannst das Datum auch später noch anpassen.
</p>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Bestätigung erhalten am</label>
<input
type="date"
value={cancelConfirmDate}
onChange={(e) => setCancelConfirmDate(e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex justify-end gap-3 pt-2">
<Button variant="secondary" onClick={() => setPendingCancelFile(null)}>
Abbrechen
</Button>
<Button
onClick={async () => {
if (!pendingCancelFile) return;
try {
await uploadApi.uploadCancellationConfirmation(
contractId,
pendingCancelFile,
cancelConfirmDate || undefined,
);
queryClient.invalidateQueries({ queryKey: ['contract', id] });
setPendingCancelFile(null);
} catch (err) {
alert('Fehler beim Hochladen: ' + (err instanceof Error ? err.message : 'Unbekannt'));
}
}}
>
Hochladen
</Button>
</div>
</div>
</Modal>
{/* Un-Snooze Bestätigungsmodal */}
<Modal
isOpen={showUnsnoozeConfirm}
@@ -3181,9 +3121,6 @@ function ContractDocumentsSection({
const [showUpload, setShowUpload] = useState(false);
const [uploadType, setUploadType] = useState(DOCUMENT_TYPES[0]);
const [uploadNotes, setUploadNotes] = useState('');
const [uploadDeliveryDate, setUploadDeliveryDate] = useState<string>(
() => new Date().toISOString().split('T')[0],
);
const { data: docsData } = useQuery({
queryKey: ['contract-documents', contractId],
@@ -3191,12 +3128,10 @@ function ContractDocumentsSection({
});
const uploadMutation = useMutation({
mutationFn: ({ file, documentType, notes, deliveryDate }: { file: File; documentType: string; notes?: string; deliveryDate?: string }) =>
contractApi.uploadDocument(contractId, file, documentType, notes, deliveryDate),
mutationFn: ({ file, documentType, notes }: { file: File; documentType: string; notes?: string }) =>
contractApi.uploadDocument(contractId, file, documentType, notes),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contract-documents', contractId] });
// Contract selbst neu laden Status kann sich durch Lieferbestätigung geändert haben
queryClient.invalidateQueries({ queryKey: ['contract'] });
setShowUpload(false);
setUploadNotes('');
},
@@ -3211,17 +3146,10 @@ function ContractDocumentsSection({
const documents: ContractDocument[] = docsData?.data || [];
const isDelivery = uploadType.trim().toLowerCase() === 'lieferbestätigung';
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
uploadMutation.mutate({
file,
documentType: uploadType,
notes: uploadNotes || undefined,
deliveryDate: isDelivery ? uploadDeliveryDate : undefined,
});
uploadMutation.mutate({ file, documentType: uploadType, notes: uploadNotes || undefined });
}
};
@@ -3267,20 +3195,6 @@ function ContractDocumentsSection({
/>
</div>
</div>
{isDelivery && (
<div className="mb-3 p-3 bg-white border border-blue-300 rounded">
<label className="block text-sm font-medium text-gray-700 mb-1">Lieferdatum</label>
<input
type="date"
value={uploadDeliveryDate}
onChange={(e) => setUploadDeliveryDate(e.target.value)}
className="block w-full max-w-[220px] px-3 py-2 border border-gray-300 rounded-lg text-sm"
/>
<p className="text-xs text-gray-500 mt-1">
Falls der Vertrag noch auf Entwurf steht, wird er auf Aktiv gesetzt und dieses Datum als Vertragsbeginn übernommen.
</p>
</div>
)}
<div className="flex items-center gap-3">
<label className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 cursor-pointer text-sm">
<Plus className="w-4 h-4" />
@@ -3311,7 +3225,7 @@ function ContractDocumentsSection({
{doc.documentType}
</span>
<a
href={fileUrl(doc.documentPath)}
href={`/api${doc.documentPath}`}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:underline"
@@ -3328,7 +3242,7 @@ function ContractDocumentsSection({
</div>
<div className="flex items-center gap-2">
<a
href={fileUrl(doc.documentPath)}
href={`/api${doc.documentPath}`}
download
className="text-gray-400 hover:text-blue-600"
title="Herunterladen"
@@ -3449,7 +3363,6 @@ function GenerateInputModal({ templateId, templateName, contractId, onClose }: {
const [stressfreiEmailId, setStressfreiEmailId] = useState('');
const [manualValues, setManualValues] = useState<Record<string, string>>({});
const [generating] = useState(false);
const { customerEmailLabel } = useProviderSettings();
const { data: inputsData, isLoading } = useQuery({
queryKey: ['pdf-inputs', templateId, contractId],
@@ -3477,7 +3390,7 @@ function GenerateInputModal({ templateId, templateName, contractId, onClose }: {
<div className="space-y-4">
{inputs?.needsStressfreiEmail && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{customerEmailLabel} E-Mail</label>
<label className="block text-sm font-medium text-gray-700 mb-1">Stressfrei-Wechseln E-Mail</label>
<select
value={stressfreiEmailId}
onChange={(e) => setStressfreiEmailId(e.target.value)}
@@ -10,7 +10,6 @@ import Input from '../../components/ui/Input';
import Select from '../../components/ui/Select';
import type { ContractType } from '../../types';
import { formatDate } from '../../utils/dateFormat';
import { useProviderSettings } from '../../hooks/useProviderSettings';
import { Plus, Trash2, Eye, EyeOff, Info, X, ArrowLeft } from 'lucide-react';
// Contract types are now loaded dynamically from the database
@@ -68,7 +67,6 @@ export default function ContractForm() {
const queryClient = useQueryClient();
const isEdit = !!id;
const back = popHistory(location.state, isEdit ? `/contracts/${id}` : '/contracts');
const { customerEmailLabel } = useProviderSettings();
const preselectedCustomerId = searchParams.get('customerId');
@@ -916,7 +914,7 @@ export default function ContractForm() {
}}
className="text-blue-600"
/>
<span className="text-sm">{customerEmailLabel} Adresse</span>
<span className="text-sm">Stressfrei-Wechseln Adresse</span>
</label>
{usernameType === 'stressfrei' && (
<Select
@@ -931,7 +929,7 @@ export default function ContractForm() {
)}
{usernameType === 'stressfrei' && stressfreiEmails.length === 0 && (
<p className="text-xs text-amber-600">
Keine {customerEmailLabel} Adressen für diesen Kunden vorhanden. Bitte zuerst beim Kunden anlegen.
Keine Stressfrei-Wechseln Adressen für diesen Kunden vorhanden. Bitte zuerst beim Kunden anlegen.
</p>
)}
</div>
+20 -53
View File
@@ -13,14 +13,11 @@ import Modal from '../../components/ui/Modal';
import Input from '../../components/ui/Input';
import Select from '../../components/ui/Select';
import FileUpload from '../../components/ui/FileUpload';
import { Edit, Plus, Trash2, MapPin, CreditCard, FileText, Gauge, Eye, EyeOff, Download, Globe, UserPlus, X, Search, Mail, Copy, Check, ChevronDown, ChevronRight, Info, Shield, ShieldCheck, ShieldX, ShieldAlert, Lock, ArrowLeft, Cake } from 'lucide-react';
import { Edit, Plus, Trash2, MapPin, CreditCard, FileText, Gauge, Eye, EyeOff, Download, Globe, UserPlus, X, Search, Mail, Copy, Check, ChevronDown, ChevronRight, Info, Shield, ShieldCheck, ShieldX, ShieldAlert, Lock, ArrowLeft } from 'lucide-react';
import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
import BirthdayManagementModal from '../../components/BirthdayManagementModal';
import { formatDate } from '../../utils/dateFormat';
import { getContractTypeInfo } from '../../utils/contractInfo';
import { useProviderSettings } from '../../hooks/useProviderSettings';
import type { Address, BankCard, IdentityDocument, Meter, Customer, CustomerRepresentative, CustomerSummary, CustomerConsent, ConsentType, ConsentStatus, RepresentativeAuthorization } from '../../types';
import { fileUrl } from '../../utils/fileUrl';
export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?: number } = {}) {
const { id } = useParams();
@@ -33,7 +30,6 @@ export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?
const customerId = portalCustomerId || parseInt(id!);
const defaultTab = searchParams.get('tab') || 'addresses';
const [activeTab, setActiveTab] = useState(defaultTab);
const { customerEmailLabel } = useProviderSettings();
// Tab-Wechsel in URL synchronisieren (für Browser-History)
const handleTabChange = (tabId: string) => {
@@ -46,7 +42,6 @@ export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?
const [showDocumentModal, setShowDocumentModal] = useState(false);
const [showMeterModal, setShowMeterModal] = useState(false);
const [showStressfreiEmailModal, setShowStressfreiEmailModal] = useState(false);
const [showBirthdayModal, setShowBirthdayModal] = useState(false);
const [showInactive, setShowInactive] = useState(false);
const [editingBankCard, setEditingBankCard] = useState<BankCard | null>(null);
const [editingDocument, setEditingDocument] = useState<IdentityDocument | null>(null);
@@ -151,7 +146,7 @@ export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?
},
...(!isCustomerPortal ? [{
id: 'stressfrei',
label: customerEmailLabel,
label: 'Stressfrei-Wechseln',
content: (
<StressfreiEmailsTab
customerId={customerId}
@@ -272,14 +267,6 @@ export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?
</dd>
</div>
)}
<div>
<dt className="text-sm text-gray-500">Anrede per</dt>
<dd>
<Badge variant={c.useInformalAddress ? 'info' : 'default'}>
{c.useInformalAddress ? 'Du (informell)' : 'Sie (formell)'}
</Badge>
</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Vorname</dt>
<dd className="flex items-center gap-1">
@@ -318,16 +305,6 @@ export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?
<dd className="flex items-center gap-1">
{new Date(c.birthDate).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })}
<CopyButton value={new Date(c.birthDate).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })} />
{!isCustomerPortal && hasPermission('customers:update') && (
<button
type="button"
onClick={() => setShowBirthdayModal(true)}
className="ml-1 p-1 hover:bg-pink-50 rounded transition-colors"
title="Geburtstag verwalten"
>
<Cake className="w-4 h-4 text-pink-500" />
</button>
)}
</dd>
</div>
)}
@@ -466,13 +443,6 @@ export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?
email={editingStressfreiEmail}
customerEmail={customer?.data?.email}
/>
{showBirthdayModal && c && (
<BirthdayManagementModal
customer={c}
onClose={() => setShowBirthdayModal(false)}
/>
)}
</div>
);
}
@@ -565,7 +535,7 @@ function BusinessDataCard({
{customer.businessRegistrationPath ? (
<div className="flex items-center gap-2 flex-wrap">
<a
href={fileUrl(customer.businessRegistrationPath)}
href={`/api${customer.businessRegistrationPath}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
@@ -574,7 +544,7 @@ function BusinessDataCard({
Anzeigen
</a>
<a
href={fileUrl(customer.businessRegistrationPath)}
href={`/api${customer.businessRegistrationPath}`}
download
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
>
@@ -616,7 +586,7 @@ function BusinessDataCard({
{customer.commercialRegisterPath ? (
<div className="flex items-center gap-2 flex-wrap">
<a
href={fileUrl(customer.commercialRegisterPath)}
href={`/api${customer.commercialRegisterPath}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
@@ -625,7 +595,7 @@ function BusinessDataCard({
Anzeigen
</a>
<a
href={fileUrl(customer.commercialRegisterPath)}
href={`/api${customer.commercialRegisterPath}`}
download
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
>
@@ -936,7 +906,7 @@ function BankCardsTab({
{card.documentPath ? (
<div className="flex items-center gap-2 flex-wrap">
<a
href={fileUrl(card.documentPath)}
href={`/api${card.documentPath}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
@@ -945,7 +915,7 @@ function BankCardsTab({
Anzeigen
</a>
<a
href={fileUrl(card.documentPath)}
href={`/api${card.documentPath}`}
download
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
>
@@ -1172,7 +1142,7 @@ function DocumentsTab({
{doc.documentPath ? (
<div className="flex items-center gap-2 flex-wrap">
<a
href={fileUrl(doc.documentPath)}
href={`/api${doc.documentPath}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
@@ -1181,7 +1151,7 @@ function DocumentsTab({
Anzeigen
</a>
<a
href={fileUrl(doc.documentPath)}
href={`/api${doc.documentPath}`}
download
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
>
@@ -2931,7 +2901,9 @@ function MeterReadingModal({
);
}
// ==================== KUNDEN-E-MAIL TAB ====================
// ==================== STRESSFREI-WECHSELN E-MAIL TAB ====================
const STRESSFREI_DOMAIN = '@stressfrei-wechseln.de';
function StressfreiEmailsTab({
customerId,
@@ -2951,7 +2923,6 @@ function StressfreiEmailsTab({
onEdit: (email: StressfreiEmail) => void;
}) {
const queryClient = useQueryClient();
const { customerEmailLabel } = useProviderSettings();
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<StressfreiEmail> }) =>
@@ -3069,7 +3040,7 @@ function StressfreiEmailsTab({
))}
</div>
) : (
<p className="text-gray-500">Keine {customerEmailLabel} Adressen vorhanden.</p>
<p className="text-gray-500">Keine Stressfrei-Wechseln Adressen vorhanden.</p>
)}
</div>
);
@@ -3242,10 +3213,6 @@ function StressfreiEmailModal({
const [provisionError, setProvisionError] = useState<string | null>(null);
const [providerStatus, setProviderStatus] = useState<'idle' | 'checking' | 'exists' | 'not_exists' | 'error'>('idle');
const [isProvisioning, setIsProvisioning] = useState(false);
// Domain dynamisch vom Provider (mit Fallback)
const { domain: providerDomain } = useProviderSettings();
const domainSuffix = `@${providerDomain || 'stressfrei-wechseln.de'}`;
const [isEnablingMailbox, setIsEnablingMailbox] = useState(false);
const [mailboxEnabled, setMailboxEnabled] = useState(false);
const [showCredentials, setShowCredentials] = useState(false);
@@ -3452,7 +3419,7 @@ function StressfreiEmailModal({
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setProvisionError(null);
const fullEmail = localPart + domainSuffix;
const fullEmail = localPart + STRESSFREI_DOMAIN;
if (isEditing) {
updateMutation.mutate({
@@ -3488,11 +3455,11 @@ function StressfreiEmailModal({
className="block w-full px-3 py-2 border border-gray-300 rounded-l-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<span className="inline-flex items-center px-3 py-2 border border-l-0 border-gray-300 bg-gray-100 text-gray-600 rounded-r-lg text-sm">
{domainSuffix}
{STRESSFREI_DOMAIN}
</span>
</div>
<p className="text-xs text-gray-500 mt-1">
Vollständige Adresse: <span className="font-mono">{localPart || '...'}{domainSuffix}</span>
Vollständige Adresse: <span className="font-mono">{localPart || '...'}{STRESSFREI_DOMAIN}</span>
</p>
</div>
@@ -3926,7 +3893,7 @@ function ConsentTab({
{customer.privacyPolicyPath ? (
<div className="flex items-center gap-3 flex-wrap">
<a
href={fileUrl(customer.privacyPolicyPath)}
href={`/api${customer.privacyPolicyPath}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
@@ -3935,7 +3902,7 @@ function ConsentTab({
Anzeigen
</a>
<a
href={fileUrl(customer.privacyPolicyPath)}
href={`/api${customer.privacyPolicyPath}`}
download
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
>
@@ -4232,7 +4199,7 @@ function AuthorizationsSection({ customerId, customerEmail }: { customerId: numb
{auth.documentPath ? (
<>
<a
href={fileUrl(auth.documentPath)}
href={`/api${auth.documentPath}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline text-xs flex items-center gap-1"
+15 -27
View File
@@ -39,8 +39,6 @@ export default function CustomerForm() {
if (data.foundingDate) {
data.foundingDate = data.foundingDate.split('T')[0] as any;
}
// Boolean → String für <select>-Wert ('true'/'false')
(data as any).useInformalAddress = data.useInformalAddress === true ? 'true' : 'false';
reset(data);
}
}, [customer, reset]);
@@ -67,9 +65,6 @@ export default function CustomerForm() {
const submitData: any = {
type: data.type,
salutation: data.salutation || '',
useInformalAddress:
data.useInformalAddress === true ||
(data.useInformalAddress as any) === 'true',
firstName: data.firstName,
lastName: data.lastName,
companyName: data.companyName || '',
@@ -140,17 +135,6 @@ export default function CustomerForm() {
]}
/>
<Select
label="Anrede per *"
{...register('useInformalAddress', {
setValueAs: (v) => v === 'true' || v === true,
})}
options={[
{ value: 'false', label: 'Sie (formell)' },
{ value: 'true', label: 'Du (informell)' },
]}
/>
<Input
label="Vorname"
{...register('firstName', { required: 'Vorname erforderlich' })}
@@ -180,18 +164,22 @@ export default function CustomerForm() {
</>
)}
<Input
label="Geburtsdatum"
type="date"
{...register('birthDate')}
value={watch('birthDate') || ''}
onClear={() => setValue('birthDate', '' as any)}
/>
{customerType !== 'BUSINESS' && (
<>
<Input
label="Geburtsdatum"
type="date"
{...register('birthDate')}
value={watch('birthDate') || ''}
onClear={() => setValue('birthDate', '' as any)}
/>
<Input
label="Geburtsort"
{...register('birthPlace')}
/>
<Input
label="Geburtsort"
{...register('birthPlace')}
/>
</>
)}
</div>
</Card>
+1 -7
View File
@@ -2,12 +2,6 @@ import { useQuery } from '@tanstack/react-query';
import { gdprApi } from '../../services/api';
import Card from '../../components/ui/Card';
import { Building } from 'lucide-react';
import DOMPurify from 'dompurify';
const SANITIZE_OPTIONS = {
FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form', 'input', 'button'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'formaction'],
};
export default function PortalImprint() {
const { data, isLoading, isError } = useQuery({
@@ -28,7 +22,7 @@ export default function PortalImprint() {
<h1 className="text-2xl font-bold">Impressum</h1>
</div>
<Card>
<div className="prose prose-sm max-w-none" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html, SANITIZE_OPTIONS) }} />
<div className="prose prose-sm max-w-none" dangerouslySetInnerHTML={{ __html: html }} />
</Card>
</div>
);
+1 -7
View File
@@ -11,12 +11,6 @@ import {
CheckCircle2,
} from 'lucide-react';
import Card from '../../components/ui/Card';
import DOMPurify from 'dompurify';
const SANITIZE_OPTIONS = {
FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form', 'input', 'button'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'formaction'],
};
const CONSENT_TYPE_LABELS: Record<ConsentType, { label: string; description: string }> = {
DATA_PROCESSING: {
@@ -184,7 +178,7 @@ export default function PortalPrivacy() {
</div>
<div
className="prose prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(privacyPolicyHtml, SANITIZE_OPTIONS) }}
dangerouslySetInnerHTML={{ __html: privacyPolicyHtml }}
/>
</Card>
@@ -2,12 +2,6 @@ import { useQuery } from '@tanstack/react-query';
import { gdprApi } from '../../services/api';
import Card from '../../components/ui/Card';
import { Shield } from 'lucide-react';
import DOMPurify from 'dompurify';
const SANITIZE_OPTIONS = {
FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form', 'input', 'button'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'formaction'],
};
export default function PortalWebsitePrivacy() {
const { data, isLoading, isError } = useQuery({
@@ -28,7 +22,7 @@ export default function PortalWebsitePrivacy() {
<h1 className="text-2xl font-bold">Datenschutzerklärung</h1>
</div>
<Card>
<div className="prose prose-sm max-w-none" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html, SANITIZE_OPTIONS) }} />
<div className="prose prose-sm max-w-none" dangerouslySetInnerHTML={{ __html: html }} />
</Card>
</div>
);
+1 -7
View File
@@ -4,12 +4,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { publicApi } from '../../services/api';
import { formatDate } from '../../utils/dateFormat';
import { Shield, CheckCircle2, FileDown, Loader2 } from 'lucide-react';
import DOMPurify from 'dompurify';
const SANITIZE_OPTIONS = {
FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form', 'input', 'button'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'formaction'],
};
export default function ConsentPage() {
const { hash } = useParams<{ hash: string }>();
@@ -156,7 +150,7 @@ export default function ConsentPage() {
</div>
<div
className="p-6 prose prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(privacyPolicyHtml, SANITIZE_OPTIONS) }}
dangerouslySetInnerHTML={{ __html: privacyPolicyHtml }}
/>
</div>
@@ -13,9 +13,6 @@ const CONTEXT_LABELS: Record<string, string> = {
'consent-link': 'Datenschutz-Link',
'authorization-request': 'Vollmacht-Anfrage',
'customer-email': 'Kunden-E-Mail',
'birthday-greeting': 'Geburtstagsgruß (manuell)',
'birthday-greeting-auto': 'Geburtstagsgruß (automatisch)',
'password-reset': 'Passwort-Reset',
};
export default function EmailLogs() {
+10 -174
View File
@@ -80,13 +80,6 @@ export default function EmailProviders() {
// Test-Status pro Provider in der Liste
const [providerTestResults, setProviderTestResults] = useState<Record<number, TestResult | null>>({});
const [testingProviderId, setTestingProviderId] = useState<number | null>(null);
// E-Mail-Zugang-Test
const [isTestingMailAccess, setIsTestingMailAccess] = useState(false);
const [mailAccessResult, setMailAccessResult] = useState<{
imap: { success: boolean; error?: string; server: string; port: number; encryption: string };
smtp: { success: boolean; error?: string; server: string; port: number; encryption: string };
user: string;
} | null>(null);
const { data: configsData, isLoading } = useQuery({
queryKey: ['email-provider-configs'],
@@ -98,7 +91,6 @@ export default function EmailProviders() {
emailProviderApi.createConfig(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['email-provider-configs'] });
queryClient.invalidateQueries({ queryKey: ['email-provider-public-settings'] });
closeModal();
},
});
@@ -108,7 +100,6 @@ export default function EmailProviders() {
emailProviderApi.updateConfig(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['email-provider-configs'] });
queryClient.invalidateQueries({ queryKey: ['email-provider-public-settings'] });
closeModal();
},
});
@@ -117,7 +108,6 @@ export default function EmailProviders() {
mutationFn: (id: number) => emailProviderApi.deleteConfig(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['email-provider-configs'] });
queryClient.invalidateQueries({ queryKey: ['email-provider-public-settings'] });
},
});
@@ -161,7 +151,6 @@ export default function EmailProviders() {
setFormData(emptyForm);
setShowPassword(false);
setModalTestResult(null);
setMailAccessResult(null);
};
// Test für einen gespeicherten Provider in der Liste
@@ -227,57 +216,6 @@ export default function EmailProviders() {
}
};
// IMAP + SMTP-Zugang der System-E-Mail testen
const handleTestMailAccess = async () => {
if (!formData.systemEmailAddress) {
setMailAccessResult({
imap: { success: false, error: 'System-E-Mail-Adresse fehlt', server: '', port: 0, encryption: '' },
smtp: { success: false, error: 'System-E-Mail-Adresse fehlt', server: '', port: 0, encryption: '' },
user: '',
});
return;
}
setIsTestingMailAccess(true);
setMailAccessResult(null);
try {
const body: Parameters<typeof emailProviderApi.testMailAccess>[0] = editingId
? {
id: editingId,
systemEmailAddress: formData.systemEmailAddress,
systemEmailPassword: formData.systemEmailPassword || undefined,
}
: {
apiUrl: formData.apiUrl,
domain: formData.domain,
systemEmailAddress: formData.systemEmailAddress,
systemEmailPassword: formData.systemEmailPassword,
imapEncryption: formData.imapEncryption,
smtpEncryption: formData.smtpEncryption,
allowSelfSignedCerts: formData.allowSelfSignedCerts,
};
const result = await emailProviderApi.testMailAccess(body);
if (result.data) {
setMailAccessResult(result.data);
}
} catch (error) {
setMailAccessResult({
imap: {
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Test',
server: '',
port: 0,
encryption: '',
},
smtp: { success: false, server: '', port: 0, encryption: '' },
user: '',
});
} finally {
setIsTestingMailAccess(false);
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -339,9 +277,9 @@ export default function EmailProviders() {
<Card className="mb-6">
<p className="text-gray-600 mb-4">
Hier konfigurieren Sie die automatische Erstellung von Kunden-E-Mail-Adressen auf Ihrer
eigenen Domain. Wenn beim Anlegen einer Adresse die Option "Bei Provider anlegen"
aktiviert ist, wird die E-Mail-Weiterleitung automatisch erstellt.
Hier konfigurieren Sie die automatische Erstellung von Stressfrei-Wechseln E-Mail-Adressen.
Wenn beim Anlegen einer Stressfrei-Adresse die Option "Bei Provider anlegen" aktiviert ist,
wird die E-Mail-Weiterleitung automatisch erstellt.
</p>
<Button onClick={openCreateModal}>
<Plus className="w-4 h-4 mr-2" />
@@ -545,32 +483,13 @@ export default function EmailProviders() {
</div>
</div>
<div>
<Input
label="Domain *"
value={formData.domain}
onChange={(e) =>
setFormData({ ...formData, domain: e.target.value.toLowerCase().trim() })
}
placeholder="stressfrei-wechseln.de"
required
pattern="^[a-z0-9]([a-z0-9\-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9\-]*[a-z0-9])?)+$"
title="Gültige Domain erforderlich, z.B. meine-firma.de oder mail.beispiel.com"
/>
{formData.domain &&
!/^[a-z0-9]([a-z0-9\-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9\-]*[a-z0-9])?)+$/.test(
formData.domain,
) && (
<p className="text-xs text-red-600 mt-1">
Keine gültige Domain Format: name.tld (z.B. meine-firma.de)
</p>
)}
</div>
<p className="text-xs text-gray-500 -mt-2">
Wird auch für die Kunden-E-Mail-Adressen genutzt (z.B. <code>name@{formData.domain || 'meine-domain.de'}</code>)
und als Bezeichnung im UI angezeigt ("{formData.domain ? formData.domain.split('.')[0].split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join('-') : 'Meine-Domain'}").
</p>
<Input
label="Domain *"
value={formData.domain}
onChange={(e) => setFormData({ ...formData, domain: e.target.value })}
placeholder="stressfrei-wechseln.de"
required
/>
<Input
label="Standard-Weiterleitungsadresse"
@@ -740,89 +659,6 @@ export default function EmailProviders() {
)}
</div>
)}
{/* E-Mail-Zugang testen (IMAP + SMTP) */}
<div className="mt-4">
<p className="text-xs text-gray-500 mb-2">
Testet den tatsächlichen E-Mail-Zugang (IMAP-Empfang und SMTP-Versand) der System-E-Mail.
</p>
<Button
type="button"
variant="secondary"
onClick={handleTestMailAccess}
disabled={isTestingMailAccess || !formData.systemEmailAddress}
className="w-full"
>
{isTestingMailAccess ? (
'Teste IMAP + SMTP...'
) : (
<>
<Mail className="w-4 h-4 mr-2" />
E-Mail-Zugang testen (IMAP + SMTP)
</>
)}
</Button>
{mailAccessResult && (
<div className="mt-2 space-y-2">
{/* IMAP-Ergebnis */}
<div className={`p-3 rounded-lg text-sm ${mailAccessResult.imap.success ? 'bg-green-50 text-green-800' : 'bg-red-50 text-red-800'}`}>
{mailAccessResult.imap.success ? (
<div className="flex items-center gap-2">
<Check className="w-4 h-4 flex-shrink-0" />
<span>
<strong>IMAP</strong> erfolgreich ({mailAccessResult.imap.server}:{mailAccessResult.imap.port}, {mailAccessResult.imap.encryption})
</span>
</div>
) : (
<div className="flex items-start gap-2">
<WifiOff className="w-4 h-4 flex-shrink-0 mt-0.5" />
<div>
<strong>IMAP</strong> fehlgeschlagen
{mailAccessResult.imap.server && (
<span className="text-xs opacity-75">
{' '}({mailAccessResult.imap.server}:{mailAccessResult.imap.port}
{mailAccessResult.imap.encryption ? `, ${mailAccessResult.imap.encryption}` : ''})
</span>
)}
{mailAccessResult.imap.error && (
<div className="mt-1 text-xs">{mailAccessResult.imap.error}</div>
)}
</div>
</div>
)}
</div>
{/* SMTP-Ergebnis */}
<div className={`p-3 rounded-lg text-sm ${mailAccessResult.smtp.success ? 'bg-green-50 text-green-800' : 'bg-red-50 text-red-800'}`}>
{mailAccessResult.smtp.success ? (
<div className="flex items-center gap-2">
<Check className="w-4 h-4 flex-shrink-0" />
<span>
<strong>SMTP</strong> erfolgreich ({mailAccessResult.smtp.server}:{mailAccessResult.smtp.port}, {mailAccessResult.smtp.encryption})
</span>
</div>
) : (
<div className="flex items-start gap-2">
<WifiOff className="w-4 h-4 flex-shrink-0 mt-0.5" />
<div>
<strong>SMTP</strong> fehlgeschlagen
{mailAccessResult.smtp.server && (
<span className="text-xs opacity-75">
{' '}({mailAccessResult.smtp.server}:{mailAccessResult.smtp.port}
{mailAccessResult.smtp.encryption ? `, ${mailAccessResult.smtp.encryption}` : ''})
</span>
)}
{mailAccessResult.smtp.error && (
<div className="mt-1 text-xs">{mailAccessResult.smtp.error}</div>
)}
</div>
</div>
)}
</div>
</div>
)}
</div>
</div>
<div className="flex justify-end gap-3 pt-4 border-t">
@@ -1,225 +0,0 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
import {
ArrowLeft,
Download,
Package,
Info,
Loader2,
Check,
AlertCircle,
Building2,
Tag,
Clock,
Calendar,
FileType,
FileText,
} from 'lucide-react';
import api from '../../services/api';
interface PreviewCounts {
providers: number;
tariffs: number;
cancellationPeriods: number;
contractDurations: number;
contractCategories: number;
pdfTemplates: number;
}
export default function FactoryDefaults() {
const [downloading, setDownloading] = useState(false);
const [downloadError, setDownloadError] = useState<string | null>(null);
const [downloadDone, setDownloadDone] = useState(false);
const { data: previewData, isLoading } = useQuery({
queryKey: ['factory-defaults-preview'],
queryFn: async () => {
const res = await api.get<{ success: boolean; data: { counts: PreviewCounts } }>(
'/factory-defaults/preview',
);
return res.data;
},
});
const counts = previewData?.data?.counts;
const handleExport = async () => {
setDownloading(true);
setDownloadError(null);
setDownloadDone(false);
try {
const res = await api.get('/factory-defaults/export', {
responseType: 'blob',
});
const blob = new Blob([res.data as BlobPart], { type: 'application/zip' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
const dateStr = new Date().toISOString().split('T')[0];
a.href = url;
a.download = `factory-defaults-${dateStr}.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
setDownloadDone(true);
setTimeout(() => setDownloadDone(false), 3000);
} catch (err) {
setDownloadError(
err instanceof Error ? err.message : 'Fehler beim Export',
);
} finally {
setDownloading(false);
}
};
const sections = counts
? [
{ icon: Building2, label: 'Anbieter', count: counts.providers, color: 'text-blue-600' },
{ icon: Tag, label: 'Tarife', count: counts.tariffs, color: 'text-indigo-600' },
{ icon: Clock, label: 'Kündigungsfristen', count: counts.cancellationPeriods, color: 'text-purple-600' },
{ icon: Calendar, label: 'Laufzeiten', count: counts.contractDurations, color: 'text-pink-600' },
{ icon: FileType, label: 'Vertragskategorien', count: counts.contractCategories, color: 'text-orange-600' },
{ icon: FileText, label: 'PDF-Auftragsvorlagen', count: counts.pdfTemplates, color: 'text-green-600' },
]
: [];
return (
<div>
<div className="flex items-center gap-4 mb-6">
<Link to="/settings">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4" />
</Button>
</Link>
<div className="flex items-center gap-3">
<Package className="w-6 h-6 text-blue-600" />
<h1 className="text-2xl font-bold">Factory-Defaults</h1>
</div>
</div>
{/* Info-Box */}
<div className="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-start gap-3">
<Info className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-900 space-y-1">
<p className="font-medium">Was sind Factory-Defaults?</p>
<p>
Das sind <strong>reine Stammdaten-Kataloge</strong> wie Anbieter, Tarife,
Kündigungsfristen, Vertragskategorien und PDF-Auftragsvorlagen. Du kannst sie
exportieren, um sie in anderen OpenCRM-Installationen als Startpunkt zu
verwenden.
</p>
<p className="pt-1">
<strong>Nicht enthalten</strong> sind Kundendaten, Verträge, Dokumente, Emails
oder Einstellungen dafür gibt es den separaten{' '}
<Link to="/settings/database-backup" className="underline">
Datenbank-Backup
</Link>
.
</p>
</div>
</div>
<Card title="Export" className="mb-6">
<p className="text-sm text-gray-600 mb-4">
Erstellt ein ZIP mit allen Kataloge-Daten + PDF-Vorlagen. Lade es herunter und
entpacke den Inhalt in einer anderen Installation unter{' '}
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-xs">
backend/factory-defaults/
</code>
, dann dort{' '}
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-xs">
npm run seed:defaults
</code>{' '}
ausführen.
</p>
{isLoading ? (
<div className="flex items-center gap-2 text-gray-500 py-4">
<Loader2 className="w-4 h-4 animate-spin" />
<span className="text-sm">Lade Übersicht</span>
</div>
) : counts ? (
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 mb-6">
{sections.map((s) => (
<div
key={s.label}
className="border border-gray-200 rounded-lg p-3 flex items-center gap-3"
>
<s.icon className={`w-5 h-5 ${s.color}`} />
<div>
<div className="text-sm text-gray-500">{s.label}</div>
<div className="text-xl font-bold">{s.count}</div>
</div>
</div>
))}
</div>
) : null}
<div className="flex items-center gap-3">
<Button onClick={handleExport} disabled={downloading}>
{downloading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
ZIP wird erstellt
</>
) : downloadDone ? (
<>
<Check className="w-4 h-4 mr-2" />
Heruntergeladen
</>
) : (
<>
<Download className="w-4 h-4 mr-2" />
Factory-Defaults exportieren
</>
)}
</Button>
</div>
{downloadError && (
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-lg flex items-start gap-2 text-sm text-red-700">
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
<span>{downloadError}</span>
</div>
)}
</Card>
<Card title="Import">
<div className="space-y-3 text-sm text-gray-600">
<p>
Der Import läuft über ein Kommandozeilen-Script dadurch bleibt klar, was wann
passiert und es gibt keine ungeplanten Überschreibungen im Produktivbetrieb.
</p>
<ol className="list-decimal list-inside space-y-1 ml-2">
<li>
ZIP in einer beliebigen Installation herunterladen und den Inhalt nach{' '}
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-xs">
backend/factory-defaults/
</code>{' '}
entpacken
</li>
<li>
Optional mehrere ZIPs zusammenwerfen (JSON-Dateien werden automatisch gemerged)
</li>
<li>
Im Backend-Ordner:{' '}
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-xs">
npm run seed:defaults
</code>
</li>
</ol>
<p className="pt-2">
Das Script läuft <strong>idempotent</strong> gleiche Einträge werden per
unique-Key aktualisiert, neue hinzugefügt. Kann beliebig oft ausgeführt werden.
</p>
</div>
</Card>
</div>
);
}
@@ -7,7 +7,6 @@ import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
import Select from '../../components/ui/Select';
import { ArrowLeft, FileText, Users, CheckCircle, Clock, XCircle, AlertTriangle, Download, X, ChevronRight } from 'lucide-react';
import { fileUrl } from '../../utils/fileUrl';
const STATUS_OPTIONS = [
{ value: '', label: 'Alle Status' },
@@ -363,7 +362,7 @@ export default function GDPRDashboard() {
<Button
variant="ghost"
size="sm"
onClick={() => window.open(fileUrl(`/uploads/${request.proofDocument}`), '_blank')}
onClick={() => window.open(`/api/uploads/${request.proofDocument}`, '_blank')}
title="Löschnachweis anzeigen"
>
<FileText className="w-4 h-4 text-blue-500" />
-423
View File
@@ -1,423 +0,0 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import toast from 'react-hot-toast';
import { monitoringApi, type SecurityEventType, type SecuritySeverity } from '../../services/api';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input';
import Select from '../../components/ui/Select';
import Modal from '../../components/ui/Modal';
import { ArrowLeft, Send, RefreshCw, Mail, ShieldAlert, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Trash2 } from 'lucide-react';
/**
* Liefert die anzuzeigenden Seitenzahlen für die Pagination.
* Bis zu 10 Seitenzahlen, current möglichst mittig.
*/
function paginationWindow(current: number, total: number, size = 10): number[] {
if (total <= size) return Array.from({ length: total }, (_, i) => i + 1);
let start = Math.max(1, current - Math.floor(size / 2));
let end = start + size - 1;
if (end > total) {
end = total;
start = end - size + 1;
}
return Array.from({ length: end - start + 1 }, (_, i) => start + i);
}
const TYPE_OPTIONS: { value: SecurityEventType | ''; label: string }[] = [
{ value: '', label: 'Alle Typen' },
{ value: 'LOGIN_FAILED', label: 'Login fehlgeschlagen' },
{ value: 'LOGIN_SUCCESS', label: 'Login erfolgreich' },
{ value: 'RATE_LIMIT_HIT', label: 'Rate-Limit greift' },
{ value: 'ACCESS_DENIED', label: 'Zugriff verweigert (IDOR)' },
{ value: 'SSRF_BLOCKED', label: 'SSRF blockiert' },
{ value: 'PASSWORD_RESET_REQUEST', label: 'Passwort-Reset angefordert' },
{ value: 'PASSWORD_RESET_CONFIRM', label: 'Passwort-Reset bestätigt' },
{ value: 'LOGOUT', label: 'Logout' },
{ value: 'TOKEN_REJECTED', label: 'Token abgelehnt' },
{ value: 'PERMISSION_CHANGED', label: 'Berechtigung geändert' },
{ value: 'SUSPICIOUS', label: 'Verdächtig (Threshold)' },
];
const SEVERITY_OPTIONS: { value: SecuritySeverity | ''; label: string }[] = [
{ value: '', label: 'Alle Stufen' },
{ value: 'INFO', label: 'Info' },
{ value: 'LOW', label: 'Niedrig' },
{ value: 'MEDIUM', label: 'Mittel' },
{ value: 'HIGH', label: 'Hoch' },
{ value: 'CRITICAL', label: 'Kritisch' },
];
function severityClass(s: SecuritySeverity): string {
switch (s) {
case 'CRITICAL': return 'bg-red-100 text-red-800 border border-red-300';
case 'HIGH': return 'bg-orange-100 text-orange-800 border border-orange-300';
case 'MEDIUM': return 'bg-yellow-100 text-yellow-800 border border-yellow-300';
case 'LOW': return 'bg-blue-100 text-blue-800 border border-blue-300';
default: return 'bg-gray-100 text-gray-700 border border-gray-300';
}
}
function severityIcon(s: SecuritySeverity): string {
switch (s) {
case 'CRITICAL': return '🚨';
case 'HIGH': return '⚠️';
case 'MEDIUM': return '🟡';
case 'LOW': return '🟢';
default: return '️';
}
}
export default function Monitoring() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [filters, setFilters] = useState({
type: '' as SecurityEventType | '',
severity: '' as SecuritySeverity | '',
search: '',
ip: '',
});
const [showClearConfirm, setShowClearConfirm] = useState(false);
const [clearOlderThanDays, setClearOlderThanDays] = useState<number | ''>('');
const [alertEmail, setAlertEmail] = useState('');
const [digestEnabled, setDigestEnabled] = useState(false);
// Settings laden
const { data: settingsData } = useQuery({
queryKey: ['monitoring-settings'],
queryFn: monitoringApi.getSettings,
});
// States nach Laden synchronisieren (nur initial)
if (settingsData?.data && alertEmail === '' && settingsData.data.alertEmail !== '') {
setAlertEmail(settingsData.data.alertEmail);
setDigestEnabled(settingsData.data.digestEnabled);
}
// Events laden
const { data: eventsData, isLoading: eventsLoading } = useQuery({
queryKey: ['monitoring-events', page, pageSize, filters],
queryFn: () => monitoringApi.getEvents({ page, limit: pageSize, ...filters }),
refetchInterval: 30_000, // alle 30s neu laden
});
const clearEvents = useMutation({
mutationFn: (olderThanDays?: number) => monitoringApi.clearEvents(olderThanDays),
onSuccess: (res) => {
toast.success(res.message || 'Events gelöscht');
setShowClearConfirm(false);
setClearOlderThanDays('');
queryClient.invalidateQueries({ queryKey: ['monitoring-events'] });
},
onError: (e: Error) => toast.error(e.message || 'Löschen fehlgeschlagen'),
});
const saveSettings = useMutation({
mutationFn: () => monitoringApi.updateSettings({ alertEmail, digestEnabled }),
onSuccess: () => {
toast.success('Einstellungen gespeichert');
queryClient.invalidateQueries({ queryKey: ['monitoring-settings'] });
},
onError: (e: Error) => toast.error(e.message || 'Speichern fehlgeschlagen'),
});
const testAlert = useMutation({
mutationFn: () => monitoringApi.testAlert(),
onSuccess: (res) => toast.success(res.message || 'Test-Alert versendet'),
onError: (e: Error) => toast.error(e.message || 'Test fehlgeschlagen'),
});
const runDigest = useMutation({
mutationFn: () => monitoringApi.runDigest(),
onSuccess: (res) => {
const r = res.data;
if (r?.sent) toast.success(`Digest mit ${r.eventCount} Events versendet`);
else toast(r?.reason || 'Kein Digest versendet', { icon: '️' });
},
onError: (e: Error) => toast.error(e.message || 'Digest fehlgeschlagen'),
});
const events = eventsData?.data || [];
const stats = eventsData?.stats;
const pagination = eventsData?.pagination;
return (
<div className="container mx-auto px-4 py-6 max-w-7xl">
<div className="mb-6">
<Button variant="ghost" onClick={() => navigate('/settings')} className="mb-2">
<ArrowLeft className="w-4 h-4 mr-1" /> Zurück zu Einstellungen
</Button>
<h1 className="text-2xl font-bold flex items-center gap-2">
<ShieldAlert className="w-6 h-6 text-orange-500" /> Sicherheits-Monitoring
</h1>
<p className="text-gray-600 text-sm mt-1">
Sicherheitsrelevante Ereignisse + Alert-Einstellungen.
</p>
</div>
{/* Settings */}
<Card className="mb-6">
<h2 className="text-lg font-semibold mb-3 flex items-center gap-2">
<Mail className="w-5 h-5" /> Alert-Empfänger
</h2>
<div className="grid sm:grid-cols-2 gap-4 mb-4">
<div>
<Input
label="E-Mail-Adresse für Alerts"
type="email"
value={alertEmail}
onChange={(e) => setAlertEmail(e.target.value)}
placeholder="security@deine-firma.de"
/>
<p className="text-xs text-gray-500 mt-1">Leer lassen, um Alerts zu deaktivieren.</p>
</div>
<div className="flex items-end gap-3">
<label className="flex items-center gap-2 mb-2">
<input
type="checkbox"
checked={digestEnabled}
onChange={(e) => setDigestEnabled(e.target.checked)}
className="w-4 h-4"
/>
<span className="text-sm">Stündlicher Digest (HIGH+MEDIUM Events)</span>
</label>
</div>
</div>
<div className="flex gap-3 flex-wrap">
<Button onClick={() => saveSettings.mutate()} disabled={saveSettings.isPending}>
Speichern
</Button>
<Button variant="secondary" onClick={() => testAlert.mutate()} disabled={!alertEmail || testAlert.isPending}>
<Send className="w-4 h-4 mr-1" /> Test-Alert senden
</Button>
<Button variant="secondary" onClick={() => runDigest.mutate()} disabled={!alertEmail || runDigest.isPending}>
<RefreshCw className="w-4 h-4 mr-1" /> Digest jetzt ausführen
</Button>
{settingsData?.data?.lastDigestAt && (
<span className="text-xs text-gray-500 self-center">
Letzter Digest: {new Date(settingsData.data.lastDigestAt).toLocaleString('de-DE')}
</span>
)}
</div>
<div className="mt-3 text-xs text-gray-600">
<strong>Sofort-Alert:</strong> CRITICAL-Events (z.B. Brute-Force-Verdacht) werden binnen 1 Minute per
E-Mail versendet.<br />
<strong>Digest:</strong> HIGH+MEDIUM-Events werden zur vollen Stunde gesammelt verschickt (wenn aktiviert).
</div>
</Card>
{/* Stats-Cards */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 mb-6">
{(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO'] as SecuritySeverity[]).map((sev) => (
<Card key={sev}>
<div className={`text-xs font-semibold ${severityClass(sev).split(' ').filter((c) => c.startsWith('text-'))[0]}`}>
{severityIcon(sev)} {sev}
</div>
<div className="text-2xl font-bold mt-1">{stats.bySeverity[sev] || 0}</div>
</Card>
))}
</div>
)}
{/* Filter */}
<Card className="mb-4">
<div className="grid sm:grid-cols-4 gap-3">
<Select
label="Typ"
value={filters.type}
onChange={(e) => { setFilters((f) => ({ ...f, type: e.target.value as any })); setPage(1); }}
options={TYPE_OPTIONS}
/>
<Select
label="Severity"
value={filters.severity}
onChange={(e) => { setFilters((f) => ({ ...f, severity: e.target.value as any })); setPage(1); }}
options={SEVERITY_OPTIONS}
/>
<Input
label="Suche (Nachricht/User/Endpoint)"
value={filters.search}
onChange={(e) => { setFilters((f) => ({ ...f, search: e.target.value })); setPage(1); }}
placeholder="z.B. admin@admin.com"
/>
<Input
label="IP-Adresse"
value={filters.ip}
onChange={(e) => { setFilters((f) => ({ ...f, ip: e.target.value })); setPage(1); }}
placeholder="z.B. 1.2.3.4"
/>
</div>
</Card>
{/* Tabelle */}
<Card>
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
<h2 className="text-lg font-semibold">Events</h2>
<div className="flex items-center gap-2">
<label className="text-sm text-gray-600">Pro Seite:</label>
<select
value={pageSize}
onChange={(e) => { setPageSize(parseInt(e.target.value)); setPage(1); }}
className="px-2 py-1 border border-gray-300 rounded text-sm"
>
<option value={10}>10</option>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
<option value={200}>200</option>
</select>
<Button variant="secondary" size="sm" onClick={() => setShowClearConfirm(true)}>
<Trash2 className="w-4 h-4 mr-1" /> Log leeren
</Button>
</div>
</div>
{eventsLoading ? (
<div className="text-gray-500 py-4">Lade</div>
) : events.length === 0 ? (
<div className="text-gray-500 py-8 text-center">Keine Events für diese Filter.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-left">
<tr>
<th className="px-3 py-2 whitespace-nowrap">Zeit</th>
<th className="px-3 py-2">Severity</th>
<th className="px-3 py-2">Typ</th>
<th className="px-3 py-2">Nachricht</th>
<th className="px-3 py-2">Wer</th>
<th className="px-3 py-2">IP</th>
<th className="px-3 py-2">Endpoint</th>
<th className="px-3 py-2">Alert</th>
</tr>
</thead>
<tbody>
{events.map((e) => (
<tr key={e.id} className="border-t hover:bg-gray-50">
<td className="px-3 py-2 font-mono text-xs whitespace-nowrap">
{new Date(e.createdAt).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'medium' })}
</td>
<td className="px-3 py-2">
<span className={`inline-block px-2 py-0.5 rounded text-xs font-semibold ${severityClass(e.severity)}`}>
{severityIcon(e.severity)} {e.severity}
</span>
</td>
<td className="px-3 py-2 font-mono text-xs">{e.type}</td>
<td className="px-3 py-2">{e.message}</td>
<td className="px-3 py-2 text-xs">{e.userEmail || (e.userId ? `User #${e.userId}` : e.customerId ? `Kunde #${e.customerId}` : '')}</td>
<td className="px-3 py-2 font-mono text-xs">{e.ipAddress || ''}</td>
<td className="px-3 py-2 font-mono text-xs">{e.endpoint || ''}</td>
<td className="px-3 py-2 text-xs">{e.alerted ? '✉️ ja' : ''}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{pagination && pagination.totalPages > 1 && (
<div className="flex flex-wrap items-center justify-between gap-3 mt-4 text-sm">
<span className="text-gray-600">
Seite {pagination.page} von {pagination.totalPages} ({pagination.total} Einträge)
</span>
<div className="flex items-center gap-1">
<button
onClick={() => setPage(1)}
disabled={page <= 1}
title="Erste Seite"
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-100"
>
<ChevronsLeft className="w-4 h-4" />
</button>
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
title="Vorherige Seite"
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-100"
>
<ChevronLeft className="w-4 h-4" />
</button>
{paginationWindow(page, pagination.totalPages, 10).map((p) => (
<button
key={p}
onClick={() => setPage(p)}
className={`min-w-[32px] px-2 py-1 rounded border text-sm ${
p === page
? 'bg-blue-600 text-white border-blue-600'
: 'border-gray-300 hover:bg-gray-100'
}`}
>
{p}
</button>
))}
<button
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
disabled={page >= pagination.totalPages}
title="Nächste Seite"
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-100"
>
<ChevronRight className="w-4 h-4" />
</button>
<button
onClick={() => setPage(pagination.totalPages)}
disabled={page >= pagination.totalPages}
title="Letzte Seite"
className="px-2 py-1 rounded border border-gray-300 disabled:opacity-40 hover:bg-gray-100"
>
<ChevronsRight className="w-4 h-4" />
</button>
</div>
</div>
)}
</Card>
{/* Clear-Confirm-Modal */}
<Modal
isOpen={showClearConfirm}
onClose={() => setShowClearConfirm(false)}
title="Security-Log leeren"
size="sm"
>
<div className="space-y-4">
<p className="text-sm text-gray-700">
Sicher? Alle Events werden aus der Datenbank entfernt. Ein
Audit-Log-Eintrag mit deinem Namen bleibt erhalten.
</p>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nur Events älter als (Tage)
</label>
<input
type="number"
min="0"
value={clearOlderThanDays}
onChange={(e) => setClearOlderThanDays(e.target.value === '' ? '' : parseInt(e.target.value))}
placeholder="leer = alle löschen"
className="block w-full max-w-[200px] px-3 py-2 border border-gray-300 rounded text-sm"
/>
<p className="text-xs text-gray-500 mt-1">
Beispiel: 30 = nur Events älter als 30 Tage löschen.
</p>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={() => setShowClearConfirm(false)}>
Abbrechen
</Button>
<Button
onClick={() => clearEvents.mutate(clearOlderThanDays === '' ? undefined : Number(clearOlderThanDays))}
disabled={clearEvents.isPending}
className="!bg-red-600 hover:!bg-red-700"
>
<Trash2 className="w-4 h-4 mr-1" />
{clearOlderThanDays === '' ? 'Alle löschen' : `Älter als ${clearOlderThanDays} Tage löschen`}
</Button>
</div>
</div>
</Modal>
</div>
);
}
+1 -2
View File
@@ -9,7 +9,6 @@ import Input from '../../components/ui/Input';
import Badge from '../../components/ui/Badge';
import Modal from '../../components/ui/Modal';
import { ArrowLeft, Plus, Edit, Trash2, FileText, Upload, Link2, Eye, Play } from 'lucide-react';
import { fileUrl } from '../../utils/fileUrl';
export default function PdfTemplates() {
const navigate = useNavigate();
@@ -96,7 +95,7 @@ export default function PdfTemplates() {
<Button variant="ghost" size="sm" onClick={() => setTestTemplate(t)} title="Testvorschau mit Vertragsdaten">
<Play className="w-4 h-4 text-green-500" />
</Button>
<a href={fileUrl(t.templatePath)} target="_blank" rel="noopener noreferrer">
<a href={`/api${t.templatePath}`} target="_blank" rel="noopener noreferrer">
<Button variant="ghost" size="sm" title="Leere Vorlage anzeigen">
<Eye className="w-4 h-4" />
</Button>
+3 -129
View File
@@ -585,18 +585,6 @@ export const cachedEmailApi = {
);
return res.data;
},
saveAttachmentAsContractDocument: async (
emailId: number,
filename: string,
params: { documentType: string; notes?: string; deliveryDate?: string },
) => {
const encodedFilename = encodeURIComponent(filename);
const res = await api.post<ApiResponse<{ id: number; documentType: string; documentPath: string }>>(
`/emails/${emailId}/attachments/${encodedFilename}/save-as-contract-document`,
params,
);
return res.data;
},
};
// Contracts - Vertragsbaum für Kundenansicht
@@ -683,12 +671,11 @@ export const contractApi = {
const res = await api.get<ApiResponse<import('../types').ContractDocument[]>>(`/contracts/${contractId}/documents`);
return res.data;
},
uploadDocument: async (contractId: number, file: File, documentType: string, notes?: string, deliveryDate?: string) => {
uploadDocument: async (contractId: number, file: File, documentType: string, notes?: string) => {
const formData = new FormData();
formData.append('file', file);
formData.append('documentType', documentType);
if (notes) formData.append('notes', notes);
if (deliveryDate) formData.append('deliveryDate', deliveryDate);
const res = await api.post<ApiResponse<import('../types').ContractDocument>>(`/contracts/${contractId}/documents`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
@@ -1088,10 +1075,9 @@ export const uploadApi = {
const res = await api.delete<ApiResponse<void>>(`/upload/contracts/${contractId}/cancellation-letter`);
return res.data;
},
uploadCancellationConfirmation: async (contractId: number, file: File, confirmationDate?: string) => {
uploadCancellationConfirmation: async (contractId: number, file: File) => {
const formData = new FormData();
formData.append('document', file);
if (confirmationDate) formData.append('confirmationDate', confirmationDate);
const res = await api.post<ApiResponse<{ path: string; filename: string }>>(`/upload/contracts/${contractId}/cancellation-confirmation`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
@@ -1113,10 +1099,9 @@ export const uploadApi = {
const res = await api.delete<ApiResponse<void>>(`/upload/contracts/${contractId}/cancellation-letter-options`);
return res.data;
},
uploadCancellationConfirmationOptions: async (contractId: number, file: File, confirmationDate?: string) => {
uploadCancellationConfirmationOptions: async (contractId: number, file: File) => {
const formData = new FormData();
formData.append('document', file);
if (confirmationDate) formData.append('confirmationDate', confirmationDate);
const res = await api.post<ApiResponse<{ path: string; filename: string }>>(`/upload/contracts/${contractId}/cancellation-confirmation-options`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
@@ -1203,8 +1188,6 @@ export interface EmailProviderConfig {
// System-E-Mail für automatisierten Versand
systemEmailAddress?: string;
systemEmailPasswordEncrypted?: string;
// UI-Label für Kunden-E-Mail-Adressen (z.B. "Stressfrei-Wechseln")
customerEmailLabel?: string | null;
isActive: boolean;
isDefault: boolean;
createdAt: string;
@@ -1259,35 +1242,10 @@ export const emailProviderApi = {
const res = await api.post<ApiResponse<EmailOperationResult>>('/email-providers/test-connection', body);
return res.data;
},
testMailAccess: async (body: {
id?: number;
apiUrl?: string;
domain?: string;
systemEmailAddress?: string;
systemEmailPassword?: string;
imapEncryption?: 'SSL' | 'STARTTLS' | 'NONE';
smtpEncryption?: 'SSL' | 'STARTTLS' | 'NONE';
allowSelfSignedCerts?: boolean;
}) => {
const res = await api.post<ApiResponse<{
imap: { success: boolean; error?: string; server: string; port: number; encryption: string };
smtp: { success: boolean; error?: string; server: string; port: number; encryption: string };
user: string;
}>>('/email-providers/test-mail-access', body);
return res.data;
},
getDomain: async () => {
const res = await api.get<ApiResponse<{ domain: string | null }>>('/email-providers/domain');
return res.data;
},
getPublicSettings: async () => {
const res = await api.get<ApiResponse<{
domain: string | null;
customerEmailLabel: string;
customerEmailLabelIsCustom: boolean;
}>>('/email-providers/public-settings');
return res.data;
},
checkEmailExists: async (localPart: string) => {
const res = await api.get<ApiResponse<{ exists: boolean; email?: string }>>(`/email-providers/check/${localPart}`);
return res.data;
@@ -1426,76 +1384,6 @@ export interface EmailLog {
sentAt: string;
}
// ==================== MONITORING ====================
export type SecurityEventType =
| 'LOGIN_FAILED' | 'LOGIN_SUCCESS' | 'RATE_LIMIT_HIT' | 'ACCESS_DENIED'
| 'SSRF_BLOCKED' | 'PASSWORD_RESET_REQUEST' | 'PASSWORD_RESET_CONFIRM'
| 'LOGOUT' | 'TOKEN_REJECTED' | 'PERMISSION_CHANGED' | 'SUSPICIOUS';
export type SecuritySeverity = 'INFO' | 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
export interface SecurityEvent {
id: number;
type: SecurityEventType;
severity: SecuritySeverity;
message: string;
ipAddress: string | null;
userId: number | null;
customerId: number | null;
userEmail: string | null;
endpoint: string | null;
details: Record<string, unknown> | null;
alerted: boolean;
alertedAt: string | null;
createdAt: string;
}
export interface MonitoringSettings {
alertEmail: string;
digestEnabled: boolean;
lastDigestAt: string | null;
}
export const monitoringApi = {
getEvents: async (params?: { page?: number; limit?: number; type?: SecurityEventType | ''; severity?: SecuritySeverity | ''; search?: string; ip?: string; since?: string }) => {
const q = new URLSearchParams();
if (params?.page) q.set('page', String(params.page));
if (params?.limit) q.set('limit', String(params.limit));
if (params?.type) q.set('type', params.type);
if (params?.severity) q.set('severity', params.severity);
if (params?.search) q.set('search', params.search);
if (params?.ip) q.set('ip', params.ip);
if (params?.since) q.set('since', params.since);
const res = await api.get<ApiResponse<SecurityEvent[]> & {
pagination: { page: number; limit: number; total: number; totalPages: number };
stats: { byType: Record<string, number>; bySeverity: Record<string, number> };
}>(`/monitoring/events?${q}`);
return res.data;
},
getSettings: async () => {
const res = await api.get<ApiResponse<MonitoringSettings>>('/monitoring/settings');
return res.data;
},
updateSettings: async (data: { alertEmail?: string; digestEnabled?: boolean }) => {
const res = await api.put<ApiResponse<void>>('/monitoring/settings', data);
return res.data;
},
testAlert: async () => {
const res = await api.post<ApiResponse<void>>('/monitoring/test-alert');
return res.data;
},
runDigest: async () => {
const res = await api.post<ApiResponse<{ sent: boolean; eventCount: number; reason?: string }>>('/monitoring/run-digest');
return res.data;
},
clearEvents: async (olderThanDays?: number) => {
const q = olderThanDays ? `?olderThanDays=${olderThanDays}` : '';
const res = await api.delete<ApiResponse<{ deletedCount: number }>>(`/monitoring/events${q}`);
return res.data;
},
};
export const emailLogApi = {
getLogs: async (params?: { page?: number; limit?: number; success?: string; search?: string; context?: string }) => {
const query = new URLSearchParams();
@@ -1711,9 +1599,6 @@ export interface MyBirthdayCheck {
isToday: boolean;
daysAgo: number;
firstName: string;
lastName: string;
salutation: string | null;
useInformalAddress: boolean;
age: number;
}
@@ -1732,17 +1617,6 @@ export const birthdayApi = {
const res = await api.post<ApiResponse<void>>('/birthdays/my-birthday/acknowledge');
return res.data;
},
resetGreeting: async (customerId: number) => {
const res = await api.post<ApiResponse<void>>(`/birthdays/${customerId}/reset`);
return res.data;
},
sendGreeting: async (customerId: number, channel: 'email' | 'whatsapp' | 'telegram' | 'signal') => {
const res = await api.post<ApiResponse<{ channel: string; messageText: string }>>(
`/birthdays/${customerId}/send`,
{ channel },
);
return res.data;
},
};
export default api;
-3
View File
@@ -62,9 +62,6 @@ export interface Customer {
customerNumber: string;
type: 'PRIVATE' | 'BUSINESS';
salutation?: string;
useInformalAddress?: boolean;
autoBirthdayGreeting?: boolean;
autoBirthdayChannel?: string | null;
firstName: string;
lastName: string;
companyName?: string;
-23
View File
@@ -1,23 +0,0 @@
/**
* Baut eine Download-URL für ein im Backend gespeichertes Upload-File.
*
* Geht über `GET /api/files/download?path=...` der Backend-Controller
* macht einen Per-File-Ownership-Check (Pfad Resource canAccessCustomer
* / canAccessContract). Damit kann auch ein eingeloggter User keine
* fremden Dateien abrufen, selbst wenn er den Pfad kennen würde.
*
* <a href> und window.open senden keinen Authorization-Header, daher
* Token als Query-Parameter (auth-Middleware akzeptiert `?token=<jwt>`).
*
* Trade-off: Tokens in URLs können in Logs/Referrer landen. Eine
* sauberere Lösung mit kurzlebigen Download-Tokens (signierte URLs)
* wäre v1.1-Item.
*/
export function fileUrl(path: string | null | undefined): string {
if (!path) return '';
const token = localStorage.getItem('token');
const normalizedPath = path.startsWith('/') ? path : '/' + path;
const base = `/api/files/download?path=${encodeURIComponent(normalizedPath)}`;
if (!token) return base;
return `${base}&token=${encodeURIComponent(token)}`;
}