Compare commits
43 Commits
v1.1.0
..
ea3f3c6d29
| Author | SHA1 | Date | |
|---|---|---|---|
| ea3f3c6d29 | |||
| d1e78b4b8e | |||
| 9e55e25dc8 | |||
| a47dfcd841 | |||
| f17adb6095 | |||
| 50b0e56a84 | |||
| 29eceef26b | |||
| 5e9e553882 | |||
| 5b85bea4eb | |||
| 3dd4f7b656 | |||
| eaa94e766a | |||
| 219e1930f7 | |||
| 4f359df161 | |||
| 0121c82412 | |||
| a9643206bb | |||
| f2876f877e | |||
| 89cf92eaf5 | |||
| dd4d57fa1b | |||
| e348e86c60 | |||
| ee4f1aacdd | |||
| 06489299d5 | |||
| 4442ab08b3 | |||
| efe8ac25cb | |||
| 3a9fcc5ec9 | |||
| d400c90e6a | |||
| aee48a8ccb | |||
| 1ad4fe0819 | |||
| b281801cdb | |||
| af2f444a24 | |||
| 2d052c76d9 | |||
| d98c97a81f | |||
| 06d45734ce | |||
| b968e6b46d | |||
| 312e879221 | |||
| fdef6d1d3b | |||
| 2b23ed64c4 | |||
| 97b4670643 | |||
| 9a014c100b | |||
| 6f3ab288ed | |||
| ee8bd7a8f7 | |||
| e4fdfbc95f | |||
| ff857be01a | |||
| 31f807fbd0 |
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
Web-basiertes CRM-System für Kundenverwaltung mit Verträgen (Energie, Telekommunikation, KFZ-Versicherung).
|
Web-basiertes CRM-System für Kundenverwaltung mit Verträgen (Energie, Telekommunikation, KFZ-Versicherung).
|
||||||
|
|
||||||
**Version: 1.1.0** ([Changelog](#changelog))
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Kundenverwaltung**: Privat- und Geschäftskunden mit Stammdaten
|
- **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
|
- **Zähler**: Strom-/Gaszähler mit Zählerstandhistorie
|
||||||
- **Rechnungen**: Rechnungsverwaltung für Energieverträge mit Dokumenten-Upload
|
- **Rechnungen**: Rechnungsverwaltung für Energieverträge mit Dokumenten-Upload
|
||||||
- **Vertrags-Cockpit**: Dashboard zur Überwachung offener Aufgaben (fehlende Dokumente, Rechnungen)
|
- **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**:
|
- **Verträge**:
|
||||||
- Energie (Strom, Gas)
|
- Energie (Strom, Gas)
|
||||||
- Telekommunikation (DSL, Glasfaser, Mobilfunk, TV)
|
- 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
|
- **Email-Provisionierung**: Automatische E-Mail-Weiterleitung bei Plesk/cPanel/DirectAdmin
|
||||||
- **Berechtigungssystem**: Admin, Mitarbeiter, Nur-Lesen, Kundenportal
|
- **Berechtigungssystem**: Admin, Mitarbeiter, Nur-Lesen, Kundenportal
|
||||||
- **Verschlüsselte Zugangsdaten**: Portal-Passwörter AES-256-GCM verschlüsselt
|
- **Verschlüsselte Zugangsdaten**: Portal-Passwörter AES-256-GCM verschlüsselt
|
||||||
- **DSGVO-Compliance**: Audit-Logging mit Hash-Chain-Integritätsprüfung,
|
- **DSGVO-Compliance**: Audit-Logging, Einwilligungsverwaltung, Datenexport, Löschanfragen
|
||||||
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)
|
|
||||||
- **Developer-Tools**: Datenbank-Browser und interaktives ER-Diagramm
|
- **Developer-Tools**: Datenbank-Browser und interaktives ER-Diagramm
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
@@ -152,39 +140,6 @@ Nach dem Seed sind folgende Zugangsdaten verfügbar:
|
|||||||
- **E-Mail:** admin@admin.com
|
- **E-Mail:** admin@admin.com
|
||||||
- **Passwort:** admin
|
- **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
|
## Developer-Tools aktivieren
|
||||||
|
|
||||||
Die Developer-Tools (Datenbankstruktur, ER-Diagramm) sind standardmäßig für Admins verfügbar. Falls der Menüpunkt nicht erscheint:
|
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")
|
- **Name**: Bezeichnung (z.B. "Plesk Hauptserver")
|
||||||
- **Typ**: Plesk/cPanel/DirectAdmin
|
- **Typ**: Plesk/cPanel/DirectAdmin
|
||||||
- **API-URL**: Server-URL (z.B. `https://server.de:8443`)
|
- **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**: API-Zugangsdaten
|
||||||
- **Benutzername/Passwort**: Nur wenn kein API-Key vorhanden
|
|
||||||
- **Domain**: E-Mail-Domain (z.B. `stressfrei-wechseln.de`)
|
- **Domain**: E-Mail-Domain (z.B. `stressfrei-wechseln.de`)
|
||||||
- **Standard-Weiterleitung**: Zusätzliche Weiterleitungsadresse (optional)
|
- **Standard-Weiterleitung**: Zusätzliche Weiterleitungsadresse (optional)
|
||||||
3. Provider als "Standard" und "Aktiv" markieren
|
3. Provider als "Standard" und "Aktiv" markieren
|
||||||
4. Verbindung testen
|
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
|
### Verwendung
|
||||||
|
|
||||||
Beim Anlegen einer Stressfrei-Wechseln Adresse im Kundenbereich erscheint die Checkbox **"Beim E-Mail-Provider anlegen"**, wenn:
|
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
|
- API-Response wird nicht blockiert
|
||||||
- Before/After-Werte über Prisma Middleware
|
- 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
|
## Lizenz
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -17,11 +17,6 @@ prisma/backups/*
|
|||||||
uploads/*
|
uploads/*
|
||||||
!uploads/.gitkeep
|
!uploads/.gitkeep
|
||||||
|
|
||||||
# Factory Defaults (firmen-spezifische Kataloge, bleiben lokal)
|
|
||||||
factory-defaults/*
|
|
||||||
!factory-defaults/.gitkeep
|
|
||||||
!factory-defaults/README.md
|
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|||||||
@@ -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.
|
|
||||||
Generated
+49
-182
@@ -15,14 +15,11 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.21.1",
|
"express": "^4.21.1",
|
||||||
"express-rate-limit": "^8.4.0",
|
|
||||||
"express-validator": "^7.2.0",
|
"express-validator": "^7.2.0",
|
||||||
"helmet": "^8.1.0",
|
|
||||||
"imapflow": "^1.2.8",
|
"imapflow": "^1.2.8",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"mailparser": "^3.9.3",
|
"mailparser": "^3.9.3",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"node-cron": "^4.2.1",
|
|
||||||
"nodemailer": "^7.0.13",
|
"nodemailer": "^7.0.13",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdfkit": "^0.17.2",
|
"pdfkit": "^0.17.2",
|
||||||
@@ -38,7 +35,6 @@
|
|||||||
"@types/mailparser": "^3.4.6",
|
"@types/mailparser": "^3.4.6",
|
||||||
"@types/multer": "^1.4.12",
|
"@types/multer": "^1.4.12",
|
||||||
"@types/node": "^22.9.0",
|
"@types/node": "^22.9.0",
|
||||||
"@types/node-cron": "^3.0.11",
|
|
||||||
"@types/nodemailer": "^7.0.9",
|
"@types/nodemailer": "^7.0.9",
|
||||||
"@types/pdfkit": "^0.17.4",
|
"@types/pdfkit": "^0.17.4",
|
||||||
"prisma": "^5.22.0",
|
"prisma": "^5.22.0",
|
||||||
@@ -511,8 +507,7 @@
|
|||||||
"node_modules/@pinojs/redact": {
|
"node_modules/@pinojs/redact": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||||
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
|
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="
|
||||||
"license": "MIT"
|
|
||||||
},
|
},
|
||||||
"node_modules/@pkgjs/parseargs": {
|
"node_modules/@pkgjs/parseargs": {
|
||||||
"version": "0.11.0",
|
"version": "0.11.0",
|
||||||
@@ -749,13 +744,6 @@
|
|||||||
"undici-types": "~6.21.0"
|
"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": {
|
"node_modules/@types/nodemailer": {
|
||||||
"version": "7.0.9",
|
"version": "7.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.9.tgz",
|
||||||
@@ -987,7 +975,6 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||||
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
|
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8.0.0"
|
"node": ">=8.0.0"
|
||||||
}
|
}
|
||||||
@@ -1071,10 +1058,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "2.1.0",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0"
|
"balanced-match": "^1.0.0"
|
||||||
}
|
}
|
||||||
@@ -1676,24 +1662,6 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/express-validator": {
|
||||||
"version": "7.3.1",
|
"version": "7.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.1.tgz",
|
||||||
@@ -1914,15 +1882,6 @@
|
|||||||
"he": "bin/he"
|
"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": {
|
"node_modules/html-to-text": {
|
||||||
"version": "9.0.5",
|
"version": "9.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
|
||||||
@@ -2006,31 +1965,19 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/imapflow": {
|
"node_modules/imapflow": {
|
||||||
"version": "1.3.3",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.2.8.tgz",
|
||||||
"integrity": "sha512-lx7nWcUDfNgITEKYYfunUDqJ3LT6ImuiA1ReqJepVEA2nqBQNUqa3ppF7Yz5CNjuDYG95pmzsCcNqRjMrwh/Vg==",
|
"integrity": "sha512-ym7FF2tKOlOzfRvxehs4eLkhjP8Mme3sSp2tcxEbyoeJuJwtEWxaVDv12+DnaMG2LXm0zuQGWZiClq31FLPUNg==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@zone-eu/mailsplit": "5.4.9",
|
"@zone-eu/mailsplit": "5.4.8",
|
||||||
"encoding-japanese": "2.2.0",
|
"encoding-japanese": "2.2.0",
|
||||||
"iconv-lite": "0.7.2",
|
"iconv-lite": "0.7.2",
|
||||||
"libbase64": "1.3.0",
|
"libbase64": "1.3.0",
|
||||||
"libmime": "5.3.8",
|
"libmime": "5.3.7",
|
||||||
"libqp": "2.1.1",
|
"libqp": "2.1.1",
|
||||||
"nodemailer": "8.0.7",
|
"nodemailer": "7.0.13",
|
||||||
"pino": "10.3.1",
|
"pino": "10.3.0",
|
||||||
"socks": "2.8.8"
|
"socks": "2.8.7"
|
||||||
}
|
|
||||||
},
|
|
||||||
"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"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/imapflow/node_modules/iconv-lite": {
|
"node_modules/imapflow/node_modules/iconv-lite": {
|
||||||
@@ -2048,27 +1995,6 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/inherits": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
@@ -2261,10 +2187,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lodash": {
|
"node_modules/lodash": {
|
||||||
"version": "4.18.1",
|
"version": "4.17.21",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
||||||
"license": "MIT"
|
|
||||||
},
|
},
|
||||||
"node_modules/lodash.includes": {
|
"node_modules/lodash.includes": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
@@ -2307,19 +2232,18 @@
|
|||||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
|
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
|
||||||
},
|
},
|
||||||
"node_modules/mailparser": {
|
"node_modules/mailparser": {
|
||||||
"version": "3.9.8",
|
"version": "3.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.8.tgz",
|
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.3.tgz",
|
||||||
"integrity": "sha512-7jSlFGXiianVnhnb6wdutJFloD34488nrHY7r6FNqwXAhZ7YiJDYrKKTxZJ0oSrXcAPHm8YoYnh97xyGtrBQ3w==",
|
"integrity": "sha512-AnB0a3zROum6fLaa52L+/K2SoRJVyFDk78Ea6q1D0ofcZLxWEWDtsS1+OrVqKbV7r5dulKL/AwYQccFGAPpuYQ==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@zone-eu/mailsplit": "5.4.8",
|
"@zone-eu/mailsplit": "5.4.8",
|
||||||
"encoding-japanese": "2.2.0",
|
"encoding-japanese": "2.2.0",
|
||||||
"he": "1.2.0",
|
"he": "1.2.0",
|
||||||
"html-to-text": "9.0.5",
|
"html-to-text": "9.0.5",
|
||||||
"iconv-lite": "0.7.2",
|
"iconv-lite": "0.7.2",
|
||||||
"libmime": "5.3.8",
|
"libmime": "5.3.7",
|
||||||
"linkify-it": "5.0.0",
|
"linkify-it": "5.0.0",
|
||||||
"nodemailer": "8.0.5",
|
"nodemailer": "7.0.13",
|
||||||
"punycode.js": "2.3.1",
|
"punycode.js": "2.3.1",
|
||||||
"tlds": "1.261.0"
|
"tlds": "1.261.0"
|
||||||
}
|
}
|
||||||
@@ -2339,27 +2263,6 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -2423,12 +2326,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "9.0.9",
|
"version": "9.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^2.0.2"
|
"brace-expansion": "^2.0.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16 || 14 >=14.17"
|
"node": ">=16 || 14 >=14.17"
|
||||||
@@ -2495,15 +2397,6 @@
|
|||||||
"node": ">= 0.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": {
|
"node_modules/nodemailer": {
|
||||||
"version": "7.0.13",
|
"version": "7.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
|
||||||
@@ -2543,7 +2436,6 @@
|
|||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
||||||
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
|
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
@@ -2613,10 +2505,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/path-to-regexp": {
|
"node_modules/path-to-regexp": {
|
||||||
"version": "0.1.13",
|
"version": "0.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="
|
||||||
"license": "MIT"
|
|
||||||
},
|
},
|
||||||
"node_modules/pdf-lib": {
|
"node_modules/pdf-lib": {
|
||||||
"version": "1.17.1",
|
"version": "1.17.1",
|
||||||
@@ -2663,10 +2554,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pino": {
|
"node_modules/pino": {
|
||||||
"version": "10.3.1",
|
"version": "10.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.0.tgz",
|
||||||
"integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
|
"integrity": "sha512-0GNPNzHXBKw6U/InGe79A3Crzyk9bcSyObF9/Gfo9DLEf5qj5RF50RSjsu0W1rZ6ZqRGdzDFCRBQvi9/rSGPtA==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@pinojs/redact": "^0.4.0",
|
"@pinojs/redact": "^0.4.0",
|
||||||
"atomic-sleep": "^1.0.0",
|
"atomic-sleep": "^1.0.0",
|
||||||
@@ -2688,7 +2578,6 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
|
"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==",
|
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"split2": "^4.0.0"
|
"split2": "^4.0.0"
|
||||||
}
|
}
|
||||||
@@ -2696,8 +2585,7 @@
|
|||||||
"node_modules/pino-std-serializers": {
|
"node_modules/pino-std-serializers": {
|
||||||
"version": "7.1.0",
|
"version": "7.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
|
||||||
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="
|
||||||
"license": "MIT"
|
|
||||||
},
|
},
|
||||||
"node_modules/png-js": {
|
"node_modules/png-js": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
@@ -2749,8 +2637,7 @@
|
|||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
"url": "https://opencollective.com/fastify"
|
"url": "https://opencollective.com/fastify"
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"license": "MIT"
|
|
||||||
},
|
},
|
||||||
"node_modules/proxy-addr": {
|
"node_modules/proxy-addr": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
@@ -2773,10 +2660,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.14.2",
|
"version": "6.14.1",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
||||||
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
|
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"side-channel": "^1.1.0"
|
"side-channel": "^1.1.0"
|
||||||
},
|
},
|
||||||
@@ -2790,8 +2676,7 @@
|
|||||||
"node_modules/quick-format-unescaped": {
|
"node_modules/quick-format-unescaped": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
||||||
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="
|
||||||
"license": "MIT"
|
|
||||||
},
|
},
|
||||||
"node_modules/range-parser": {
|
"node_modules/range-parser": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
@@ -2843,10 +2728,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/readdir-glob/node_modules/minimatch": {
|
"node_modules/readdir-glob/node_modules/minimatch": {
|
||||||
"version": "5.1.9",
|
"version": "5.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
|
||||||
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
|
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^2.0.1"
|
"brace-expansion": "^2.0.1"
|
||||||
},
|
},
|
||||||
@@ -2858,7 +2742,6 @@
|
|||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
|
||||||
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
|
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.13.0"
|
"node": ">= 12.13.0"
|
||||||
}
|
}
|
||||||
@@ -2900,7 +2783,6 @@
|
|||||||
"version": "2.5.0",
|
"version": "2.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
||||||
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
|
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
@@ -3081,19 +2963,17 @@
|
|||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
|
||||||
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
|
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 6.0.0",
|
"node": ">= 6.0.0",
|
||||||
"npm": ">= 3.0.0"
|
"npm": ">= 3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/socks": {
|
"node_modules/socks": {
|
||||||
"version": "2.8.8",
|
"version": "2.8.7",
|
||||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.8.tgz",
|
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
|
||||||
"integrity": "sha512-NlGELfPrgX2f1TAAcz0WawlLn+0r3FyhhCRpFFK2CemXenPYvzMWWZINv3eDNo9ucdwme7oCHRY0Jnbs4aIkog==",
|
"integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ip-address": "^10.1.1",
|
"ip-address": "^10.0.1",
|
||||||
"smart-buffer": "^4.2.0"
|
"smart-buffer": "^4.2.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3101,20 +2981,10 @@
|
|||||||
"npm": ">= 3.0.0"
|
"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": {
|
"node_modules/sonic-boom": {
|
||||||
"version": "4.2.1",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz",
|
||||||
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
|
"integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"atomic-sleep": "^1.0.0"
|
"atomic-sleep": "^1.0.0"
|
||||||
}
|
}
|
||||||
@@ -3123,7 +2993,6 @@
|
|||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.x"
|
"node": ">= 10.x"
|
||||||
}
|
}
|
||||||
@@ -3277,7 +3146,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
|
||||||
"integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==",
|
"integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"real-require": "^0.2.0"
|
"real-require": "^0.2.0"
|
||||||
},
|
},
|
||||||
@@ -3366,10 +3234,9 @@
|
|||||||
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="
|
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="
|
||||||
},
|
},
|
||||||
"node_modules/undici": {
|
"node_modules/undici": {
|
||||||
"version": "6.25.0",
|
"version": "6.23.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
|
||||||
"integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==",
|
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.17"
|
"node": ">=18.17"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "opencrm-backend",
|
"name": "opencrm-backend",
|
||||||
"version": "1.1.0",
|
"version": "1.0.0",
|
||||||
"description": "OpenCRM Backend API",
|
"description": "OpenCRM Backend API",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"prisma": {
|
"prisma": {
|
||||||
@@ -15,8 +15,7 @@
|
|||||||
"db:seed": "tsx prisma/seed.ts",
|
"db:seed": "tsx prisma/seed.ts",
|
||||||
"db:studio": "prisma studio",
|
"db:studio": "prisma studio",
|
||||||
"db:backup": "tsx prisma/backup-data.ts",
|
"db:backup": "tsx prisma/backup-data.ts",
|
||||||
"db:restore": "tsx prisma/restore-data.ts",
|
"db:restore": "tsx prisma/restore-data.ts"
|
||||||
"seed:defaults": "tsx scripts/seed-factory-defaults.ts"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
@@ -26,14 +25,11 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.21.1",
|
"express": "^4.21.1",
|
||||||
"express-rate-limit": "^8.4.0",
|
|
||||||
"express-validator": "^7.2.0",
|
"express-validator": "^7.2.0",
|
||||||
"helmet": "^8.1.0",
|
|
||||||
"imapflow": "^1.2.8",
|
"imapflow": "^1.2.8",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"mailparser": "^3.9.3",
|
"mailparser": "^3.9.3",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"node-cron": "^4.2.1",
|
|
||||||
"nodemailer": "^7.0.13",
|
"nodemailer": "^7.0.13",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdfkit": "^0.17.2",
|
"pdfkit": "^0.17.2",
|
||||||
@@ -49,7 +45,6 @@
|
|||||||
"@types/mailparser": "^3.4.6",
|
"@types/mailparser": "^3.4.6",
|
||||||
"@types/multer": "^1.4.12",
|
"@types/multer": "^1.4.12",
|
||||||
"@types/node": "^22.9.0",
|
"@types/node": "^22.9.0",
|
||||||
"@types/node-cron": "^3.0.11",
|
|
||||||
"@types/nodemailer": "^7.0.9",
|
"@types/nodemailer": "^7.0.9",
|
||||||
"@types/pdfkit": "^0.17.4",
|
"@types/pdfkit": "^0.17.4",
|
||||||
"prisma": "^5.22.0",
|
"prisma": "^5.22.0",
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Datenbank-Backup Script
|
* 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:
|
* 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.
|
* Erstellt einen Ordner 'backups/YYYY-MM-DD_HH-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.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
@@ -31,7 +28,7 @@ async function main() {
|
|||||||
|
|
||||||
// Tabellen in Abhängigkeitsreihenfolge (unabhängige zuerst)
|
// Tabellen in Abhängigkeitsreihenfolge (unabhängige zuerst)
|
||||||
const tables = [
|
const tables = [
|
||||||
// ============ Level 0: Reine Stammdaten/Kataloge ============
|
// Level 0: Keine Abhängigkeiten
|
||||||
{ name: 'Permission', query: () => prisma.permission.findMany() },
|
{ name: 'Permission', query: () => prisma.permission.findMany() },
|
||||||
{ name: 'Role', query: () => prisma.role.findMany() },
|
{ name: 'Role', query: () => prisma.role.findMany() },
|
||||||
{ name: 'SalesPlatform', query: () => prisma.salesPlatform.findMany() },
|
{ name: 'SalesPlatform', query: () => prisma.salesPlatform.findMany() },
|
||||||
@@ -40,58 +37,40 @@ async function main() {
|
|||||||
{ name: 'ContractDuration', query: () => prisma.contractDuration.findMany() },
|
{ name: 'ContractDuration', query: () => prisma.contractDuration.findMany() },
|
||||||
{ name: 'AppSetting', query: () => prisma.appSetting.findMany() },
|
{ name: 'AppSetting', query: () => prisma.appSetting.findMany() },
|
||||||
{ name: 'EmailProviderConfig', query: () => prisma.emailProviderConfig.findMany() },
|
{ name: 'EmailProviderConfig', query: () => prisma.emailProviderConfig.findMany() },
|
||||||
{ name: 'Provider', query: () => prisma.provider.findMany() },
|
{ name: 'EnergyProvider', query: () => prisma.energyProvider.findMany() },
|
||||||
{ name: 'PdfTemplate', query: () => prisma.pdfTemplate.findMany() },
|
{ name: 'TelecomProvider', query: () => prisma.telecomProvider.findMany() },
|
||||||
{ name: 'AuditRetentionPolicy', query: () => prisma.auditRetentionPolicy.findMany() },
|
|
||||||
|
|
||||||
// ============ Level 1: Abhängig von Level 0 ============
|
// Level 1: Abhängig von Level 0
|
||||||
{ name: 'RolePermission', query: () => prisma.rolePermission.findMany() },
|
{ name: 'RolePermission', query: () => prisma.rolePermission.findMany() },
|
||||||
{ name: 'User', query: () => prisma.user.findMany() },
|
{ name: 'User', query: () => prisma.user.findMany() },
|
||||||
{ name: 'Customer', query: () => prisma.customer.findMany() },
|
{ name: 'Customer', query: () => prisma.customer.findMany() },
|
||||||
{ name: 'Tariff', query: () => prisma.tariff.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: '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: 'CustomerRepresentative', query: () => prisma.customerRepresentative.findMany() },
|
||||||
{ name: 'CustomerConsent', query: () => prisma.customerConsent.findMany() },
|
{ name: 'StressfreiEmail', query: () => prisma.stressfreiEmail.findMany() },
|
||||||
{ name: 'DataDeletionRequest', query: () => prisma.dataDeletionRequest.findMany() },
|
|
||||||
|
|
||||||
// ============ Level 3: Contracts + abhängige ============
|
|
||||||
{ name: 'Contract', query: () => prisma.contract.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 ============
|
// Level 3: Abhängig von Level 2
|
||||||
{ 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) ============
|
|
||||||
{ name: 'CachedEmail', query: () => prisma.cachedEmail.findMany() },
|
{ name: 'CachedEmail', query: () => prisma.cachedEmail.findMany() },
|
||||||
{ name: 'EmailLog', query: () => prisma.emailLog.findMany() },
|
{ name: 'ContractTask', query: () => prisma.contractTask.findMany() },
|
||||||
{ name: 'AuditLog', query: () => prisma.auditLog.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;
|
let totalRecords = 0;
|
||||||
const stats: { table: string; count: number }[] = [];
|
const stats: { table: string; count: number }[] = [];
|
||||||
const skipped: string[] = [];
|
|
||||||
|
|
||||||
for (const table of tables) {
|
for (const table of tables) {
|
||||||
try {
|
try {
|
||||||
@@ -100,7 +79,7 @@ async function main() {
|
|||||||
totalRecords += count;
|
totalRecords += count;
|
||||||
stats.push({ table: table.name, 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`);
|
const filePath = path.join(backupDir, `${table.name}.json`);
|
||||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
||||||
|
|
||||||
@@ -108,26 +87,20 @@ async function main() {
|
|||||||
console.log(`${status} ${table.name}: ${count} Einträge`);
|
console.log(`${status} ${table.name}: ${count} Einträge`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Tabelle existiert möglicherweise nicht (bei älteren Schema-Versionen)
|
// Tabelle existiert möglicherweise nicht (bei älteren Schema-Versionen)
|
||||||
skipped.push(table.name);
|
console.log(`⚠️ ${table.name}: Übersprungen (${error.message?.slice(0, 50)}...)`);
|
||||||
console.log(`⚠️ ${table.name}: Übersprungen (${error.message?.slice(0, 80)}...)`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backup-Info speichern
|
// Backup-Info speichern
|
||||||
const backupInfo = {
|
const backupInfo = {
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
schemaVersion: 'current',
|
|
||||||
totalRecords,
|
totalRecords,
|
||||||
tables: stats,
|
tables: stats,
|
||||||
skippedTables: skipped,
|
|
||||||
};
|
};
|
||||||
fs.writeFileSync(path.join(backupDir, '_backup-info.json'), JSON.stringify(backupInfo, null, 2));
|
fs.writeFileSync(path.join(backupDir, '_backup-info.json'), JSON.stringify(backupInfo, null, 2));
|
||||||
|
|
||||||
console.log(`\n✅ Backup abgeschlossen!`);
|
console.log(`\n✅ Backup abgeschlossen!`);
|
||||||
console.log(` 📊 ${totalRecords} Datensätze in ${stats.filter(s => s.count > 0).length} Tabellen`);
|
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`);
|
console.log(` 📁 Gespeichert in: ${backupDir}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+373
-144
@@ -4,40 +4,43 @@
|
|||||||
* Stellt Daten aus einem JSON-Backup wieder her.
|
* Stellt Daten aus einem JSON-Backup wieder her.
|
||||||
*
|
*
|
||||||
* Verwendung:
|
* Verwendung:
|
||||||
* npm run db:restore # Letztes Backup
|
* npx ts-node prisma/restore-data.ts [backup-ordner]
|
||||||
* npx tsx prisma/restore-data.ts <ordner> # Bestimmtes Backup
|
|
||||||
*
|
*
|
||||||
* WICHTIG: Führe vorher 'npx prisma db push' oder 'npx prisma migrate deploy' aus,
|
* Beispiele:
|
||||||
* damit das Schema zur DB passt!
|
* 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 fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
// Hilfsfunktion: JSON-Datei lesen (leer bei fehlender Datei)
|
// Hilfsfunktion: JSON-Datei lesen
|
||||||
function readJsonFile<T>(filePath: string): T[] {
|
function readJsonFile<T>(filePath: string): T[] {
|
||||||
if (!fs.existsSync(filePath)) return [];
|
if (!fs.existsSync(filePath)) {
|
||||||
try {
|
|
||||||
const content = fs.readFileSync(filePath, 'utf-8');
|
|
||||||
return JSON.parse(content);
|
|
||||||
} catch {
|
|
||||||
return [];
|
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 {
|
function convertDates(obj: any): any {
|
||||||
if (obj === null || obj === undefined) return obj;
|
if (obj === null || obj === undefined) return obj;
|
||||||
if (typeof obj === 'string') {
|
if (typeof obj === 'string') {
|
||||||
|
// ISO-Datumsformat erkennen
|
||||||
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(obj)) {
|
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(obj)) {
|
||||||
return new Date(obj);
|
return new Date(obj);
|
||||||
}
|
}
|
||||||
return obj;
|
return obj;
|
||||||
}
|
}
|
||||||
if (Array.isArray(obj)) return obj.map(convertDates);
|
if (Array.isArray(obj)) {
|
||||||
|
return obj.map(convertDates);
|
||||||
|
}
|
||||||
if (typeof obj === 'object') {
|
if (typeof obj === 'object') {
|
||||||
const result: any = {};
|
const result: any = {};
|
||||||
for (const [key, value] of Object.entries(obj)) {
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
@@ -48,75 +51,19 @@ function convertDates(obj: any): any {
|
|||||||
return obj;
|
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() {
|
async function main() {
|
||||||
|
// Backup-Ordner bestimmen
|
||||||
const backupsDir = path.join(__dirname, 'backups');
|
const backupsDir = path.join(__dirname, 'backups');
|
||||||
let backupName = process.argv[2];
|
let backupName = process.argv[2];
|
||||||
|
|
||||||
if (!backupName) {
|
if (!backupName) {
|
||||||
|
// Neuestes Backup finden
|
||||||
if (!fs.existsSync(backupsDir)) {
|
if (!fs.existsSync(backupsDir)) {
|
||||||
console.error('❌ Kein Backup-Ordner gefunden!');
|
console.error('❌ Kein Backup-Ordner gefunden!');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
const backups = fs.readdirSync(backupsDir)
|
const backups = fs.readdirSync(backupsDir)
|
||||||
.filter((f) => fs.statSync(path.join(backupsDir, f)).isDirectory())
|
.filter(f => fs.statSync(path.join(backupsDir, f)).isDirectory())
|
||||||
.sort()
|
.sort()
|
||||||
.reverse();
|
.reverse();
|
||||||
|
|
||||||
@@ -129,16 +76,18 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const backupDir = path.join(backupsDir, backupName);
|
const backupDir = path.join(backupsDir, backupName);
|
||||||
|
|
||||||
if (!fs.existsSync(backupDir)) {
|
if (!fs.existsSync(backupDir)) {
|
||||||
console.error(`❌ Backup-Ordner nicht gefunden: ${backupDir}`);
|
console.error(`❌ Backup-Ordner nicht gefunden: ${backupDir}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backup-Info lesen
|
||||||
const infoPath = path.join(backupDir, '_backup-info.json');
|
const infoPath = path.join(backupDir, '_backup-info.json');
|
||||||
if (fs.existsSync(infoPath)) {
|
if (fs.existsSync(infoPath)) {
|
||||||
const info = JSON.parse(fs.readFileSync(infoPath, 'utf-8'));
|
const info = JSON.parse(fs.readFileSync(infoPath, 'utf-8'));
|
||||||
console.log(`\n📅 Backup vom: ${new Date(info.timestamp).toLocaleString('de-DE')}`);
|
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`);
|
console.log(`🔄 Starte Wiederherstellung aus: ${backupDir}\n`);
|
||||||
@@ -147,76 +96,362 @@ async function main() {
|
|||||||
await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS = 0');
|
await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS = 0');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Tabellen in Abhängigkeitsreihenfolge (gleich wie im Backup)
|
// Tabellen in Abhängigkeitsreihenfolge wiederherstellen
|
||||||
const order: Array<{
|
const restoreOrder = [
|
||||||
name: string;
|
// Level 0: Keine Abhängigkeiten
|
||||||
model: any;
|
{
|
||||||
compositeKey?: string[];
|
name: 'Permission',
|
||||||
}> = [
|
restore: async (data: any[]) => {
|
||||||
// Level 0
|
for (const item of data) {
|
||||||
{ name: 'Permission', model: prisma.permission },
|
await prisma.permission.upsert({
|
||||||
{ name: 'Role', model: prisma.role },
|
where: { id: item.id },
|
||||||
{ name: 'SalesPlatform', model: prisma.salesPlatform },
|
update: convertDates(item),
|
||||||
{ name: 'ContractCategory', model: prisma.contractCategory },
|
create: convertDates(item),
|
||||||
{ 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: 'Role',
|
||||||
{ name: 'AuditRetentionPolicy', model: prisma.auditRetentionPolicy },
|
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
|
// Level 1
|
||||||
{ name: 'RolePermission', model: prisma.rolePermission, compositeKey: ['roleId', 'permissionId'] },
|
{
|
||||||
{ name: 'User', model: prisma.user },
|
name: 'RolePermission',
|
||||||
{ name: 'Customer', model: prisma.customer },
|
restore: async (data: any[]) => {
|
||||||
{ name: 'Tariff', model: prisma.tariff },
|
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
|
// Level 2
|
||||||
{ name: 'UserRole', model: prisma.userRole, compositeKey: ['userId', 'roleId'] },
|
{
|
||||||
{ name: 'Address', model: prisma.address },
|
name: 'UserRole',
|
||||||
{ name: 'BankCard', model: prisma.bankCard },
|
restore: async (data: any[]) => {
|
||||||
{ name: 'IdentityDocument', model: prisma.identityDocument },
|
for (const item of data) {
|
||||||
{ name: 'Meter', model: prisma.meter },
|
await prisma.userRole.upsert({
|
||||||
{ name: 'StressfreiEmail', model: prisma.stressfreiEmail },
|
where: { userId_roleId: { userId: item.userId, roleId: item.roleId } },
|
||||||
{ name: 'CustomerRepresentative', model: prisma.customerRepresentative },
|
update: {},
|
||||||
{ name: 'CustomerConsent', model: prisma.customerConsent },
|
create: convertDates(item),
|
||||||
{ name: 'DataDeletionRequest', model: prisma.dataDeletionRequest },
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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
|
// Level 3
|
||||||
{ name: 'Contract', model: prisma.contract },
|
{
|
||||||
{ name: 'RepresentativeAuthorization', model: prisma.representativeAuthorization },
|
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
|
// Level 4
|
||||||
{ name: 'EnergyContractDetails', model: prisma.energyContractDetails },
|
{
|
||||||
{ name: 'InternetContractDetails', model: prisma.internetContractDetails },
|
name: 'ContractTaskSubtask',
|
||||||
{ name: 'MobileContractDetails', model: prisma.mobileContractDetails },
|
restore: async (data: any[]) => {
|
||||||
{ name: 'TvContractDetails', model: prisma.tvContractDetails },
|
for (const item of data) {
|
||||||
{ name: 'CarInsuranceDetails', model: prisma.carInsuranceDetails },
|
await prisma.contractTaskSubtask.upsert({
|
||||||
{ name: 'ContractMeter', model: prisma.contractMeter },
|
where: { id: item.id },
|
||||||
{ name: 'ContractDocument', model: prisma.contractDocument },
|
update: convertDates(item),
|
||||||
{ name: 'ContractHistoryEntry', model: prisma.contractHistoryEntry },
|
create: convertDates(item),
|
||||||
{ name: 'ContractTask', model: prisma.contractTask },
|
});
|
||||||
{ name: 'Invoice', model: prisma.invoice },
|
}
|
||||||
{ name: 'MeterReading', model: prisma.meterReading },
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// Level 5: Sub-Tabellen
|
// Vertragsdetails
|
||||||
{ name: 'ContractTaskSubtask', model: prisma.contractTaskSubtask },
|
{
|
||||||
{ name: 'PhoneNumber', model: prisma.phoneNumber },
|
name: 'EnergyContractDetails',
|
||||||
{ name: 'SimCard', model: prisma.simCard },
|
restore: async (data: any[]) => {
|
||||||
|
for (const item of data) {
|
||||||
// Level 6: Logs & Emails
|
await prisma.energyContractDetails.upsert({
|
||||||
{ name: 'CachedEmail', model: prisma.cachedEmail },
|
where: { id: item.id },
|
||||||
{ name: 'EmailLog', model: prisma.emailLog },
|
update: convertDates(item),
|
||||||
{ name: 'AuditLog', model: prisma.auditLog },
|
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;
|
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 filePath = path.join(backupDir, `${table.name}.json`);
|
||||||
const data = readJsonFile<any>(filePath);
|
const data = readJsonFile(filePath);
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
console.log(`⚪ ${table.name}: Keine Daten`);
|
console.log(`⚪ ${table.name}: Keine Daten`);
|
||||||
@@ -224,23 +459,17 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const count = await restoreTable(table.name, data, table.model, {
|
await table.restore(data);
|
||||||
compositeKey: table.compositeKey,
|
totalRestored += data.length;
|
||||||
});
|
console.log(`✅ ${table.name}: ${data.length} Einträge wiederhergestellt`);
|
||||||
totalRestored += count;
|
|
||||||
console.log(`✅ ${table.name}: ${count}/${data.length} Einträge wiederhergestellt`);
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
skipped.push(table.name);
|
console.log(`⚠️ ${table.name}: Fehler - ${error.message?.slice(0, 80)}`);
|
||||||
console.log(`⚠️ ${table.name}: Fehler - ${error.message?.slice(0, 100)}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`\n✅ Wiederherstellung abgeschlossen!`);
|
console.log(`\n✅ Wiederherstellung abgeschlossen!`);
|
||||||
console.log(` 📊 ${totalRestored} Datensätze wiederhergestellt`);
|
console.log(` 📊 ${totalRestored} Datensätze wiederhergestellt\n`);
|
||||||
if (skipped.length > 0) {
|
|
||||||
console.log(` ⚠️ ${skipped.length} Tabellen mit Fehlern: ${skipped.join(', ')}`);
|
|
||||||
}
|
|
||||||
console.log('');
|
|
||||||
} finally {
|
} finally {
|
||||||
// Foreign Key Checks wieder aktivieren
|
// Foreign Key Checks wieder aktivieren
|
||||||
await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS = 1');
|
await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS = 1');
|
||||||
|
|||||||
@@ -78,10 +78,6 @@ model User {
|
|||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
tokenInvalidatedAt DateTime? // Zeitpunkt ab dem alle Tokens ungültig sind (für Zwangslogout bei Rechteänderung)
|
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)
|
// Messaging-Kanäle (für Datenschutz-Link-Versand)
|
||||||
whatsappNumber String?
|
whatsappNumber String?
|
||||||
telegramUsername String?
|
telegramUsername String?
|
||||||
@@ -167,22 +163,9 @@ model Customer {
|
|||||||
portalPasswordEncrypted String? // Verschlüsseltes Passwort (für Anzeige)
|
portalPasswordEncrypted String? // Verschlüsseltes Passwort (für Anzeige)
|
||||||
portalLastLogin DateTime? // Letzte Anmeldung
|
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)
|
// Geburtstagsmodal: Jahr in dem dem Kunden der Geburtstagsgruß gezeigt wurde (vermeidet mehrfaches Anzeigen)
|
||||||
lastBirthdayGreetingYear Int?
|
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?
|
user User?
|
||||||
addresses Address[]
|
addresses Address[]
|
||||||
bankCards BankCard[]
|
bankCards BankCard[]
|
||||||
@@ -368,10 +351,6 @@ model EmailProviderConfig {
|
|||||||
systemEmailAddress String? // z.B. "info@stressfrei-wechseln.de"
|
systemEmailAddress String? // z.B. "info@stressfrei-wechseln.de"
|
||||||
systemEmailPasswordEncrypted String? // Passwort (verschlüsselt)
|
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)
|
isActive Boolean @default(true)
|
||||||
isDefault Boolean @default(false) // Standard-Provider
|
isDefault Boolean @default(false) // Standard-Provider
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -1113,53 +1092,3 @@ model AuditRetentionPolicy {
|
|||||||
|
|
||||||
@@unique([resourceType, sensitivity])
|
@@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])
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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());
|
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import * as authService from '../services/auth.service.js';
|
import * as authService from '../services/auth.service.js';
|
||||||
import { AuthRequest, ApiResponse } from '../types/index.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
|
// Mitarbeiter-Login
|
||||||
export async function login(req: Request, res: Response): Promise<void> {
|
export async function login(req: Request, res: Response): Promise<void> {
|
||||||
const { email, password } = req.body || {};
|
|
||||||
const ctx = contextFromRequest(req);
|
|
||||||
try {
|
try {
|
||||||
|
const { email, password } = req.body;
|
||||||
|
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -18,25 +16,8 @@ export async function login(req: Request, res: Response): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await authService.login(email, password);
|
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);
|
res.json({ success: true, data: result } as ApiResponse);
|
||||||
} catch (error) {
|
} 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({
|
res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Anmeldung fehlgeschlagen',
|
error: error instanceof Error ? error.message : 'Anmeldung fehlgeschlagen',
|
||||||
@@ -46,9 +27,9 @@ export async function login(req: Request, res: Response): Promise<void> {
|
|||||||
|
|
||||||
// Kundenportal-Login
|
// Kundenportal-Login
|
||||||
export async function customerLogin(req: Request, res: Response): Promise<void> {
|
export async function customerLogin(req: Request, res: Response): Promise<void> {
|
||||||
const { email, password } = req.body || {};
|
|
||||||
const ctx = contextFromRequest(req);
|
|
||||||
try {
|
try {
|
||||||
|
const { email, password } = req.body;
|
||||||
|
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -58,25 +39,8 @@ export async function customerLogin(req: Request, res: Response): Promise<void>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await authService.customerLogin(email, password);
|
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);
|
res.json({ success: true, data: result } as ApiResponse);
|
||||||
} catch (error) {
|
} 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({
|
res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Anmeldung fehlgeschlagen',
|
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> {
|
export async function register(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { email, password, firstName, lastName, roleIds } = req.body;
|
const { email, password, firstName, lastName, roleIds } = req.body;
|
||||||
|
|||||||
@@ -1,14 +1,5 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import * as backupService from '../services/backup.service.js';
|
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';
|
import { logChange } from '../services/audit.service.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,8 +45,8 @@ export async function restoreBackup(req: Request, res: Response) {
|
|||||||
try {
|
try {
|
||||||
const { name } = req.params;
|
const { name } = req.params;
|
||||||
|
|
||||||
if (!name || !isValidBackupName(name)) {
|
if (!name) {
|
||||||
return res.status(400).json({ error: 'Ungültiger Backup-Name' });
|
return res.status(400).json({ error: 'Backup-Name erforderlich' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await backupService.restoreBackup(name);
|
const result = await backupService.restoreBackup(name);
|
||||||
@@ -88,8 +79,8 @@ export async function deleteBackup(req: Request, res: Response) {
|
|||||||
try {
|
try {
|
||||||
const { name } = req.params;
|
const { name } = req.params;
|
||||||
|
|
||||||
if (!name || !isValidBackupName(name)) {
|
if (!name) {
|
||||||
return res.status(400).json({ error: 'Ungültiger Backup-Name' });
|
return res.status(400).json({ error: 'Backup-Name erforderlich' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await backupService.deleteBackup(name);
|
const result = await backupService.deleteBackup(name);
|
||||||
@@ -116,8 +107,8 @@ export async function downloadBackup(req: Request, res: Response) {
|
|||||||
try {
|
try {
|
||||||
const { name } = req.params;
|
const { name } = req.params;
|
||||||
|
|
||||||
if (!name || !isValidBackupName(name)) {
|
if (!name) {
|
||||||
return res.status(400).json({ error: 'Ungültiger Backup-Name' });
|
return res.status(400).json({ error: 'Backup-Name erforderlich' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await backupService.createBackupZip(name);
|
const result = await backupService.createBackupZip(name);
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { AuthRequest } from '../types/index.js';
|
import { AuthRequest } from '../types/index.js';
|
||||||
import * as birthdayService from '../services/birthday.service.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
|
* 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' });
|
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',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,25 +11,17 @@ import { decrypt } from '../utils/encryption.js';
|
|||||||
import { ApiResponse } from '../types/index.js';
|
import { ApiResponse } from '../types/index.js';
|
||||||
import { getCustomerTargets, getContractTargets, getIdentityDocumentTargets, getBankCardTargets, documentTargets } from '../config/documentTargets.config.js';
|
import { getCustomerTargets, getContractTargets, getIdentityDocumentTargets, getBankCardTargets, documentTargets } from '../config/documentTargets.config.js';
|
||||||
import { generateEmailPdf } from '../services/pdfService.js';
|
import { generateEmailPdf } from '../services/pdfService.js';
|
||||||
import { maybeActivateOnDeliveryConfirmation } from '../services/contractStatusScheduler.service.js';
|
|
||||||
import { DocumentType } from '@prisma/client';
|
import { DocumentType } from '@prisma/client';
|
||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { AuthRequest } from '../types/index.js';
|
|
||||||
import {
|
|
||||||
canAccessCustomer,
|
|
||||||
canAccessContract,
|
|
||||||
canAccessCachedEmail,
|
|
||||||
} from '../utils/accessControl.js';
|
|
||||||
|
|
||||||
// ==================== E-MAIL LIST ====================
|
// ==================== E-MAIL LIST ====================
|
||||||
|
|
||||||
// E-Mails für einen Kunden abrufen
|
// 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 {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
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 stressfreiEmailId = req.query.accountId ? parseInt(req.query.accountId as string) : undefined;
|
||||||
const folder = req.query.folder as string | undefined; // INBOX oder SENT
|
const folder = req.query.folder as string | undefined; // INBOX oder SENT
|
||||||
const limit = req.query.limit ? parseInt(req.query.limit as string) : 50;
|
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
|
// 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 {
|
try {
|
||||||
const contractId = parseInt(req.params.contractId);
|
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 folder = req.query.folder as string | undefined; // INBOX oder SENT
|
||||||
const limit = req.query.limit ? parseInt(req.query.limit as string) : 50;
|
const limit = req.query.limit ? parseInt(req.query.limit as string) : 50;
|
||||||
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0;
|
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 ====================
|
// ==================== SINGLE EMAIL ====================
|
||||||
|
|
||||||
// Einzelne E-Mail abrufen (mit Body)
|
// 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 {
|
try {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
if (!(await canAccessCachedEmail(req, res, id))) return;
|
|
||||||
|
|
||||||
const email = await cachedEmailService.getCachedEmailById(id);
|
const email = await cachedEmailService.getCachedEmailById(id);
|
||||||
|
|
||||||
if (!email) {
|
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
|
// E-Mail senden
|
||||||
export async function sendEmailFromAccount(req: Request, res: Response): Promise<void> {
|
export async function sendEmailFromAccount(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const stressfreiEmailId = parseInt(req.params.id);
|
const stressfreiEmailId = parseInt(req.params.id);
|
||||||
const { to, cc, subject, text, html, inReplyTo, references, attachments, contractId } = req.body;
|
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
|
// StressfreiEmail laden
|
||||||
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(stressfreiEmailId);
|
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(stressfreiEmailId);
|
||||||
|
|
||||||
@@ -425,10 +396,9 @@ export async function sendEmailFromAccount(req: Request, res: Response): Promise
|
|||||||
// ==================== ATTACHMENTS ====================
|
// ==================== ATTACHMENTS ====================
|
||||||
|
|
||||||
// Anhang-Liste einer E-Mail abrufen
|
// 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 {
|
try {
|
||||||
const emailId = parseInt(req.params.emailId);
|
const emailId = parseInt(req.params.emailId);
|
||||||
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
|
||||||
|
|
||||||
// E-Mail aus Cache laden
|
// E-Mail aus Cache laden
|
||||||
const email = await cachedEmailService.getCachedEmailById(emailId);
|
const email = await cachedEmailService.getCachedEmailById(emailId);
|
||||||
@@ -459,14 +429,11 @@ export async function getAttachments(req: AuthRequest, res: Response): Promise<v
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Einzelnen Anhang herunterladen
|
// Einzelnen Anhang herunterladen
|
||||||
export async function downloadAttachment(req: AuthRequest, res: Response): Promise<void> {
|
export async function downloadAttachment(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const emailId = parseInt(req.params.emailId);
|
const emailId = parseInt(req.params.emailId);
|
||||||
const filename = decodeURIComponent(req.params.filename);
|
const filename = decodeURIComponent(req.params.filename);
|
||||||
|
|
||||||
// Portal-Isolation: nur eigene/vertretene Emails
|
|
||||||
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
|
||||||
|
|
||||||
// E-Mail aus Cache laden
|
// E-Mail aus Cache laden
|
||||||
const email = await cachedEmailService.getCachedEmailById(emailId);
|
const email = await cachedEmailService.getCachedEmailById(emailId);
|
||||||
if (!email) {
|
if (!email) {
|
||||||
@@ -533,48 +500,17 @@ export async function downloadAttachment(req: AuthRequest, res: Response): Promi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Security: Content-Type aus IMAP kommt vom Absender und kann `text/html`
|
// Datei senden - inline (öffnen) oder attachment (download)
|
||||||
// o.ä. sein. Für inline-Preview nur eine Whitelist "harmloser" Typen
|
const disposition = req.query.view === 'true' ? 'inline' : 'attachment';
|
||||||
// zulassen, sonst zwingend als Download (attachment) ausliefern, um XSS
|
res.setHeader('Content-Type', attachment.contentType);
|
||||||
// via inline-HTML-Anhang zu verhindern. Zusätzlich nosniff setzen.
|
res.setHeader('Content-Disposition', `${disposition}; filename="${encodeURIComponent(attachment.filename)}"`);
|
||||||
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)}"`);
|
|
||||||
res.setHeader('Content-Length', attachment.size);
|
res.setHeader('Content-Length', attachment.size);
|
||||||
res.send(attachment.content);
|
res.send(attachment.content);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('downloadAttachment error:', 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({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: `Fehler beim Herunterladen des Anhangs: ${friendly}`,
|
error: 'Fehler beim Herunterladen des Anhangs',
|
||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1643,7 +1579,7 @@ export async function saveEmailAsInvoice(req: Request, res: Response): Promise<v
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vertrag laden
|
// Vertrag laden und prüfen ob es ein Energievertrag ist
|
||||||
const contract = await prisma.contract.findUnique({
|
const contract = await prisma.contract.findUnique({
|
||||||
where: { id: email.contractId },
|
where: { id: email.contractId },
|
||||||
include: { energyDetails: true },
|
include: { energyDetails: true },
|
||||||
@@ -1657,6 +1593,22 @@ export async function saveEmailAsInvoice(req: Request, res: Response): Promise<v
|
|||||||
return;
|
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)
|
// Empfänger-Adressen parsen (JSON Array)
|
||||||
let toAddresses: string[] = [];
|
let toAddresses: string[] = [];
|
||||||
let ccAddresses: string[] = [];
|
let ccAddresses: string[] = [];
|
||||||
@@ -1693,15 +1645,8 @@ export async function saveEmailAsInvoice(req: Request, res: Response): Promise<v
|
|||||||
// PDF speichern
|
// PDF speichern
|
||||||
fs.writeFileSync(filePath, pdfBuffer);
|
fs.writeFileSync(filePath, pdfBuffer);
|
||||||
|
|
||||||
// Invoice in DB erstellen (für alle Vertragstypen)
|
// Invoice in DB erstellen
|
||||||
const invoice = contract.energyDetails
|
const invoice = await invoiceService.addInvoice(contract.energyDetails.id, {
|
||||||
? 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),
|
invoiceDate: new Date(invoiceDate),
|
||||||
invoiceType,
|
invoiceType,
|
||||||
documentPath: relativePath,
|
documentPath: relativePath,
|
||||||
@@ -1770,7 +1715,7 @@ export async function saveAttachmentAsInvoice(req: Request, res: Response): Prom
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vertrag laden
|
// Vertrag laden und prüfen ob es ein Energievertrag ist
|
||||||
const contract = await prisma.contract.findUnique({
|
const contract = await prisma.contract.findUnique({
|
||||||
where: { id: email.contractId },
|
where: { id: email.contractId },
|
||||||
include: { energyDetails: true },
|
include: { energyDetails: true },
|
||||||
@@ -1784,6 +1729,22 @@ export async function saveAttachmentAsInvoice(req: Request, res: Response): Prom
|
|||||||
return;
|
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
|
// Für gesendete E-Mails: Prüfen ob UID vorhanden
|
||||||
if (email.folder === 'SENT' && email.uid === 0) {
|
if (email.folder === 'SENT' && email.uid === 0) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
@@ -1855,15 +1816,8 @@ export async function saveAttachmentAsInvoice(req: Request, res: Response): Prom
|
|||||||
// Datei speichern
|
// Datei speichern
|
||||||
fs.writeFileSync(filePath, attachment.content);
|
fs.writeFileSync(filePath, attachment.content);
|
||||||
|
|
||||||
// Invoice in DB erstellen (für alle Vertragstypen)
|
// Invoice in DB erstellen
|
||||||
const invoice = contract.energyDetails
|
const invoice = await invoiceService.addInvoice(contract.energyDetails.id, {
|
||||||
? 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),
|
invoiceDate: new Date(invoiceDate),
|
||||||
invoiceType,
|
invoiceType,
|
||||||
documentPath: relativePath,
|
documentPath: relativePath,
|
||||||
@@ -1883,139 +1837,3 @@ export async function saveAttachmentAsInvoice(req: Request, res: Response): Prom
|
|||||||
} as ApiResponse);
|
} 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import * as contractHistoryService from '../services/contractHistory.service.js'
|
|||||||
import * as authorizationService from '../services/authorization.service.js';
|
import * as authorizationService from '../services/authorization.service.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
import { logChange } from '../services/audit.service.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> {
|
export async function getContracts(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
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 {
|
try {
|
||||||
const contractId = parseInt(req.params.id);
|
const password = await contractService.getContractPassword(parseInt(req.params.id));
|
||||||
if (!(await canAccessContract(req, res, contractId))) return;
|
|
||||||
|
|
||||||
const password = await contractService.getContractPassword(contractId);
|
|
||||||
if (password === null) {
|
if (password === null) {
|
||||||
res.status(404).json({
|
res.status(404).json({
|
||||||
success: false,
|
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 {
|
try {
|
||||||
const simCardId = parseInt(req.params.simCardId);
|
const credentials = await contractService.getSimCardCredentials(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);
|
|
||||||
res.json({ success: true, data: credentials } as ApiResponse);
|
res.json({ success: true, data: credentials } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
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 {
|
try {
|
||||||
const contractId = parseInt(req.params.id);
|
const credentials = await contractService.getInternetCredentials(parseInt(req.params.id));
|
||||||
if (!(await canAccessContract(req, res, contractId))) return;
|
|
||||||
|
|
||||||
const credentials = await contractService.getInternetCredentials(contractId);
|
|
||||||
res.json({ success: true, data: credentials } as ApiResponse);
|
res.json({ success: true, data: credentials } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
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 {
|
try {
|
||||||
const phoneNumberId = parseInt(req.params.phoneNumberId);
|
const credentials = await contractService.getSipCredentials(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);
|
|
||||||
res.json({ success: true, data: credentials } as ApiResponse);
|
res.json({ success: true, data: credentials } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
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> {
|
export async function getContractDocuments(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const contractId = parseInt(req.params.id);
|
const contractId = parseInt(req.params.id);
|
||||||
if (!(await canAccessContract(req, res, contractId))) return;
|
|
||||||
|
|
||||||
const documents = await prisma.contractDocument.findMany({
|
const documents = await prisma.contractDocument.findMany({
|
||||||
where: { contractId },
|
where: { contractId },
|
||||||
orderBy: { createdAt: 'desc' },
|
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> {
|
export async function uploadContractDocument(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const contractId = parseInt(req.params.id);
|
const contractId = parseInt(req.params.id);
|
||||||
if (!(await canAccessContract(req, res, contractId))) return;
|
const { documentType, notes } = req.body;
|
||||||
const { documentType, notes, deliveryDate } = req.body;
|
|
||||||
|
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' } as ApiResponse);
|
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,
|
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);
|
res.status(201).json({ success: true, data: doc } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
@@ -512,7 +474,6 @@ export async function deleteContractDocument(req: AuthRequest, res: Response): P
|
|||||||
try {
|
try {
|
||||||
const documentId = parseInt(req.params.documentId);
|
const documentId = parseInt(req.params.documentId);
|
||||||
const contractId = parseInt(req.params.id);
|
const contractId = parseInt(req.params.id);
|
||||||
if (!(await canAccessContract(req, res, contractId))) return;
|
|
||||||
|
|
||||||
const doc = await prisma.contractDocument.findUnique({ where: { id: documentId } });
|
const doc = await prisma.contractDocument.findUnique({ where: { id: documentId } });
|
||||||
if (!doc || doc.contractId !== contractId) {
|
if (!doc || doc.contractId !== contractId) {
|
||||||
|
|||||||
@@ -4,23 +4,9 @@ import * as customerService from '../services/customer.service.js';
|
|||||||
import * as authService from '../services/auth.service.js';
|
import * as authService from '../services/auth.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.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
|
// Customer CRUD
|
||||||
export async function getCustomers(req: AuthRequest, res: Response): Promise<void> {
|
export async function getCustomers(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { search, type, page, limit } = req.query;
|
const { search, type, page, limit } = req.query;
|
||||||
const result = await customerService.getAllCustomers({
|
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,
|
page: page ? parseInt(page as string) : undefined,
|
||||||
limit: limit ? parseInt(limit as string) : undefined,
|
limit: limit ? parseInt(limit as string) : undefined,
|
||||||
});
|
});
|
||||||
let customers = result.customers as any[];
|
res.json({ success: true, data: result.customers, pagination: result.pagination } as ApiResponse);
|
||||||
|
|
||||||
// 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);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
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 {
|
try {
|
||||||
const customerId = parseInt(req.params.id);
|
const customer = await customerService.getCustomerById(parseInt(req.params.id));
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
|
||||||
const customer = await customerService.getCustomerById(customerId);
|
|
||||||
if (!customer) {
|
if (!customer) {
|
||||||
res.status(404).json({ success: false, error: 'Kunde nicht gefunden' } as ApiResponse);
|
res.status(404).json({ success: false, error: 'Kunde nicht gefunden' } as ApiResponse);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Portal-Kunden/Read-only sehen kein portalPasswordEncrypted
|
res.json({ success: true, data: customer } as ApiResponse);
|
||||||
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);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ success: false, error: 'Fehler beim Laden des Kunden' } as ApiResponse);
|
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> {
|
export async function createCustomer(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Whitelist: nur erlaubte Felder aus req.body übernehmen
|
const data = { ...req.body };
|
||||||
const data: any = pickCustomerCreate(req.body);
|
|
||||||
// Convert birthDate string to Date if present
|
// Convert birthDate string to Date if present
|
||||||
if (data.birthDate) {
|
if (data.birthDate) {
|
||||||
data.birthDate = new Date(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> {
|
export async function updateCustomer(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.id);
|
const customerId = parseInt(req.params.id);
|
||||||
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
|
const data = { ...req.body };
|
||||||
const data: any = pickCustomerUpdate(req.body);
|
|
||||||
|
|
||||||
// Vorherigen Stand laden für Audit
|
// Vorherigen Stand laden für Audit
|
||||||
const before = await prisma.customer.findUnique({ where: { id: customerId } });
|
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',
|
salutation: 'Anrede', firstName: 'Vorname', lastName: 'Nachname', email: 'E-Mail',
|
||||||
phone: 'Telefon', mobile: 'Mobil', birthDate: 'Geburtsdatum', birthPlace: 'Geburtsort',
|
phone: 'Telefon', mobile: 'Mobil', birthDate: 'Geburtsdatum', birthPlace: 'Geburtsort',
|
||||||
companyName: 'Firma', type: 'Typ', taxNumber: 'Steuernummer', notes: 'Notizen',
|
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)) {
|
for (const [key, value] of Object.entries(data)) {
|
||||||
// Technische/interne Felder überspringen
|
// Technische/interne Felder überspringen
|
||||||
@@ -201,21 +157,18 @@ export async function deleteCustomer(req: Request, res: Response): Promise<void>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Addresses
|
// Addresses
|
||||||
export async function getAddresses(req: AuthRequest, res: Response): Promise<void> {
|
export async function getAddresses(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const addresses = await customerService.getCustomerAddresses(parseInt(req.params.customerId));
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
|
||||||
const addresses = await customerService.getCustomerAddresses(customerId);
|
|
||||||
res.json({ success: true, data: addresses } as ApiResponse);
|
res.json({ success: true, data: addresses } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ success: false, error: 'Fehler beim Laden der Adressen' } as ApiResponse);
|
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 {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
|
||||||
const address = await customerService.createAddress(customerId, req.body);
|
const address = await customerService.createAddress(customerId, req.body);
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'CREATE', resourceType: 'Address',
|
req, action: 'CREATE', resourceType: 'Address',
|
||||||
@@ -317,22 +270,22 @@ export async function deleteAddress(req: Request, res: Response): Promise<void>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Bank Cards
|
// Bank Cards
|
||||||
export async function getBankCards(req: AuthRequest, res: Response): Promise<void> {
|
export async function getBankCards(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
|
||||||
const showInactive = req.query.showInactive === 'true';
|
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);
|
res.json({ success: true, data: cards } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ success: false, error: 'Fehler beim Laden der Bankkarten' } as ApiResponse);
|
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 {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
|
||||||
const card = await customerService.createBankCard(customerId, req.body);
|
const card = await customerService.createBankCard(customerId, req.body);
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'CREATE', resourceType: 'BankCard',
|
req, action: 'CREATE', resourceType: 'BankCard',
|
||||||
@@ -429,22 +382,22 @@ export async function deleteBankCard(req: Request, res: Response): Promise<void>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Identity Documents
|
// Identity Documents
|
||||||
export async function getDocuments(req: AuthRequest, res: Response): Promise<void> {
|
export async function getDocuments(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
|
||||||
const showInactive = req.query.showInactive === 'true';
|
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);
|
res.json({ success: true, data: docs } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ success: false, error: 'Fehler beim Laden der Ausweise' } as ApiResponse);
|
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 {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
|
||||||
const doc = await customerService.createDocument(customerId, req.body);
|
const doc = await customerService.createDocument(customerId, req.body);
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'CREATE', resourceType: 'IdentityDocument',
|
req, action: 'CREATE', resourceType: 'IdentityDocument',
|
||||||
@@ -547,22 +500,22 @@ export async function deleteDocument(req: Request, res: Response): Promise<void>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Meters
|
// Meters
|
||||||
export async function getMeters(req: AuthRequest, res: Response): Promise<void> {
|
export async function getMeters(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
|
||||||
const showInactive = req.query.showInactive === 'true';
|
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);
|
res.json({ success: true, data: meters } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ success: false, error: 'Fehler beim Laden der Zähler' } as ApiResponse);
|
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 {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
|
||||||
const meter = await customerService.createMeter(customerId, req.body);
|
const meter = await customerService.createMeter(customerId, req.body);
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'CREATE', resourceType: 'Meter',
|
req, action: 'CREATE', resourceType: 'Meter',
|
||||||
@@ -655,11 +608,9 @@ export async function deleteMeter(req: Request, res: Response): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Meter Readings
|
// Meter Readings
|
||||||
export async function getMeterReadings(req: AuthRequest, res: Response): Promise<void> {
|
export async function getMeterReadings(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const meterId = parseInt(req.params.meterId);
|
const readings = await customerService.getMeterReadings(parseInt(req.params.meterId));
|
||||||
if (!(await canAccessMeter(req, res, meterId))) return;
|
|
||||||
const readings = await customerService.getMeterReadings(meterId);
|
|
||||||
res.json({ success: true, data: readings } as ApiResponse);
|
res.json({ success: true, data: readings } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ success: false, error: 'Fehler beim Laden der Zählerstände' } as ApiResponse);
|
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 ====================
|
// ==================== PORTAL SETTINGS ====================
|
||||||
|
|
||||||
export async function getPortalSettings(req: AuthRequest, res: Response): Promise<void> {
|
export async function getPortalSettings(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const settings = await customerService.getPortalSettings(parseInt(req.params.customerId));
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
|
||||||
const settings = await customerService.getPortalSettings(customerId);
|
|
||||||
if (!settings) {
|
if (!settings) {
|
||||||
res.status(404).json({ success: false, error: 'Kunde nicht gefunden' } as ApiResponse);
|
res.status(404).json({ success: false, error: 'Kunde nicht gefunden' } as ApiResponse);
|
||||||
return;
|
return;
|
||||||
@@ -998,12 +947,10 @@ export async function getPortalPassword(req: Request, res: Response): Promise<vo
|
|||||||
|
|
||||||
// ==================== REPRESENTATIVE MANAGEMENT ====================
|
// ==================== REPRESENTATIVE MANAGEMENT ====================
|
||||||
|
|
||||||
export async function getRepresentatives(req: AuthRequest, res: Response): Promise<void> {
|
export async function getRepresentatives(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
|
||||||
// Wer kann diesen Kunden vertreten (representedBy)?
|
// 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);
|
res.json({ success: true, data: representedBy } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
|
|||||||
@@ -4,14 +4,6 @@ import { Request, Response } from 'express';
|
|||||||
import * as emailProviderService from '../services/emailProvider/emailProviderService.js';
|
import * as emailProviderService from '../services/emailProvider/emailProviderService.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiResponse } from '../types/index.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 ====================
|
// ==================== CONFIG CRUD ====================
|
||||||
|
|
||||||
@@ -120,33 +112,6 @@ export async function testConnection(req: Request, res: Response): Promise<void>
|
|||||||
domain: req.body.domain,
|
domain: req.body.domain,
|
||||||
} : undefined;
|
} : 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 });
|
const result = await emailProviderService.testProviderConnection({ id, testData });
|
||||||
res.json({ success: result.success, data: result } as ApiResponse);
|
res.json({ success: result.success, data: result } as ApiResponse);
|
||||||
} catch (error) {
|
} 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> {
|
export async function checkEmailExists(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { localPart } = req.params;
|
const { localPart } = req.params;
|
||||||
@@ -398,20 +181,3 @@ export async function getProviderDomain(req: Request, res: Response): Promise<vo
|
|||||||
} as ApiResponse);
|
} 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);
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,6 @@ import * as gdprService from '../services/gdpr.service.js';
|
|||||||
import * as consentService from '../services/consent.service.js';
|
import * as consentService from '../services/consent.service.js';
|
||||||
import * as consentPublicService from '../services/consent-public.service.js';
|
import * as consentPublicService from '../services/consent-public.service.js';
|
||||||
import * as appSettingService from '../services/appSetting.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 { createAuditLog, logChange } from '../services/audit.service.js';
|
||||||
import { ConsentType, DeletionRequestStatus } from '@prisma/client';
|
import { ConsentType, DeletionRequestStatus } from '@prisma/client';
|
||||||
import prisma from '../lib/prisma.js';
|
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' });
|
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 filepath = path.join(process.cwd(), 'uploads', request.proofDocument);
|
||||||
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' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fs.existsSync(filepath)) {
|
if (!fs.existsSync(filepath)) {
|
||||||
return res.status(404).json({ success: false, error: 'Datei nicht gefunden' });
|
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) {
|
export async function getCustomerConsents(req: AuthRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
|
||||||
const consents = await consentService.getCustomerConsents(customerId);
|
const consents = await consentService.getCustomerConsents(customerId);
|
||||||
|
|
||||||
// Labels hinzufügen
|
// Labels hinzufügen
|
||||||
@@ -253,7 +246,6 @@ export async function getCustomerConsents(req: AuthRequest, res: Response) {
|
|||||||
export async function checkConsentStatus(req: AuthRequest, res: Response) {
|
export async function checkConsentStatus(req: AuthRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
|
||||||
const result = await consentService.hasFullConsent(customerId);
|
const result = await consentService.hasFullConsent(customerId);
|
||||||
res.json({ success: true, data: result });
|
res.json({ success: true, data: result });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -802,7 +794,6 @@ export async function sendAuthorizationRequest(req: AuthRequest, res: Response)
|
|||||||
export async function getAuthorizations(req: AuthRequest, res: Response) {
|
export async function getAuthorizations(req: AuthRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
|
||||||
// Sicherstellen dass Einträge für alle aktiven Vertreter existieren
|
// Sicherstellen dass Einträge für alle aktiven Vertreter existieren
|
||||||
await authorizationService.ensureAuthorizationEntries(customerId);
|
await authorizationService.ensureAuthorizationEntries(customerId);
|
||||||
const authorizations = await authorizationService.getAuthorizationsForCustomer(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 representativeId = parseInt(req.params.representativeId);
|
||||||
const { grant } = req.body;
|
const { grant } = req.body;
|
||||||
|
|
||||||
// Validierungen:
|
// Vertreter-Name laden
|
||||||
// 1) Self-Grant verhindern (sinnlos und schafft Datenmüll).
|
const representative = await prisma.customer.findUnique({
|
||||||
if (representativeId === user.customerId) {
|
where: { id: representativeId },
|
||||||
return res.status(400).json({ success: false, error: 'Kein Self-Grant möglich' });
|
select: { firstName: true, lastName: true },
|
||||||
}
|
|
||||||
// 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 } } },
|
|
||||||
});
|
});
|
||||||
if (!relation) {
|
const repName = representative ? `${representative.firstName} ${representative.lastName}` : `#${representativeId}`;
|
||||||
return res.status(403).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Kein Vertreter-Verhältnis – Vollmacht nicht erlaubt',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const repName = `${relation.representative.firstName} ${relation.representative.lastName}`;
|
|
||||||
|
|
||||||
let auth;
|
let auth;
|
||||||
if (grant) {
|
if (grant) {
|
||||||
@@ -1006,9 +982,10 @@ export async function toggleMyAuthorization(req: AuthRequest, res: Response) {
|
|||||||
res.json({ success: true, data: auth });
|
res.json({ success: true, data: auth });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Ändern der Vollmacht:', error);
|
console.error('Fehler beim Ändern der Vollmacht:', error);
|
||||||
// Generische Fehlermeldung – Prisma-Errors enthalten Pfad/Schema und
|
res.status(400).json({
|
||||||
// sollten nicht an Endkunden geleakt werden.
|
success: false,
|
||||||
res.status(400).json({ success: false, error: 'Vollmacht konnte nicht aktualisiert werden' });
|
error: error instanceof Error ? error.message : 'Fehler beim Ändern',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import * as invoiceService from '../services/invoice.service.js';
|
import * as invoiceService from '../services/invoice.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
import { ApiResponse } from '../types/index.js';
|
||||||
import { canAccessContract, canAccessEnergyContractDetails } from '../utils/accessControl.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Alle Rechnungen für ein EnergyContractDetails abrufen
|
* 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 {
|
try {
|
||||||
const ecdId = parseInt(req.params.ecdId);
|
const ecdId = parseInt(req.params.ecdId);
|
||||||
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
|
|
||||||
const invoices = await invoiceService.getInvoices(ecdId);
|
const invoices = await invoiceService.getInvoices(ecdId);
|
||||||
res.json({ success: true, data: invoices } as ApiResponse);
|
res.json({ success: true, data: invoices } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -25,11 +23,10 @@ export async function getInvoices(req: AuthRequest, res: Response): Promise<void
|
|||||||
/**
|
/**
|
||||||
* Einzelne Rechnung abrufen
|
* Einzelne Rechnung abrufen
|
||||||
*/
|
*/
|
||||||
export async function getInvoice(req: AuthRequest, res: Response): Promise<void> {
|
export async function getInvoice(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const ecdId = parseInt(req.params.ecdId);
|
const ecdId = parseInt(req.params.ecdId);
|
||||||
const invoiceId = parseInt(req.params.invoiceId);
|
const invoiceId = parseInt(req.params.invoiceId);
|
||||||
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
|
|
||||||
const invoice = await invoiceService.getInvoice(ecdId, invoiceId);
|
const invoice = await invoiceService.getInvoice(ecdId, invoiceId);
|
||||||
|
|
||||||
if (!invoice) {
|
if (!invoice) {
|
||||||
@@ -53,10 +50,9 @@ export async function getInvoice(req: AuthRequest, res: Response): Promise<void>
|
|||||||
/**
|
/**
|
||||||
* Neue Rechnung hinzufügen
|
* 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 {
|
try {
|
||||||
const ecdId = parseInt(req.params.ecdId);
|
const ecdId = parseInt(req.params.ecdId);
|
||||||
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
|
|
||||||
const { invoiceDate, invoiceType, documentPath, notes } = req.body;
|
const { invoiceDate, invoiceType, documentPath, notes } = req.body;
|
||||||
|
|
||||||
if (!invoiceDate || !invoiceType) {
|
if (!invoiceDate || !invoiceType) {
|
||||||
@@ -93,11 +89,10 @@ export async function addInvoice(req: AuthRequest, res: Response): Promise<void>
|
|||||||
/**
|
/**
|
||||||
* Rechnung aktualisieren
|
* Rechnung aktualisieren
|
||||||
*/
|
*/
|
||||||
export async function updateInvoice(req: AuthRequest, res: Response): Promise<void> {
|
export async function updateInvoice(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const ecdId = parseInt(req.params.ecdId);
|
const ecdId = parseInt(req.params.ecdId);
|
||||||
const invoiceId = parseInt(req.params.invoiceId);
|
const invoiceId = parseInt(req.params.invoiceId);
|
||||||
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
|
|
||||||
const { invoiceDate, invoiceType, documentPath, notes } = req.body;
|
const { invoiceDate, invoiceType, documentPath, notes } = req.body;
|
||||||
|
|
||||||
const invoice = await invoiceService.updateInvoice(ecdId, invoiceId, {
|
const invoice = await invoiceService.updateInvoice(ecdId, invoiceId, {
|
||||||
@@ -126,11 +121,10 @@ export async function updateInvoice(req: AuthRequest, res: Response): Promise<vo
|
|||||||
/**
|
/**
|
||||||
* Rechnung löschen
|
* Rechnung löschen
|
||||||
*/
|
*/
|
||||||
export async function deleteInvoice(req: AuthRequest, res: Response): Promise<void> {
|
export async function deleteInvoice(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const ecdId = parseInt(req.params.ecdId);
|
const ecdId = parseInt(req.params.ecdId);
|
||||||
const invoiceId = parseInt(req.params.invoiceId);
|
const invoiceId = parseInt(req.params.invoiceId);
|
||||||
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
|
|
||||||
|
|
||||||
await invoiceService.deleteInvoice(ecdId, invoiceId);
|
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) ====================
|
// ==================== 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 {
|
try {
|
||||||
const contractId = parseInt(req.params.id);
|
const contractId = parseInt(req.params.id);
|
||||||
if (!(await canAccessContract(req, res, contractId))) return;
|
|
||||||
const invoices = await invoiceService.getInvoicesByContract(contractId);
|
const invoices = await invoiceService.getInvoicesByContract(contractId);
|
||||||
res.json({ success: true, data: invoices } as ApiResponse);
|
res.json({ success: true, data: invoices } as ApiResponse);
|
||||||
} catch (error) {
|
} 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 {
|
try {
|
||||||
const contractId = parseInt(req.params.id);
|
const contractId = parseInt(req.params.id);
|
||||||
if (!(await canAccessContract(req, res, contractId))) return;
|
|
||||||
const { invoiceDate, invoiceType, notes } = req.body;
|
const { invoiceDate, invoiceType, notes } = req.body;
|
||||||
const invoice = await invoiceService.addInvoiceByContract(contractId, {
|
const invoice = await invoiceService.addInvoiceByContract(contractId, {
|
||||||
invoiceDate: new Date(invoiceDate),
|
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 { AuthRequest } from '../types/index.js';
|
||||||
import * as pdfTemplateService from '../services/pdfTemplate.service.js';
|
import * as pdfTemplateService from '../services/pdfTemplate.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { canAccessContract } from '../utils/accessControl.js';
|
|
||||||
|
|
||||||
export async function getTemplates(req: AuthRequest, res: Response) {
|
export async function getTemplates(req: AuthRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
@@ -150,7 +149,6 @@ export async function getRequiredInputs(req: AuthRequest, res: Response) {
|
|||||||
try {
|
try {
|
||||||
const templateId = parseInt(req.params.id);
|
const templateId = parseInt(req.params.id);
|
||||||
const contractId = parseInt(req.params.contractId);
|
const contractId = parseInt(req.params.contractId);
|
||||||
if (!(await canAccessContract(req, res, contractId))) return;
|
|
||||||
const inputs = await pdfTemplateService.getRequiredInputs(templateId, contractId);
|
const inputs = await pdfTemplateService.getRequiredInputs(templateId, contractId);
|
||||||
res.json({ success: true, data: inputs });
|
res.json({ success: true, data: inputs });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -162,7 +160,6 @@ export async function generatePdf(req: AuthRequest, res: Response) {
|
|||||||
try {
|
try {
|
||||||
const templateId = parseInt(req.params.id);
|
const templateId = parseInt(req.params.id);
|
||||||
const contractId = parseInt(req.params.contractId);
|
const contractId = parseInt(req.params.contractId);
|
||||||
if (!(await canAccessContract(req, res, contractId))) return;
|
|
||||||
|
|
||||||
// Extras aus Body (POST) oder Query-Parametern (GET)
|
// Extras aus Body (POST) oder Query-Parametern (GET)
|
||||||
const stressfreiEmailId = req.body?.stressfreiEmailId || req.query.stressfreiEmailId;
|
const stressfreiEmailId = req.body?.stressfreiEmailId || req.query.stressfreiEmailId;
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import * as stressfreiEmailService from '../services/stressfreiEmail.service.js';
|
import * as stressfreiEmailService from '../services/stressfreiEmail.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
import { ApiResponse } from '../types/index.js';
|
||||||
import { canAccessStressfreiEmail } from '../utils/accessControl.js';
|
|
||||||
|
|
||||||
export async function getEmailsByCustomer(req: Request, res: Response): Promise<void> {
|
export async function getEmailsByCustomer(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
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 {
|
try {
|
||||||
const emailId = parseInt(req.params.id);
|
const email = await stressfreiEmailService.getEmailById(parseInt(req.params.id));
|
||||||
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
|
||||||
|
|
||||||
const email = await stressfreiEmailService.getEmailById(emailId);
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
res.status(404).json({
|
res.status(404).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -31,13 +27,7 @@ export async function getEmail(req: AuthRequest, res: Response): Promise<void> {
|
|||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
res.json({ success: true, data: email } as ApiResponse);
|
||||||
// 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);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import prisma from '../lib/prisma.js';
|
|||||||
import * as userService from '../services/user.service.js';
|
import * as userService from '../services/user.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiResponse } from '../types/index.js';
|
import { ApiResponse } from '../types/index.js';
|
||||||
import { pickUserCreate, pickUserUpdate } from '../utils/sanitize.js';
|
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
export async function getUsers(req: Request, res: Response): Promise<void> {
|
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> {
|
export async function createUser(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
|
const user = await userService.createUser(req.body);
|
||||||
const user = await userService.createUser(pickUserCreate(req.body) as any);
|
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'CREATE', resourceType: 'User',
|
req, action: 'CREATE', resourceType: 'User',
|
||||||
resourceId: user.id.toString(),
|
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> {
|
export async function updateUser(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const userId = parseInt(req.params.id);
|
const userId = parseInt(req.params.id);
|
||||||
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
|
const data = req.body;
|
||||||
const data = pickUserUpdate(req.body);
|
|
||||||
|
|
||||||
// Vorherigen Stand laden für Audit
|
// Vorherigen Stand laden für Audit
|
||||||
const before = await prisma.user.findUnique({ where: { id: userId } });
|
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) {
|
if (user) {
|
||||||
// Audit: Geänderte Felder ermitteln und loggen
|
// Audit: Geänderte Felder ermitteln und loggen
|
||||||
if (before) {
|
if (before) {
|
||||||
|
|||||||
+9
-105
@@ -1,6 +1,5 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import helmet from 'helmet';
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
@@ -33,99 +32,24 @@ import consentPublicRoutes from './routes/consent-public.routes.js';
|
|||||||
import emailLogRoutes from './routes/emailLog.routes.js';
|
import emailLogRoutes from './routes/emailLog.routes.js';
|
||||||
import pdfTemplateRoutes from './routes/pdfTemplate.routes.js';
|
import pdfTemplateRoutes from './routes/pdfTemplate.routes.js';
|
||||||
import birthdayRoutes from './routes/birthday.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 { auditContextMiddleware } from './middleware/auditContext.js';
|
||||||
import { auditMiddleware } from './middleware/audit.js';
|
import { auditMiddleware } from './middleware/audit.js';
|
||||||
import { authenticate } from './middleware/auth.js';
|
|
||||||
|
|
||||||
dotenv.config();
|
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 app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
// Hinter einem Reverse-Proxy (Nginx/Plesk) läuft der Server typisch auf localhost.
|
// Middleware
|
||||||
// `trust proxy = 'loopback'` vertraut nur Connections von 127.0.0.1 / ::1
|
app.use(cors());
|
||||||
// (= lokaler Reverse-Proxy). Damit kann ein Angreifer mit DIREKTEM Zugriff
|
app.use(express.json());
|
||||||
// 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' }));
|
|
||||||
|
|
||||||
// Audit-Logging Middleware (DSGVO-konform)
|
// Audit-Logging Middleware (DSGVO-konform)
|
||||||
app.use(auditContextMiddleware);
|
app.use(auditContextMiddleware);
|
||||||
app.use(auditMiddleware);
|
app.use(auditMiddleware);
|
||||||
|
|
||||||
// Datei-Download mit Per-File-Ownership-Check (ersetzt das alte
|
// Statische Dateien für Uploads
|
||||||
// `/api/uploads/*` express.static).
|
app.use('/api/uploads', express.static(path.join(process.cwd(), 'uploads')));
|
||||||
// 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);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Öffentliche Routes (OHNE Authentifizierung)
|
// Öffentliche Routes (OHNE Authentifizierung)
|
||||||
app.use('/api/public/consent', consentPublicRoutes);
|
app.use('/api/public/consent', consentPublicRoutes);
|
||||||
@@ -159,8 +83,6 @@ app.use('/api/gdpr', gdprRoutes);
|
|||||||
app.use('/api/email-logs', emailLogRoutes);
|
app.use('/api/email-logs', emailLogRoutes);
|
||||||
app.use('/api/pdf-templates', pdfTemplateRoutes);
|
app.use('/api/pdf-templates', pdfTemplateRoutes);
|
||||||
app.use('/api/birthdays', birthdayRoutes);
|
app.use('/api/birthdays', birthdayRoutes);
|
||||||
app.use('/api/factory-defaults', factoryDefaultsRoutes);
|
|
||||||
app.use('/api/monitoring', monitoringRoutes);
|
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
app.get('/api/health', (req, res) => {
|
app.get('/api/health', (req, res) => {
|
||||||
@@ -185,29 +107,11 @@ if (process.env.NODE_ENV === 'production') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Error handling
|
// Error handling
|
||||||
// body-parser wirft 413 (PayloadTooLargeError) bzw. 400 (SyntaxError) mit einem
|
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
// `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) => {
|
|
||||||
console.error(err.stack);
|
console.error(err.stack);
|
||||||
const status = typeof err.status === 'number' && err.status >= 400 && err.status < 600 ? err.status : 500;
|
res.status(500).json({ success: false, error: 'Interner Serverfehler' });
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen-Adresse: in Production typischerweise 127.0.0.1 (nur lokaler
|
app.listen(PORT, () => {
|
||||||
// Reverse-Proxy soll connecten dürfen). LISTEN_ADDR per Env überschreibbar.
|
console.log(`Server läuft auf Port ${PORT}`);
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Response, NextFunction } from 'express';
|
|||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import { AuthRequest, JwtPayload } from '../types/index.js';
|
import { AuthRequest, JwtPayload } from '../types/index.js';
|
||||||
import { emit as emitSecurityEvent } from '../services/securityMonitor.service.js';
|
|
||||||
|
|
||||||
export async function authenticate(
|
export async function authenticate(
|
||||||
req: AuthRequest,
|
req: AuthRequest,
|
||||||
@@ -27,27 +26,27 @@ export async function authenticate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// JWT_SECRET wird beim Server-Start geprüft (Fail-Fast in index.ts)
|
const decoded = jwt.verify(
|
||||||
// Algorithmus explizit auf HS256 festlegen (Defense-in-Depth gegen alg-confusion).
|
token,
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET as string, {
|
process.env.JWT_SECRET || 'fallback-secret'
|
||||||
algorithms: ['HS256'],
|
) as JwtPayload;
|
||||||
}) 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) {
|
if (decoded.userId && decoded.iat) {
|
||||||
// Mitarbeiter-Login
|
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: decoded.userId },
|
where: { id: decoded.userId },
|
||||||
select: { tokenInvalidatedAt: true, isActive: true },
|
select: { tokenInvalidatedAt: true, isActive: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Benutzer nicht gefunden oder deaktiviert
|
||||||
if (!user || !user.isActive) {
|
if (!user || !user.isActive) {
|
||||||
res.status(401).json({ success: false, error: 'Benutzer nicht mehr aktiv' });
|
res.status(401).json({ success: false, error: 'Benutzer nicht mehr aktiv' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Token wurde vor der Invalidierung ausgestellt
|
||||||
if (user.tokenInvalidatedAt) {
|
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()) {
|
if (tokenIssuedAt < user.tokenInvalidatedAt.getTime()) {
|
||||||
res.status(401).json({
|
res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -56,42 +55,11 @@ export async function authenticate(
|
|||||||
return;
|
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;
|
req.user = decoded;
|
||||||
next();
|
next();
|
||||||
} catch (err) {
|
} catch {
|
||||||
// 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}`,
|
|
||||||
});
|
|
||||||
res.status(401).json({ success: false, error: 'Ungültiger Token' });
|
res.status(401).json({ success: false, error: 'Ungültiger Token' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,18 +1,12 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import * as authController from '../controllers/auth.controller.js';
|
import * as authController from '../controllers/auth.controller.js';
|
||||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||||
import { loginRateLimiter, passwordResetRateLimiter } from '../middleware/rateLimit.js';
|
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.post('/login', loginRateLimiter, authController.login);
|
router.post('/login', authController.login);
|
||||||
router.post('/customer-login', loginRateLimiter, authController.customerLogin);
|
router.post('/customer-login', authController.customerLogin); // Kundenportal-Login
|
||||||
router.get('/me', authenticate, authController.me);
|
router.get('/me', authenticate, authController.me);
|
||||||
router.post('/logout', authenticate, authController.logout);
|
|
||||||
router.post('/register', authenticate, requirePermission('users:create'), authController.register);
|
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;
|
export default router;
|
||||||
|
|||||||
@@ -7,10 +7,6 @@ const router = Router();
|
|||||||
// Admin: Kommende und vergangene Geburtstage
|
// Admin: Kommende und vergangene Geburtstage
|
||||||
router.get('/upcoming', authenticate, requirePermission('customers:read'), birthdayController.getUpcomingBirthdays);
|
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
|
// Portal: eigener Geburtstag-Check
|
||||||
router.get('/my-birthday', authenticate, birthdayController.getMyBirthday);
|
router.get('/my-birthday', authenticate, birthdayController.getMyBirthday);
|
||||||
router.post('/my-birthday/acknowledge', authenticate, birthdayController.acknowledgeMyBirthday);
|
router.post('/my-birthday/acknowledge', authenticate, birthdayController.acknowledgeMyBirthday);
|
||||||
|
|||||||
@@ -203,15 +203,6 @@ router.post(
|
|||||||
cachedEmailController.saveAttachmentAsInvoice
|
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 ====================
|
// ==================== VERTRAGSZUORDNUNG ====================
|
||||||
|
|
||||||
// E-Mail Vertrag zuordnen
|
// E-Mail Vertrag zuordnen
|
||||||
|
|||||||
@@ -15,9 +15,7 @@ router.delete('/configs/:id', authenticate, requirePermission('settings:update')
|
|||||||
|
|
||||||
// Email Operations
|
// Email Operations
|
||||||
router.post('/test-connection', authenticate, requirePermission('settings:update'), emailProviderController.testConnection);
|
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('/domain', authenticate, emailProviderController.getProviderDomain);
|
||||||
router.get('/public-settings', authenticate, emailProviderController.getPublicSettings);
|
|
||||||
router.get('/check/:localPart', authenticate, requirePermission('customers:read'), emailProviderController.checkEmailExists);
|
router.get('/check/:localPart', authenticate, requirePermission('customers:read'), emailProviderController.checkEmailExists);
|
||||||
router.post('/provision', authenticate, requirePermission('customers:update'), emailProviderController.provisionEmail);
|
router.post('/provision', authenticate, requirePermission('customers:update'), emailProviderController.provisionEmail);
|
||||||
router.delete('/deprovision/:localPart', authenticate, requirePermission('customers:update'), emailProviderController.deprovisionEmail);
|
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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -5,15 +5,15 @@ import { authenticate, requirePermission } from '../middleware/auth.js';
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// Provider routes (Portal-Kunden sollen keine Provider-Liste/Tarife sehen)
|
// Provider routes
|
||||||
router.get('/', authenticate, requirePermission('providers:read'), providerController.getProviders);
|
router.get('/', authenticate, providerController.getProviders);
|
||||||
router.post('/', authenticate, requirePermission('providers:create'), providerController.createProvider);
|
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.put('/:id', authenticate, requirePermission('providers:update'), providerController.updateProvider);
|
||||||
router.delete('/:id', authenticate, requirePermission('providers:delete'), providerController.deleteProvider);
|
router.delete('/:id', authenticate, requirePermission('providers:delete'), providerController.deleteProvider);
|
||||||
|
|
||||||
// Nested tariff routes
|
// 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);
|
router.post('/:providerId/tariffs', authenticate, requirePermission('providers:create'), tariffController.createTariff);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { authenticate, requirePermission } from '../middleware/auth.js';
|
|||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// Standalone tariff routes (for update/delete by tariff id)
|
// 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.put('/:id', authenticate, requirePermission('providers:update'), tariffController.updateTariff);
|
||||||
router.delete('/:id', authenticate, requirePermission('providers:delete'), tariffController.deleteTariff);
|
router.delete('/:id', authenticate, requirePermission('providers:delete'), tariffController.deleteTariff);
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import prisma from '../lib/prisma.js';
|
|||||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||||
import { AuthRequest } from '../types/index.js';
|
import { AuthRequest } from '../types/index.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { canAccessContract } from '../utils/accessControl.js';
|
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -547,7 +546,6 @@ async function handleContractDocumentUpload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const contractId = parseInt(req.params.id);
|
const contractId = parseInt(req.params.id);
|
||||||
if (!(await canAccessContract(req, res, contractId))) return;
|
|
||||||
const relativePath = `/uploads/${subDir}/${req.file.filename}`;
|
const relativePath = `/uploads/${subDir}/${req.file.filename}`;
|
||||||
|
|
||||||
// Alte Datei löschen falls vorhanden
|
// 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
|
// Vertrag in der DB aktualisieren
|
||||||
await prisma.contract.update({
|
await prisma.contract.update({
|
||||||
where: { id: contractId },
|
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({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
@@ -633,7 +592,6 @@ async function handleContractDocumentDelete(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const contractId = parseInt(req.params.id);
|
const contractId = parseInt(req.params.id);
|
||||||
if (!(await canAccessContract(req, res, contractId))) return;
|
|
||||||
|
|
||||||
const contract = await prisma.contract.findUnique({ where: { id: contractId } });
|
const contract = await prisma.contract.findUnique({ where: { id: contractId } });
|
||||||
if (!contract) {
|
if (!contract) {
|
||||||
|
|||||||
@@ -1,49 +1,8 @@
|
|||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import crypto from 'crypto';
|
|
||||||
import { JwtPayload } from '../types/index.js';
|
import { JwtPayload } from '../types/index.js';
|
||||||
import { encrypt, decrypt } from '../utils/encryption.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
|
// Mitarbeiter-Login
|
||||||
export async function login(email: string, password: string) {
|
export async function login(email: string, password: string) {
|
||||||
@@ -67,9 +26,6 @@ export async function login(email: string, password: string) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user || !user.isActive) {
|
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');
|
throw new Error('Ungültige Anmeldedaten');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,10 +34,6 @@ export async function login(email: string, password: string) {
|
|||||||
throw new Error('Ungültige Anmeldedaten');
|
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
|
// Collect all permissions from all roles
|
||||||
const permissions = new Set<string>();
|
const permissions = new Set<string>();
|
||||||
for (const userRole of user.roles) {
|
for (const userRole of user.roles) {
|
||||||
@@ -100,7 +52,7 @@ export async function login(email: string, password: string) {
|
|||||||
isCustomerPortal: false,
|
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'],
|
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) {
|
if (!customer || !customer.portalEnabled || !customer.portalPasswordHash) {
|
||||||
console.log('[CustomerLogin] Abbruch: Kunde nicht gefunden oder Portal nicht aktiviert');
|
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');
|
throw new Error('Ungültige Anmeldedaten');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,9 +110,6 @@ export async function customerLogin(email: string, password: string) {
|
|||||||
throw new Error('Ungültige Anmeldedaten');
|
throw new Error('Ungültige Anmeldedaten');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lazy-Upgrade analog zu Mitarbeiter-Login
|
|
||||||
maybeUpgradePasswordHash('customer', customer.id, password, customer.portalPasswordHash).catch(() => {});
|
|
||||||
|
|
||||||
// Letzte Anmeldung aktualisieren
|
// Letzte Anmeldung aktualisieren
|
||||||
await prisma.customer.update({
|
await prisma.customer.update({
|
||||||
where: { id: customer.id },
|
where: { id: customer.id },
|
||||||
@@ -188,7 +135,7 @@ export async function customerLogin(email: string, password: string) {
|
|||||||
representedCustomerIds,
|
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'],
|
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) {
|
export async function setCustomerPortalPassword(customerId: number, password: string) {
|
||||||
console.log('[SetPortalPassword] Setze Passwort für Kunde:', customerId);
|
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);
|
const encryptedPassword = encrypt(password);
|
||||||
|
|
||||||
console.log('[SetPortalPassword] Hash erstellt, Länge:', hashedPassword.length);
|
console.log('[SetPortalPassword] Hash erstellt, Länge:', hashedPassword.length);
|
||||||
@@ -261,7 +208,7 @@ export async function createUser(data: {
|
|||||||
roleIds: number[];
|
roleIds: number[];
|
||||||
customerId?: 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({
|
const user = await prisma.user.create({
|
||||||
data: {
|
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.');
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -239,16 +239,6 @@ export async function createBackup(): Promise<BackupResult> {
|
|||||||
{ name: 'Address', query: () => prisma.address.findMany() },
|
{ name: 'Address', query: () => prisma.address.findMany() },
|
||||||
{ name: 'BankCard', query: () => prisma.bankCard.findMany() },
|
{ name: 'BankCard', query: () => prisma.bankCard.findMany() },
|
||||||
{ name: 'IdentityDocument', query: () => prisma.identityDocument.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;
|
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
|
// WICHTIG: Alle Tabellen vor dem Restore leeren, damit keine alten Daten übrig bleiben
|
||||||
console.log('[Restore] Lösche alle bestehenden Daten...');
|
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
|
// Detail-Tabellen
|
||||||
await prisma.carInsuranceDetails.deleteMany({});
|
await prisma.carInsuranceDetails.deleteMany({});
|
||||||
await prisma.tvContractDetails.deleteMany({});
|
await prisma.tvContractDetails.deleteMany({});
|
||||||
@@ -323,21 +309,12 @@ export async function restoreBackup(backupName: string): Promise<RestoreResult>
|
|||||||
await prisma.meterReading.deleteMany({});
|
await prisma.meterReading.deleteMany({});
|
||||||
await prisma.contractHistoryEntry.deleteMany({});
|
await prisma.contractHistoryEntry.deleteMany({});
|
||||||
|
|
||||||
// Neue Contract-bezogene Tabellen
|
|
||||||
await prisma.contractDocument.deleteMany({});
|
|
||||||
await prisma.contractMeter.deleteMany({});
|
|
||||||
|
|
||||||
// E-Mail & Verträge
|
// E-Mail & Verträge
|
||||||
await prisma.cachedEmail.deleteMany({});
|
await prisma.cachedEmail.deleteMany({});
|
||||||
await prisma.contractTaskSubtask.deleteMany({});
|
await prisma.contractTaskSubtask.deleteMany({});
|
||||||
await prisma.contractTask.deleteMany({});
|
await prisma.contractTask.deleteMany({});
|
||||||
await prisma.contract.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
|
// Kunden-bezogene Daten
|
||||||
await prisma.stressfreiEmail.deleteMany({});
|
await prisma.stressfreiEmail.deleteMany({});
|
||||||
await prisma.meter.deleteMany({});
|
await prisma.meter.deleteMany({});
|
||||||
@@ -351,8 +328,7 @@ export async function restoreBackup(backupName: string): Promise<RestoreResult>
|
|||||||
await prisma.user.deleteMany({});
|
await prisma.user.deleteMany({});
|
||||||
await prisma.customer.deleteMany({});
|
await prisma.customer.deleteMany({});
|
||||||
|
|
||||||
// Stammdaten & Kataloge
|
// Stammdaten
|
||||||
await prisma.pdfTemplate.deleteMany({});
|
|
||||||
await prisma.tariff.deleteMany({});
|
await prisma.tariff.deleteMany({});
|
||||||
await prisma.provider.deleteMany({});
|
await prisma.provider.deleteMany({});
|
||||||
await prisma.rolePermission.deleteMany({});
|
await prisma.rolePermission.deleteMany({});
|
||||||
@@ -364,7 +340,6 @@ export async function restoreBackup(backupName: string): Promise<RestoreResult>
|
|||||||
await prisma.contractCategory.deleteMany({});
|
await prisma.contractCategory.deleteMany({});
|
||||||
await prisma.emailProviderConfig.deleteMany({});
|
await prisma.emailProviderConfig.deleteMany({});
|
||||||
await prisma.appSetting.deleteMany({});
|
await prisma.appSetting.deleteMany({});
|
||||||
await prisma.auditRetentionPolicy.deleteMany({});
|
|
||||||
|
|
||||||
console.log('[Restore] Alle Daten gelöscht, starte Wiederherstellung...');
|
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;
|
let totalRestored = 0;
|
||||||
@@ -1007,36 +873,8 @@ export async function uploadBackupZip(zipBuffer: Buffer): Promise<BackupResult>
|
|||||||
|
|
||||||
const finalBackupName = path.basename(finalBackupDir);
|
const finalBackupName = path.basename(finalBackupDir);
|
||||||
|
|
||||||
// ZIP entpacken – mit Schutz gegen Zip-Slip (../../etc/passwd Angriff).
|
// ZIP extrahieren
|
||||||
// Jeder Eintragspfad muss innerhalb von finalBackupDir bleiben.
|
zip.extractAllTo(finalBackupDir, true);
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, backupName: finalBackupName };
|
return { success: true, backupName: finalBackupName };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@@ -140,9 +140,6 @@ export interface MyBirthdayCheck {
|
|||||||
isToday: boolean;
|
isToday: boolean;
|
||||||
daysAgo: number; // 0 = heute, >0 = x Tage her
|
daysAgo: number; // 0 = heute, >0 = x Tage her
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
|
||||||
salutation: string | null;
|
|
||||||
useInformalAddress: boolean;
|
|
||||||
age: number;
|
age: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,9 +153,6 @@ export async function checkMyBirthday(customerId: number): Promise<MyBirthdayChe
|
|||||||
where: { id: customerId },
|
where: { id: customerId },
|
||||||
select: {
|
select: {
|
||||||
firstName: true,
|
firstName: true,
|
||||||
lastName: true,
|
|
||||||
salutation: true,
|
|
||||||
useInformalAddress: true,
|
|
||||||
birthDate: true,
|
birthDate: true,
|
||||||
lastBirthdayGreetingYear: true,
|
lastBirthdayGreetingYear: true,
|
||||||
},
|
},
|
||||||
@@ -166,20 +160,13 @@ export async function checkMyBirthday(customerId: number): Promise<MyBirthdayChe
|
|||||||
|
|
||||||
if (!customer?.birthDate) return null;
|
if (!customer?.birthDate) return null;
|
||||||
|
|
||||||
const baseInfo = {
|
|
||||||
firstName: customer.firstName,
|
|
||||||
lastName: customer.lastName,
|
|
||||||
salutation: customer.salutation,
|
|
||||||
useInformalAddress: customer.useInformalAddress,
|
|
||||||
};
|
|
||||||
|
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
today.setHours(0, 0, 0, 0);
|
today.setHours(0, 0, 0, 0);
|
||||||
const thisYear = today.getFullYear();
|
const thisYear = today.getFullYear();
|
||||||
|
|
||||||
// Schon dieses Jahr angezeigt?
|
// Schon dieses Jahr angezeigt?
|
||||||
if (customer.lastBirthdayGreetingYear === thisYear) {
|
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());
|
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: 0–7)
|
// Nur wenn heute oder in den letzten 7 Tagen (diff: 0–7)
|
||||||
if (diff < 0 || diff > 7) {
|
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);
|
const age = calculateAge(customer.birthDate, today);
|
||||||
@@ -199,7 +186,7 @@ export async function checkMyBirthday(customerId: number): Promise<MyBirthdayChe
|
|||||||
show: true,
|
show: true,
|
||||||
isToday: diff === 0,
|
isToday: diff === 0,
|
||||||
daysAgo: diff,
|
daysAgo: diff,
|
||||||
...baseInfo,
|
firstName: customer.firstName,
|
||||||
age,
|
age,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -214,124 +201,3 @@ export async function acknowledgeBirthdayGreeting(customerId: number): Promise<v
|
|||||||
data: { lastBirthdayGreetingYear: thisYear },
|
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -136,7 +136,6 @@ export async function updateCustomer(
|
|||||||
data: {
|
data: {
|
||||||
type?: CustomerType;
|
type?: CustomerType;
|
||||||
salutation?: string;
|
salutation?: string;
|
||||||
useInformalAddress?: boolean;
|
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
companyName?: string;
|
companyName?: string;
|
||||||
@@ -149,8 +148,6 @@ export async function updateCustomer(
|
|||||||
businessRegistration?: string;
|
businessRegistration?: string;
|
||||||
commercialRegister?: string;
|
commercialRegister?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
autoBirthdayGreeting?: boolean;
|
|
||||||
autoBirthdayChannel?: string | null;
|
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
return prisma.customer.update({
|
return prisma.customer.update({
|
||||||
|
|||||||
@@ -74,30 +74,11 @@ export interface CreateProviderConfigData {
|
|||||||
// System-E-Mail
|
// System-E-Mail
|
||||||
systemEmailAddress?: string;
|
systemEmailAddress?: string;
|
||||||
systemEmailPassword?: string;
|
systemEmailPassword?: string;
|
||||||
// UI-Label für Kunden-E-Mail-Adressen (z.B. "Stressfrei-Wechseln", "Meine-Firma")
|
|
||||||
customerEmailLabel?: string;
|
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
isDefault?: 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) {
|
export async function createProviderConfig(data: CreateProviderConfigData) {
|
||||||
// Domain validieren
|
|
||||||
const validatedDomain = validateDomain(data.domain);
|
|
||||||
|
|
||||||
// Falls isDefault=true, alle anderen auf false setzen
|
// Falls isDefault=true, alle anderen auf false setzen
|
||||||
if (data.isDefault) {
|
if (data.isDefault) {
|
||||||
await prisma.emailProviderConfig.updateMany({
|
await prisma.emailProviderConfig.updateMany({
|
||||||
@@ -119,14 +100,13 @@ export async function createProviderConfig(data: CreateProviderConfigData) {
|
|||||||
apiKey: data.apiKey || null,
|
apiKey: data.apiKey || null,
|
||||||
username: data.username || null,
|
username: data.username || null,
|
||||||
passwordEncrypted,
|
passwordEncrypted,
|
||||||
domain: validatedDomain,
|
domain: data.domain,
|
||||||
defaultForwardEmail: data.defaultForwardEmail || null,
|
defaultForwardEmail: data.defaultForwardEmail || null,
|
||||||
imapEncryption: data.imapEncryption ?? 'SSL',
|
imapEncryption: data.imapEncryption ?? 'SSL',
|
||||||
smtpEncryption: data.smtpEncryption ?? 'SSL',
|
smtpEncryption: data.smtpEncryption ?? 'SSL',
|
||||||
allowSelfSignedCerts: data.allowSelfSignedCerts ?? false,
|
allowSelfSignedCerts: data.allowSelfSignedCerts ?? false,
|
||||||
systemEmailAddress: data.systemEmailAddress || null,
|
systemEmailAddress: data.systemEmailAddress || null,
|
||||||
systemEmailPasswordEncrypted,
|
systemEmailPasswordEncrypted,
|
||||||
customerEmailLabel: data.customerEmailLabel || null,
|
|
||||||
isActive: data.isActive ?? true,
|
isActive: data.isActive ?? true,
|
||||||
isDefault: data.isDefault ?? false,
|
isDefault: data.isDefault ?? false,
|
||||||
},
|
},
|
||||||
@@ -152,14 +132,13 @@ export async function updateProviderConfig(
|
|||||||
if (data.apiUrl !== undefined) updateData.apiUrl = data.apiUrl;
|
if (data.apiUrl !== undefined) updateData.apiUrl = data.apiUrl;
|
||||||
if (data.apiKey !== undefined) updateData.apiKey = data.apiKey || null;
|
if (data.apiKey !== undefined) updateData.apiKey = data.apiKey || null;
|
||||||
if (data.username !== undefined) updateData.username = data.username || 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)
|
if (data.defaultForwardEmail !== undefined)
|
||||||
updateData.defaultForwardEmail = data.defaultForwardEmail || null;
|
updateData.defaultForwardEmail = data.defaultForwardEmail || null;
|
||||||
if (data.imapEncryption !== undefined) updateData.imapEncryption = data.imapEncryption;
|
if (data.imapEncryption !== undefined) updateData.imapEncryption = data.imapEncryption;
|
||||||
if (data.smtpEncryption !== undefined) updateData.smtpEncryption = data.smtpEncryption;
|
if (data.smtpEncryption !== undefined) updateData.smtpEncryption = data.smtpEncryption;
|
||||||
if (data.allowSelfSignedCerts !== undefined) updateData.allowSelfSignedCerts = data.allowSelfSignedCerts;
|
if (data.allowSelfSignedCerts !== undefined) updateData.allowSelfSignedCerts = data.allowSelfSignedCerts;
|
||||||
if (data.systemEmailAddress !== undefined) updateData.systemEmailAddress = data.systemEmailAddress || null;
|
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.isActive !== undefined) updateData.isActive = data.isActive;
|
||||||
if (data.isDefault !== undefined) updateData.isDefault = data.isDefault;
|
if (data.isDefault !== undefined) updateData.isDefault = data.isDefault;
|
||||||
|
|
||||||
@@ -492,39 +471,6 @@ export async function getProviderDomain(): Promise<string | null> {
|
|||||||
return config?.domain || 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)
|
// Provider-Instanz aus übergebener Config erstellen (für Tests mit ungespeicherten Daten)
|
||||||
function createProviderFromFormData(data: {
|
function createProviderFromFormData(data: {
|
||||||
type: 'PLESK' | 'CPANEL' | 'DIRECTADMIN';
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,36 +14,6 @@ export interface ImapCredentials {
|
|||||||
password: string;
|
password: string;
|
||||||
encryption?: MailEncryption; // SSL, STARTTLS oder NONE (Standard: SSL)
|
encryption?: MailEncryption; // SSL, STARTTLS oder NONE (Standard: SSL)
|
||||||
allowSelfSignedCerts?: boolean; // Selbstsignierte Zertifikate erlauben
|
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 {
|
export interface FetchedEmail {
|
||||||
@@ -136,7 +106,7 @@ export async function fetchEmails(
|
|||||||
|
|
||||||
// TLS-Optionen nur wenn nicht NONE
|
// TLS-Optionen nur wenn nicht NONE
|
||||||
if (encryption !== 'NONE') {
|
if (encryption !== 'NONE') {
|
||||||
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
|
clientOptions.tls = { rejectUnauthorized };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debug-Logging
|
// Debug-Logging
|
||||||
@@ -290,7 +260,7 @@ export async function testImapConnection(credentials: ImapCredentials): Promise<
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (encryption !== 'NONE') {
|
if (encryption !== 'NONE') {
|
||||||
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
|
clientOptions.tls = { rejectUnauthorized };
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = new ImapFlow(clientOptions);
|
const client = new ImapFlow(clientOptions);
|
||||||
@@ -306,36 +276,9 @@ export async function testImapConnection(credentials: ImapCredentials): Promise<
|
|||||||
// Ignorieren
|
// 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) {
|
if (error instanceof Error) {
|
||||||
const msg = error.message.toLowerCase();
|
const msg = error.message.toLowerCase();
|
||||||
const errorCode = (error as NodeJS.ErrnoException).code?.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')) {
|
if (msg.includes('authentication') || msg.includes('login')) {
|
||||||
throw new Error('IMAP-Authentifizierung fehlgeschlagen');
|
throw new Error('IMAP-Authentifizierung fehlgeschlagen');
|
||||||
@@ -382,7 +325,7 @@ export async function getHighestUid(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (encryption !== 'NONE') {
|
if (encryption !== 'NONE') {
|
||||||
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
|
clientOptions.tls = { rejectUnauthorized };
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = new ImapFlow(clientOptions);
|
const client = new ImapFlow(clientOptions);
|
||||||
@@ -417,41 +360,6 @@ export async function fetchAttachment(
|
|||||||
uid: number,
|
uid: number,
|
||||||
attachmentFilename: string,
|
attachmentFilename: string,
|
||||||
folder: string = 'INBOX'
|
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> {
|
): Promise<EmailAttachmentData | null> {
|
||||||
// Verschlüsselungs-Einstellungen basierend auf Modus
|
// Verschlüsselungs-Einstellungen basierend auf Modus
|
||||||
const encryption = credentials.encryption ?? 'SSL';
|
const encryption = credentials.encryption ?? 'SSL';
|
||||||
@@ -466,48 +374,25 @@ async function fetchAttachmentInner(
|
|||||||
pass: credentials.password,
|
pass: credentials.password,
|
||||||
},
|
},
|
||||||
logger: false,
|
logger: false,
|
||||||
// Timeouts gegen hängende Verbindungen
|
|
||||||
socketTimeout: 30000,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (encryption !== 'NONE') {
|
if (encryption !== 'NONE') {
|
||||||
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
|
clientOptions.tls = { rejectUnauthorized };
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = new ImapFlow(clientOptions);
|
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 {
|
try {
|
||||||
await client.connect();
|
await client.connect();
|
||||||
|
|
||||||
// Ordner öffnen – bei Fehler alle verfügbaren Ordner listen für Debugging
|
|
||||||
try {
|
|
||||||
await client.mailboxOpen(folder);
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// E-Mail per UID abrufen
|
// E-Mail per UID abrufen
|
||||||
let attachment: EmailAttachmentData | null = null;
|
let attachment: EmailAttachmentData | null = null;
|
||||||
let foundMessage = false;
|
|
||||||
|
|
||||||
// Drittes Argument { uid: true } sorgt dafür, dass UID FETCH statt FETCH verwendet wird
|
// Drittes Argument { uid: true } sorgt dafür, dass UID FETCH statt FETCH verwendet wird
|
||||||
try {
|
|
||||||
for await (const message of client.fetch(uid.toString(), {
|
for await (const message of client.fetch(uid.toString(), {
|
||||||
source: true,
|
source: true,
|
||||||
}, { uid: true })) {
|
}, { uid: true })) {
|
||||||
foundMessage = true;
|
|
||||||
if (!message.source) continue;
|
if (!message.source) continue;
|
||||||
|
|
||||||
// E-Mail parsen
|
// E-Mail parsen
|
||||||
@@ -529,16 +414,6 @@ async function fetchAttachmentInner(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} 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();
|
await client.logout();
|
||||||
return attachment;
|
return attachment;
|
||||||
@@ -584,7 +459,7 @@ export async function appendToSent(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (encryption !== 'NONE') {
|
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...`);
|
console.log(`[IMAP] Appending email to ${sentFolder} folder...`);
|
||||||
@@ -670,7 +545,7 @@ export async function fetchAttachmentList(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (encryption !== 'NONE') {
|
if (encryption !== 'NONE') {
|
||||||
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
|
clientOptions.tls = { rejectUnauthorized };
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = new ImapFlow(clientOptions);
|
const client = new ImapFlow(clientOptions);
|
||||||
@@ -762,7 +637,7 @@ export async function moveToTrash(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (encryption !== 'NONE') {
|
if (encryption !== 'NONE') {
|
||||||
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
|
clientOptions.tls = { rejectUnauthorized };
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = new ImapFlow(clientOptions);
|
const client = new ImapFlow(clientOptions);
|
||||||
@@ -835,7 +710,7 @@ export async function restoreFromTrash(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (encryption !== 'NONE') {
|
if (encryption !== 'NONE') {
|
||||||
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
|
clientOptions.tls = { rejectUnauthorized };
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = new ImapFlow(clientOptions);
|
const client = new ImapFlow(clientOptions);
|
||||||
@@ -906,7 +781,7 @@ export async function permanentDelete(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (encryption !== 'NONE') {
|
if (encryption !== 'NONE') {
|
||||||
const __tls = buildTlsOptions(credentials); if (__tls) clientOptions.tls = __tls as any;
|
clientOptions.tls = { rejectUnauthorized };
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = new ImapFlow(clientOptions);
|
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(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -15,10 +15,6 @@ export interface SmtpCredentials {
|
|||||||
password: string;
|
password: string;
|
||||||
encryption?: MailEncryption; // SSL, STARTTLS oder NONE (Standard: SSL)
|
encryption?: MailEncryption; // SSL, STARTTLS oder NONE (Standard: SSL)
|
||||||
allowSelfSignedCerts?: boolean; // Selbstsignierte Zertifikate erlauben
|
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
|
// Anhang-Interface
|
||||||
@@ -53,16 +49,6 @@ export interface EmailLogContext {
|
|||||||
triggeredBy?: string; // User-Email
|
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
|
// E-Mail senden
|
||||||
export async function sendEmail(
|
export async function sendEmail(
|
||||||
credentials: SmtpCredentials,
|
credentials: SmtpCredentials,
|
||||||
@@ -70,21 +56,6 @@ export async function sendEmail(
|
|||||||
params: SendEmailParams,
|
params: SendEmailParams,
|
||||||
logContext?: EmailLogContext
|
logContext?: EmailLogContext
|
||||||
): Promise<SendEmailResult> {
|
): 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
|
// Verschlüsselungs-Einstellungen basierend auf Modus
|
||||||
const encryption = credentials.encryption ?? 'SSL';
|
const encryption = credentials.encryption ?? 'SSL';
|
||||||
const rejectUnauthorized = !credentials.allowSelfSignedCerts;
|
const rejectUnauthorized = !credentials.allowSelfSignedCerts;
|
||||||
@@ -98,7 +69,7 @@ export async function sendEmail(
|
|||||||
port: number;
|
port: number;
|
||||||
secure: boolean;
|
secure: boolean;
|
||||||
auth: { user: string; pass: string };
|
auth: { user: string; pass: string };
|
||||||
tls?: { rejectUnauthorized: boolean; minVersion?: string; ciphers?: string; servername?: string };
|
tls?: { rejectUnauthorized: boolean };
|
||||||
ignoreTLS?: boolean;
|
ignoreTLS?: boolean;
|
||||||
requireTLS?: boolean;
|
requireTLS?: boolean;
|
||||||
connectionTimeout: number;
|
connectionTimeout: number;
|
||||||
@@ -120,16 +91,6 @@ export async function sendEmail(
|
|||||||
// TLS-Optionen nur wenn nicht NONE
|
// TLS-Optionen nur wenn nicht NONE
|
||||||
if (encryption !== 'NONE') {
|
if (encryption !== 'NONE') {
|
||||||
transportOptions.tls = { rejectUnauthorized };
|
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 {
|
} else {
|
||||||
// Keine Verschlüsselung: STARTTLS ignorieren
|
// Keine Verschlüsselung: STARTTLS ignorieren
|
||||||
transportOptions.ignoreTLS = true;
|
transportOptions.ignoreTLS = true;
|
||||||
@@ -312,7 +273,7 @@ export async function testSmtpConnection(credentials: SmtpCredentials): Promise<
|
|||||||
port: number;
|
port: number;
|
||||||
secure: boolean;
|
secure: boolean;
|
||||||
auth: { user: string; pass: string };
|
auth: { user: string; pass: string };
|
||||||
tls?: { rejectUnauthorized: boolean; minVersion?: string; ciphers?: string; servername?: string };
|
tls?: { rejectUnauthorized: boolean };
|
||||||
ignoreTLS?: boolean;
|
ignoreTLS?: boolean;
|
||||||
connectionTimeout: number;
|
connectionTimeout: number;
|
||||||
greetingTimeout: number;
|
greetingTimeout: number;
|
||||||
@@ -330,9 +291,6 @@ export async function testSmtpConnection(credentials: SmtpCredentials): Promise<
|
|||||||
|
|
||||||
if (encryption !== 'NONE') {
|
if (encryption !== 'NONE') {
|
||||||
transportOptions.tls = { rejectUnauthorized };
|
transportOptions.tls = { rejectUnauthorized };
|
||||||
if (credentials.servername) {
|
|
||||||
transportOptions.tls.servername = credentials.servername;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
transportOptions.ignoreTLS = true;
|
transportOptions.ignoreTLS = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
@@ -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.
|
|
||||||
@@ -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
@@ -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
@@ -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 1–3: 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)
|
|
||||||
Generated
-28
@@ -14,7 +14,6 @@
|
|||||||
"@tiptap/react": "^3.19.0",
|
"@tiptap/react": "^3.19.0",
|
||||||
"@tiptap/starter-kit": "^3.19.0",
|
"@tiptap/starter-kit": "^3.19.0",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"dompurify": "^3.4.1",
|
|
||||||
"lucide-react": "^0.454.0",
|
"lucide-react": "^0.454.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
@@ -23,7 +22,6 @@
|
|||||||
"react-router-dom": "^6.28.0"
|
"react-router-dom": "^6.28.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/dompurify": "^3.0.5",
|
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^4.3.3",
|
"@vitejs/plugin-react": "^4.3.3",
|
||||||
@@ -1597,16 +1595,6 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@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": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -1654,13 +1642,6 @@
|
|||||||
"@types/react": "^18.0.0"
|
"@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": {
|
"node_modules/@types/use-sync-external-store": {
|
||||||
"version": "0.0.6",
|
"version": "0.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
"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==",
|
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "opencrm-frontend",
|
"name": "opencrm-frontend",
|
||||||
"version": "1.1.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -14,7 +14,6 @@
|
|||||||
"@tiptap/react": "^3.19.0",
|
"@tiptap/react": "^3.19.0",
|
||||||
"@tiptap/starter-kit": "^3.19.0",
|
"@tiptap/starter-kit": "^3.19.0",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"dompurify": "^3.4.1",
|
|
||||||
"lucide-react": "^0.454.0",
|
"lucide-react": "^0.454.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
@@ -23,7 +22,6 @@
|
|||||||
"react-router-dom": "^6.28.0"
|
"react-router-dom": "^6.28.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/dompurify": "^3.0.5",
|
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^4.3.3",
|
"@vitejs/plugin-react": "^4.3.3",
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import { Shield } from 'lucide-react';
|
|||||||
import ScrollToTop from './components/ScrollToTop';
|
import ScrollToTop from './components/ScrollToTop';
|
||||||
import Layout from './components/layout/Layout';
|
import Layout from './components/layout/Layout';
|
||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import PasswordResetRequest from './pages/PasswordResetRequest';
|
|
||||||
import PasswordResetConfirm from './pages/PasswordResetConfirm';
|
|
||||||
import Dashboard from './pages/Dashboard';
|
import Dashboard from './pages/Dashboard';
|
||||||
import CustomerList from './pages/customers/CustomerList';
|
import CustomerList from './pages/customers/CustomerList';
|
||||||
import CustomerDetail from './pages/customers/CustomerDetail';
|
import CustomerDetail from './pages/customers/CustomerDetail';
|
||||||
@@ -27,10 +25,8 @@ import PortalSettings from './pages/settings/PortalSettings';
|
|||||||
import DeadlineSettings from './pages/settings/DeadlineSettings';
|
import DeadlineSettings from './pages/settings/DeadlineSettings';
|
||||||
import EmailProviders from './pages/settings/EmailProviders';
|
import EmailProviders from './pages/settings/EmailProviders';
|
||||||
import DatabaseBackup from './pages/settings/DatabaseBackup';
|
import DatabaseBackup from './pages/settings/DatabaseBackup';
|
||||||
import FactoryDefaults from './pages/settings/FactoryDefaults';
|
|
||||||
import AuditLogs from './pages/settings/AuditLogs';
|
import AuditLogs from './pages/settings/AuditLogs';
|
||||||
import EmailLogPage from './pages/settings/EmailLogs';
|
import EmailLogPage from './pages/settings/EmailLogs';
|
||||||
import Monitoring from './pages/settings/Monitoring';
|
|
||||||
import GDPRDashboard from './pages/settings/GDPRDashboard';
|
import GDPRDashboard from './pages/settings/GDPRDashboard';
|
||||||
import UserList from './pages/users/UserList';
|
import UserList from './pages/users/UserList';
|
||||||
import Settings from './pages/Settings';
|
import Settings from './pages/Settings';
|
||||||
@@ -149,10 +145,6 @@ function App() {
|
|||||||
element={isAuthenticated ? <Navigate to="/" replace /> : <Login />}
|
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
|
<Route
|
||||||
path="/"
|
path="/"
|
||||||
element={
|
element={
|
||||||
@@ -200,10 +192,8 @@ function App() {
|
|||||||
<Route path="settings/deadlines" element={<DeadlineSettings />} />
|
<Route path="settings/deadlines" element={<DeadlineSettings />} />
|
||||||
<Route path="settings/email-providers" element={<EmailProviders />} />
|
<Route path="settings/email-providers" element={<EmailProviders />} />
|
||||||
<Route path="settings/database-backup" element={<DatabaseBackup />} />
|
<Route path="settings/database-backup" element={<DatabaseBackup />} />
|
||||||
<Route path="settings/factory-defaults" element={<FactoryDefaults />} />
|
|
||||||
<Route path="settings/audit-logs" element={<AuditLogs />} />
|
<Route path="settings/audit-logs" element={<AuditLogs />} />
|
||||||
<Route path="settings/email-logs" element={<EmailLogPage />} />
|
<Route path="settings/email-logs" element={<EmailLogPage />} />
|
||||||
<Route path="settings/monitoring" element={<Monitoring />} />
|
|
||||||
<Route path="settings/gdpr" element={<GDPRDashboard />} />
|
<Route path="settings/gdpr" element={<GDPRDashboard />} />
|
||||||
<Route path="settings/privacy-policy" element={<PrivacyPolicyEditor />} />
|
<Route path="settings/privacy-policy" element={<PrivacyPolicyEditor />} />
|
||||||
<Route path="settings/authorization-template" element={<AuthorizationTemplateEditor />} />
|
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -27,16 +27,6 @@ export default function BirthdayModal() {
|
|||||||
|
|
||||||
const handleClose = () => ackMutation.mutate();
|
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 (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4">
|
<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">
|
<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 ? (
|
{info.isToday ? (
|
||||||
<>
|
<>
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||||
Herzlichen Glückwunsch, {greetingName}!
|
Herzlichen Glückwunsch, {info.firstName}!
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-600 mb-1">
|
<p className="text-gray-600 mb-1">
|
||||||
Alles Gute zu {greetingPossessive} {info.age}. Geburtstag!
|
Alles Gute zu Ihrem {info.age}. Geburtstag!
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-500 text-sm mb-6">
|
<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>
|
</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||||
Nachträglich alles Gute, {greetingName}!
|
Nachträglich alles Gute, {info.firstName}!
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-600 mb-1">
|
<p className="text-gray-600 mb-1">
|
||||||
{greetingYour === 'Ihr' ? 'Sie' : 'Du'} {hadBirthday} vor {info.daysAgo} Tag{info.daysAgo === 1 ? '' : 'en'} Geburtstag
|
Sie hatten vor {info.daysAgo} Tag{info.daysAgo === 1 ? '' : 'en'} Geburtstag
|
||||||
{info.age > 0 && ` und ${becameOld} ${info.age} Jahre alt geworden`}.
|
{info.age > 0 && ` und sind ${info.age} Jahre alt geworden`}.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-500 text-sm mb-6">
|
<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>
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import Select from '../ui/Select';
|
|||||||
import Badge from '../ui/Badge';
|
import Badge from '../ui/Badge';
|
||||||
import { invoiceApi } from '../../services/api';
|
import { invoiceApi } from '../../services/api';
|
||||||
import type { Invoice, InvoiceType } from '../../types';
|
import type { Invoice, InvoiceType } from '../../types';
|
||||||
import { fileUrl } from '../../utils/fileUrl';
|
|
||||||
|
|
||||||
const invoiceTypeLabels: Record<InvoiceType, string> = {
|
const invoiceTypeLabels: Record<InvoiceType, string> = {
|
||||||
INTERIM: 'Zwischenrechnung',
|
INTERIM: 'Zwischenrechnung',
|
||||||
@@ -121,7 +120,7 @@ export default function InvoicesSection({
|
|||||||
{invoice.documentPath && (
|
{invoice.documentPath && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<a
|
<a
|
||||||
href={fileUrl(invoice.documentPath)}
|
href={`/api${invoice.documentPath}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-1 text-blue-600 hover:text-blue-800 text-sm"
|
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" />
|
<Eye className="w-4 h-4" />
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={fileUrl(invoice.documentPath)}
|
href={`/api${invoice.documentPath}`}
|
||||||
download
|
download
|
||||||
className="flex items-center gap-1 text-blue-600 hover:text-blue-800 text-sm"
|
className="flex items-center gap-1 text-blue-600 hover:text-blue-800 text-sm"
|
||||||
title="Download"
|
title="Download"
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { Send, Paperclip, X, FileText } from 'lucide-react';
|
import { Send, Paperclip, X, FileText } from 'lucide-react';
|
||||||
import toast from 'react-hot-toast';
|
|
||||||
import Modal from '../ui/Modal';
|
import Modal from '../ui/Modal';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
import { stressfreiEmailApi, CachedEmail, MailboxAccount, EmailAttachment } from '../../services/api';
|
import { stressfreiEmailApi, CachedEmail, MailboxAccount, EmailAttachment } from '../../services/api';
|
||||||
@@ -151,24 +150,12 @@ export default function ComposeEmailModal({
|
|||||||
attachments: attachments.length > 0 ? attachments : undefined,
|
attachments: attachments.length > 0 ? attachments : undefined,
|
||||||
contractId,
|
contractId,
|
||||||
}),
|
}),
|
||||||
onSuccess: (result) => {
|
onSuccess: () => {
|
||||||
// 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();
|
handleClose();
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err) => {
|
||||||
const msg =
|
setError(err instanceof Error ? err.message : 'Fehler beim Senden');
|
||||||
err?.response?.data?.error ||
|
|
||||||
(err instanceof Error ? err.message : 'Fehler beim Senden');
|
|
||||||
setError(msg);
|
|
||||||
toast.error(`SMTP-Fehler: ${msg}`, { duration: 8000 });
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { RefreshCw, Plus, Mail, Inbox, Send, Trash2 } from 'lucide-react';
|
import { RefreshCw, Plus, Mail, Inbox, Send, Trash2 } from 'lucide-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import toast from 'react-hot-toast';
|
|
||||||
import { cachedEmailApi, stressfreiEmailApi, CachedEmail } from '../../services/api';
|
import { cachedEmailApi, stressfreiEmailApi, CachedEmail } from '../../services/api';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import { useProviderSettings } from '../../hooks/useProviderSettings';
|
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
import EmailList from './EmailList';
|
import EmailList from './EmailList';
|
||||||
import EmailDetail from './EmailDetail';
|
import EmailDetail from './EmailDetail';
|
||||||
@@ -19,7 +17,6 @@ interface EmailClientTabProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function EmailClientTab({ customerId }: EmailClientTabProps) {
|
export default function EmailClientTab({ customerId }: EmailClientTabProps) {
|
||||||
const { customerEmailLabel } = useProviderSettings();
|
|
||||||
const [selectedAccountId, setSelectedAccountId] = useState<number | null>(null);
|
const [selectedAccountId, setSelectedAccountId] = useState<number | null>(null);
|
||||||
const [selectedFolder, setSelectedFolder] = useState<EmailFolder>('INBOX');
|
const [selectedFolder, setSelectedFolder] = useState<EmailFolder>('INBOX');
|
||||||
const [selectedEmail, setSelectedEmail] = useState<CachedEmail | null>(null);
|
const [selectedEmail, setSelectedEmail] = useState<CachedEmail | null>(null);
|
||||||
@@ -98,14 +95,7 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
|
|||||||
// Synchronisation
|
// Synchronisation
|
||||||
const syncMutation = useMutation({
|
const syncMutation = useMutation({
|
||||||
mutationFn: (accountId: number) => stressfreiEmailApi.syncEmails(accountId),
|
mutationFn: (accountId: number) => stressfreiEmailApi.syncEmails(accountId),
|
||||||
onSuccess: (result) => {
|
onSuccess: () => {
|
||||||
// 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');
|
|
||||||
// E-Mail-Listen neu laden
|
// E-Mail-Listen neu laden
|
||||||
queryClient.invalidateQueries({ queryKey: ['emails'] });
|
queryClient.invalidateQueries({ queryKey: ['emails'] });
|
||||||
// Ordner-Anzahlen aktualisieren
|
// Ordner-Anzahlen aktualisieren
|
||||||
@@ -113,10 +103,6 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
|
|||||||
// Mailbox-Accounts aktualisieren
|
// Mailbox-Accounts aktualisieren
|
||||||
queryClient.invalidateQueries({ queryKey: ['mailbox-accounts', customerId] });
|
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 = () => {
|
const handleSync = () => {
|
||||||
@@ -152,7 +138,7 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
|
|||||||
Keine E-Mail-Konten vorhanden
|
Keine E-Mail-Konten vorhanden
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-center max-w-md">
|
<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.
|
um E-Mails hier empfangen und versenden zu können.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Reply, Star, Paperclip, Link2, X, Download, ExternalLink, Trash2, Undo2, Save, FileDown } from 'lucide-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 { CachedEmail, cachedEmailApi } from '../../services/api';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
@@ -385,16 +384,7 @@ export default function EmailDetail({
|
|||||||
{showHtml && email.htmlBody ? (
|
{showHtml && email.htmlBody ? (
|
||||||
<div
|
<div
|
||||||
className="prose prose-sm max-w-none"
|
className="prose prose-sm max-w-none"
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{ __html: email.htmlBody }}
|
||||||
__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'],
|
|
||||||
}),
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<pre className="whitespace-pre-wrap text-sm text-gray-700 font-sans">
|
<pre className="whitespace-pre-wrap text-sm text-gray-700 font-sans">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
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 Modal from '../ui/Modal';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
import Input from '../ui/Input';
|
import Input from '../ui/Input';
|
||||||
@@ -9,17 +9,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import type { InvoiceType } from '../../types';
|
import type { InvoiceType } from '../../types';
|
||||||
|
|
||||||
const CONTRACT_DOCUMENT_TYPES = [
|
|
||||||
'Auftragsformular',
|
|
||||||
'Auftragsbestätigung',
|
|
||||||
'Lieferbestätigung',
|
|
||||||
'Vertragsunterlagen',
|
|
||||||
'Vollmacht',
|
|
||||||
'Widerrufsbelehrung',
|
|
||||||
'Preisblatt',
|
|
||||||
'Sonstiges',
|
|
||||||
];
|
|
||||||
|
|
||||||
interface SaveAttachmentModalProps {
|
interface SaveAttachmentModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -36,7 +25,7 @@ type SelectedTarget = {
|
|||||||
label: string;
|
label: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SaveMode = 'document' | 'invoice' | 'contractDocument';
|
type SaveMode = 'document' | 'invoice';
|
||||||
|
|
||||||
export default function SaveAttachmentModal({
|
export default function SaveAttachmentModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
@@ -53,11 +42,6 @@ export default function SaveAttachmentModal({
|
|||||||
invoiceType: 'INTERIM' as InvoiceType,
|
invoiceType: 'INTERIM' as InvoiceType,
|
||||||
notes: '',
|
notes: '',
|
||||||
});
|
});
|
||||||
const [contractDocumentData, setContractDocumentData] = useState({
|
|
||||||
documentType: CONTRACT_DOCUMENT_TYPES[0],
|
|
||||||
notes: '',
|
|
||||||
deliveryDate: new Date().toISOString().split('T')[0],
|
|
||||||
});
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Ziele laden
|
// Ziele laden
|
||||||
@@ -69,8 +53,8 @@ export default function SaveAttachmentModal({
|
|||||||
|
|
||||||
const targets = targetsData?.data;
|
const targets = targetsData?.data;
|
||||||
|
|
||||||
// Vertrag zugeordnet? → dann Rechnung + Vertragsdokument möglich
|
// Prüfen ob es ein Energievertrag ist
|
||||||
const hasContract = !!targets?.contract;
|
const isEnergyContract = targets?.contract?.type === 'ELECTRICITY' || targets?.contract?.type === 'GAS';
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: () => {
|
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 = () => {
|
const handleClose = () => {
|
||||||
setSelectedTarget(null);
|
setSelectedTarget(null);
|
||||||
setSaveMode('document');
|
setSaveMode('document');
|
||||||
@@ -164,11 +121,6 @@ export default function SaveAttachmentModal({
|
|||||||
invoiceType: 'INTERIM',
|
invoiceType: 'INTERIM',
|
||||||
notes: '',
|
notes: '',
|
||||||
});
|
});
|
||||||
setContractDocumentData({
|
|
||||||
documentType: CONTRACT_DOCUMENT_TYPES[0],
|
|
||||||
notes: '',
|
|
||||||
deliveryDate: new Date().toISOString().split('T')[0],
|
|
||||||
});
|
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -311,12 +263,12 @@ export default function SaveAttachmentModal({
|
|||||||
|
|
||||||
{targets && (
|
{targets && (
|
||||||
<>
|
<>
|
||||||
{/* Mode Toggle (nur wenn ein Vertrag zugeordnet ist) */}
|
{/* Mode Toggle für Energieverträge */}
|
||||||
{hasContract && (
|
{isEnergyContract && (
|
||||||
<div className="flex gap-2 p-1 bg-gray-100 rounded-lg">
|
<div className="flex gap-2 p-1 bg-gray-100 rounded-lg">
|
||||||
<button
|
<button
|
||||||
onClick={() => setSaveMode('document')}
|
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'
|
saveMode === 'document'
|
||||||
? 'bg-white text-blue-600 shadow-sm'
|
? 'bg-white text-blue-600 shadow-sm'
|
||||||
: 'text-gray-600 hover:text-gray-900'
|
: 'text-gray-600 hover:text-gray-900'
|
||||||
@@ -325,20 +277,9 @@ export default function SaveAttachmentModal({
|
|||||||
<FileText className="w-4 h-4" />
|
<FileText className="w-4 h-4" />
|
||||||
Als Dokument
|
Als Dokument
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => setSaveMode('invoice')}
|
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'
|
saveMode === 'invoice'
|
||||||
? 'bg-white text-green-600 shadow-sm'
|
? 'bg-white text-green-600 shadow-sm'
|
||||||
: 'text-gray-600 hover:text-gray-900'
|
: 'text-gray-600 hover:text-gray-900'
|
||||||
@@ -402,7 +343,7 @@ export default function SaveAttachmentModal({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Invoice Mode */}
|
{/* Invoice Mode */}
|
||||||
{saveMode === 'invoice' && hasContract && (
|
{saveMode === 'invoice' && isEnergyContract && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="p-3 bg-green-50 rounded-lg">
|
<div className="p-3 bg-green-50 rounded-lg">
|
||||||
<p className="text-sm text-green-700">
|
<p className="text-sm text-green-700">
|
||||||
@@ -436,52 +377,6 @@ export default function SaveAttachmentModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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}>
|
<Button variant="secondary" onClick={handleClose}>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</Button>
|
</Button>
|
||||||
{saveMode === 'document' && (
|
{saveMode === 'document' ? (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => saveMutation.mutate()}
|
onClick={() => saveMutation.mutate()}
|
||||||
disabled={
|
disabled={!selectedTarget || saveMutation.isPending || saveInvoiceMutation.isPending}
|
||||||
!selectedTarget ||
|
|
||||||
saveMutation.isPending ||
|
|
||||||
saveInvoiceMutation.isPending ||
|
|
||||||
saveContractDocumentMutation.isPending
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{saveMutation.isPending ? 'Wird gespeichert...' : 'Speichern'}
|
{saveMutation.isPending ? 'Wird gespeichert...' : 'Speichern'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
) : (
|
||||||
{saveMode === 'invoice' && (
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => saveInvoiceMutation.mutate()}
|
onClick={() => saveInvoiceMutation.mutate()}
|
||||||
disabled={
|
disabled={!invoiceData.invoiceDate || saveMutation.isPending || saveInvoiceMutation.isPending}
|
||||||
!invoiceData.invoiceDate ||
|
|
||||||
saveMutation.isPending ||
|
|
||||||
saveInvoiceMutation.isPending ||
|
|
||||||
saveContractDocumentMutation.isPending
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{saveInvoiceMutation.isPending ? 'Wird gespeichert...' : 'Als Rechnung speichern'}
|
{saveInvoiceMutation.isPending ? 'Wird gespeichert...' : 'Als Rechnung speichern'}
|
||||||
</Button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -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,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import Button from '../components/ui/Button';
|
import Button from '../components/ui/Button';
|
||||||
import Input from '../components/ui/Input';
|
import Input from '../components/ui/Input';
|
||||||
@@ -73,15 +73,6 @@ export default function Login() {
|
|||||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||||
{isLoading ? 'Anmeldung...' : 'Anmelden'}
|
{isLoading ? 'Anmeldung...' : 'Anmelden'}
|
||||||
</Button>
|
</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>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import Card from '../components/ui/Card';
|
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() {
|
export default function Settings() {
|
||||||
const { hasPermission, developerMode, setDeveloperMode } = useAuth();
|
const { hasPermission, developerMode, setDeveloperMode } = useAuth();
|
||||||
@@ -156,7 +156,7 @@ export default function Settings() {
|
|||||||
Email-Provisionierung
|
Email-Provisionierung
|
||||||
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
|
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
</h3>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -177,23 +177,6 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</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') && (
|
{hasPermission('audit:read') && (
|
||||||
<Link
|
<Link
|
||||||
to="/settings/audit-logs"
|
to="/settings/audit-logs"
|
||||||
@@ -238,27 +221,6 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</Link>
|
</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') && (
|
{hasPermission('gdpr:admin') && (
|
||||||
<Link
|
<Link
|
||||||
to="/settings/gdpr"
|
to="/settings/gdpr"
|
||||||
|
|||||||
@@ -17,9 +17,7 @@ import { Edit, Trash2, Copy, Eye, EyeOff, ArrowLeft, ArrowRight, Download, Exter
|
|||||||
import { calculateConsumption, calculateCosts, calculateMultiMeterConsumption } from '../../utils/energyCalculations';
|
import { calculateConsumption, calculateCosts, calculateMultiMeterConsumption } from '../../utils/energyCalculations';
|
||||||
import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
|
import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
|
||||||
import { formatDate } from '../../utils/dateFormat';
|
import { formatDate } from '../../utils/dateFormat';
|
||||||
import { useProviderSettings } from '../../hooks/useProviderSettings';
|
|
||||||
import type { ContractType, ContractStatus, SimCard, MeterReading, ContractTask, ContractTaskSubtask, ContractMeter, ContractDocument } from '../../types';
|
import type { ContractType, ContractStatus, SimCard, MeterReading, ContractTask, ContractTaskSubtask, ContractMeter, ContractDocument } from '../../types';
|
||||||
import { fileUrl } from '../../utils/fileUrl';
|
|
||||||
|
|
||||||
const typeLabels: Record<ContractType, string> = {
|
const typeLabels: Record<ContractType, string> = {
|
||||||
ELECTRICITY: 'Strom',
|
ELECTRICITY: 'Strom',
|
||||||
@@ -1446,7 +1444,6 @@ export default function ContractDetail() {
|
|||||||
const currentPath = `/contracts/${id}`;
|
const currentPath = `/contracts/${id}`;
|
||||||
const { hasPermission, isCustomer, isCustomerPortal } = useAuth();
|
const { hasPermission, isCustomer, isCustomerPortal } = useAuth();
|
||||||
const contractId = parseInt(id!);
|
const contractId = parseInt(id!);
|
||||||
const { customerEmailLabel } = useProviderSettings();
|
|
||||||
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [decryptedPassword, setDecryptedPassword] = useState<string | null>(null);
|
const [decryptedPassword, setDecryptedPassword] = useState<string | null>(null);
|
||||||
@@ -1472,12 +1469,6 @@ export default function ContractDetail() {
|
|||||||
// Un-Snooze Bestätigungsmodal
|
// Un-Snooze Bestätigungsmodal
|
||||||
const [showUnsnoozeConfirm, setShowUnsnoozeConfirm] = useState(false);
|
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({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['contract', id],
|
queryKey: ['contract', id],
|
||||||
queryFn: () => contractApi.getById(contractId),
|
queryFn: () => contractApi.getById(contractId),
|
||||||
@@ -2035,7 +2026,7 @@ export default function ContractDetail() {
|
|||||||
{c.cancellationLetterPath ? (
|
{c.cancellationLetterPath ? (
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<a
|
<a
|
||||||
href={fileUrl(c.cancellationLetterPath)}
|
href={`/api${c.cancellationLetterPath}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
||||||
@@ -2044,7 +2035,7 @@ export default function ContractDetail() {
|
|||||||
Anzeigen
|
Anzeigen
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={fileUrl(c.cancellationLetterPath)}
|
href={`/api${c.cancellationLetterPath}`}
|
||||||
download
|
download
|
||||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
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">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<a
|
<a
|
||||||
href={fileUrl(c.cancellationConfirmationPath)}
|
href={`/api${c.cancellationConfirmationPath}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
||||||
@@ -2101,7 +2092,7 @@ export default function ContractDetail() {
|
|||||||
Anzeigen
|
Anzeigen
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={fileUrl(c.cancellationConfirmationPath)}
|
href={`/api${c.cancellationConfirmationPath}`}
|
||||||
download
|
download
|
||||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
||||||
>
|
>
|
||||||
@@ -2110,13 +2101,8 @@ export default function ContractDetail() {
|
|||||||
</a>
|
</a>
|
||||||
<FileUpload
|
<FileUpload
|
||||||
onUpload={async (file) => {
|
onUpload={async (file) => {
|
||||||
// Datei puffern, Datums-Modal öffnen
|
await uploadApi.uploadCancellationConfirmation(contractId, file);
|
||||||
setCancelConfirmDate(
|
queryClient.invalidateQueries({ queryKey: ['contract', id] });
|
||||||
c.cancellationConfirmationDate
|
|
||||||
? c.cancellationConfirmationDate.split('T')[0]
|
|
||||||
: new Date().toISOString().split('T')[0]
|
|
||||||
);
|
|
||||||
setPendingCancelFile(file);
|
|
||||||
}}
|
}}
|
||||||
existingFile={c.cancellationConfirmationPath}
|
existingFile={c.cancellationConfirmationPath}
|
||||||
accept=".pdf"
|
accept=".pdf"
|
||||||
@@ -2163,8 +2149,8 @@ export default function ContractDetail() {
|
|||||||
) : (
|
) : (
|
||||||
<FileUpload
|
<FileUpload
|
||||||
onUpload={async (file) => {
|
onUpload={async (file) => {
|
||||||
setCancelConfirmDate(new Date().toISOString().split('T')[0]);
|
await uploadApi.uploadCancellationConfirmation(contractId, file);
|
||||||
setPendingCancelFile(file);
|
queryClient.invalidateQueries({ queryKey: ['contract', id] });
|
||||||
}}
|
}}
|
||||||
accept=".pdf"
|
accept=".pdf"
|
||||||
label="PDF hochladen"
|
label="PDF hochladen"
|
||||||
@@ -2178,7 +2164,7 @@ export default function ContractDetail() {
|
|||||||
{c.cancellationLetterOptionsPath ? (
|
{c.cancellationLetterOptionsPath ? (
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<a
|
<a
|
||||||
href={fileUrl(c.cancellationLetterOptionsPath)}
|
href={`/api${c.cancellationLetterOptionsPath}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
||||||
@@ -2187,7 +2173,7 @@ export default function ContractDetail() {
|
|||||||
Anzeigen
|
Anzeigen
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={fileUrl(c.cancellationLetterOptionsPath)}
|
href={`/api${c.cancellationLetterOptionsPath}`}
|
||||||
download
|
download
|
||||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
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">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<a
|
<a
|
||||||
href={fileUrl(c.cancellationConfirmationOptionsPath)}
|
href={`/api${c.cancellationConfirmationOptionsPath}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
||||||
@@ -2244,7 +2230,7 @@ export default function ContractDetail() {
|
|||||||
Anzeigen
|
Anzeigen
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={fileUrl(c.cancellationConfirmationOptionsPath)}
|
href={`/api${c.cancellationConfirmationOptionsPath}`}
|
||||||
download
|
download
|
||||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
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">
|
<dt className="text-sm text-gray-500">
|
||||||
Benutzername
|
Benutzername
|
||||||
{c.stressfreiEmail && (
|
{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>
|
</dt>
|
||||||
<dd className="font-mono flex items-center gap-1">
|
<dd className="font-mono flex items-center gap-1">
|
||||||
@@ -3080,52 +3066,6 @@ export default function ContractDetail() {
|
|||||||
{/* Status-Info Modal */}
|
{/* Status-Info Modal */}
|
||||||
<StatusInfoModal isOpen={showStatusInfo} onClose={() => setShowStatusInfo(false)} />
|
<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 */}
|
{/* Un-Snooze Bestätigungsmodal */}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={showUnsnoozeConfirm}
|
isOpen={showUnsnoozeConfirm}
|
||||||
@@ -3181,9 +3121,6 @@ function ContractDocumentsSection({
|
|||||||
const [showUpload, setShowUpload] = useState(false);
|
const [showUpload, setShowUpload] = useState(false);
|
||||||
const [uploadType, setUploadType] = useState(DOCUMENT_TYPES[0]);
|
const [uploadType, setUploadType] = useState(DOCUMENT_TYPES[0]);
|
||||||
const [uploadNotes, setUploadNotes] = useState('');
|
const [uploadNotes, setUploadNotes] = useState('');
|
||||||
const [uploadDeliveryDate, setUploadDeliveryDate] = useState<string>(
|
|
||||||
() => new Date().toISOString().split('T')[0],
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: docsData } = useQuery({
|
const { data: docsData } = useQuery({
|
||||||
queryKey: ['contract-documents', contractId],
|
queryKey: ['contract-documents', contractId],
|
||||||
@@ -3191,12 +3128,10 @@ function ContractDocumentsSection({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const uploadMutation = useMutation({
|
const uploadMutation = useMutation({
|
||||||
mutationFn: ({ file, documentType, notes, deliveryDate }: { file: File; documentType: string; notes?: string; deliveryDate?: string }) =>
|
mutationFn: ({ file, documentType, notes }: { file: File; documentType: string; notes?: string }) =>
|
||||||
contractApi.uploadDocument(contractId, file, documentType, notes, deliveryDate),
|
contractApi.uploadDocument(contractId, file, documentType, notes),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['contract-documents', contractId] });
|
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);
|
setShowUpload(false);
|
||||||
setUploadNotes('');
|
setUploadNotes('');
|
||||||
},
|
},
|
||||||
@@ -3211,17 +3146,10 @@ function ContractDocumentsSection({
|
|||||||
|
|
||||||
const documents: ContractDocument[] = docsData?.data || [];
|
const documents: ContractDocument[] = docsData?.data || [];
|
||||||
|
|
||||||
const isDelivery = uploadType.trim().toLowerCase() === 'lieferbestätigung';
|
|
||||||
|
|
||||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
uploadMutation.mutate({
|
uploadMutation.mutate({ file, documentType: uploadType, notes: uploadNotes || undefined });
|
||||||
file,
|
|
||||||
documentType: uploadType,
|
|
||||||
notes: uploadNotes || undefined,
|
|
||||||
deliveryDate: isDelivery ? uploadDeliveryDate : undefined,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3267,20 +3195,6 @@ function ContractDocumentsSection({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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">
|
<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" />
|
<Plus className="w-4 h-4" />
|
||||||
@@ -3311,7 +3225,7 @@ function ContractDocumentsSection({
|
|||||||
{doc.documentType}
|
{doc.documentType}
|
||||||
</span>
|
</span>
|
||||||
<a
|
<a
|
||||||
href={fileUrl(doc.documentPath)}
|
href={`/api${doc.documentPath}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-sm text-blue-600 hover:underline"
|
className="text-sm text-blue-600 hover:underline"
|
||||||
@@ -3328,7 +3242,7 @@ function ContractDocumentsSection({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<a
|
<a
|
||||||
href={fileUrl(doc.documentPath)}
|
href={`/api${doc.documentPath}`}
|
||||||
download
|
download
|
||||||
className="text-gray-400 hover:text-blue-600"
|
className="text-gray-400 hover:text-blue-600"
|
||||||
title="Herunterladen"
|
title="Herunterladen"
|
||||||
@@ -3449,7 +3363,6 @@ function GenerateInputModal({ templateId, templateName, contractId, onClose }: {
|
|||||||
const [stressfreiEmailId, setStressfreiEmailId] = useState('');
|
const [stressfreiEmailId, setStressfreiEmailId] = useState('');
|
||||||
const [manualValues, setManualValues] = useState<Record<string, string>>({});
|
const [manualValues, setManualValues] = useState<Record<string, string>>({});
|
||||||
const [generating] = useState(false);
|
const [generating] = useState(false);
|
||||||
const { customerEmailLabel } = useProviderSettings();
|
|
||||||
|
|
||||||
const { data: inputsData, isLoading } = useQuery({
|
const { data: inputsData, isLoading } = useQuery({
|
||||||
queryKey: ['pdf-inputs', templateId, contractId],
|
queryKey: ['pdf-inputs', templateId, contractId],
|
||||||
@@ -3477,7 +3390,7 @@ function GenerateInputModal({ templateId, templateName, contractId, onClose }: {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{inputs?.needsStressfreiEmail && (
|
{inputs?.needsStressfreiEmail && (
|
||||||
<div>
|
<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
|
<select
|
||||||
value={stressfreiEmailId}
|
value={stressfreiEmailId}
|
||||||
onChange={(e) => setStressfreiEmailId(e.target.value)}
|
onChange={(e) => setStressfreiEmailId(e.target.value)}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import Input from '../../components/ui/Input';
|
|||||||
import Select from '../../components/ui/Select';
|
import Select from '../../components/ui/Select';
|
||||||
import type { ContractType } from '../../types';
|
import type { ContractType } from '../../types';
|
||||||
import { formatDate } from '../../utils/dateFormat';
|
import { formatDate } from '../../utils/dateFormat';
|
||||||
import { useProviderSettings } from '../../hooks/useProviderSettings';
|
|
||||||
import { Plus, Trash2, Eye, EyeOff, Info, X, ArrowLeft } from 'lucide-react';
|
import { Plus, Trash2, Eye, EyeOff, Info, X, ArrowLeft } from 'lucide-react';
|
||||||
|
|
||||||
// Contract types are now loaded dynamically from the database
|
// Contract types are now loaded dynamically from the database
|
||||||
@@ -68,7 +67,6 @@ export default function ContractForm() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isEdit = !!id;
|
const isEdit = !!id;
|
||||||
const back = popHistory(location.state, isEdit ? `/contracts/${id}` : '/contracts');
|
const back = popHistory(location.state, isEdit ? `/contracts/${id}` : '/contracts');
|
||||||
const { customerEmailLabel } = useProviderSettings();
|
|
||||||
|
|
||||||
const preselectedCustomerId = searchParams.get('customerId');
|
const preselectedCustomerId = searchParams.get('customerId');
|
||||||
|
|
||||||
@@ -916,7 +914,7 @@ export default function ContractForm() {
|
|||||||
}}
|
}}
|
||||||
className="text-blue-600"
|
className="text-blue-600"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm">{customerEmailLabel} Adresse</span>
|
<span className="text-sm">Stressfrei-Wechseln Adresse</span>
|
||||||
</label>
|
</label>
|
||||||
{usernameType === 'stressfrei' && (
|
{usernameType === 'stressfrei' && (
|
||||||
<Select
|
<Select
|
||||||
@@ -931,7 +929,7 @@ export default function ContractForm() {
|
|||||||
)}
|
)}
|
||||||
{usernameType === 'stressfrei' && stressfreiEmails.length === 0 && (
|
{usernameType === 'stressfrei' && stressfreiEmails.length === 0 && (
|
||||||
<p className="text-xs text-amber-600">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,14 +13,11 @@ import Modal from '../../components/ui/Modal';
|
|||||||
import Input from '../../components/ui/Input';
|
import Input from '../../components/ui/Input';
|
||||||
import Select from '../../components/ui/Select';
|
import Select from '../../components/ui/Select';
|
||||||
import FileUpload from '../../components/ui/FileUpload';
|
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 CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
|
||||||
import BirthdayManagementModal from '../../components/BirthdayManagementModal';
|
|
||||||
import { formatDate } from '../../utils/dateFormat';
|
import { formatDate } from '../../utils/dateFormat';
|
||||||
import { getContractTypeInfo } from '../../utils/contractInfo';
|
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 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 } = {}) {
|
export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?: number } = {}) {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
@@ -33,7 +30,6 @@ export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?
|
|||||||
const customerId = portalCustomerId || parseInt(id!);
|
const customerId = portalCustomerId || parseInt(id!);
|
||||||
const defaultTab = searchParams.get('tab') || 'addresses';
|
const defaultTab = searchParams.get('tab') || 'addresses';
|
||||||
const [activeTab, setActiveTab] = useState(defaultTab);
|
const [activeTab, setActiveTab] = useState(defaultTab);
|
||||||
const { customerEmailLabel } = useProviderSettings();
|
|
||||||
|
|
||||||
// Tab-Wechsel in URL synchronisieren (für Browser-History)
|
// Tab-Wechsel in URL synchronisieren (für Browser-History)
|
||||||
const handleTabChange = (tabId: string) => {
|
const handleTabChange = (tabId: string) => {
|
||||||
@@ -46,7 +42,6 @@ export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?
|
|||||||
const [showDocumentModal, setShowDocumentModal] = useState(false);
|
const [showDocumentModal, setShowDocumentModal] = useState(false);
|
||||||
const [showMeterModal, setShowMeterModal] = useState(false);
|
const [showMeterModal, setShowMeterModal] = useState(false);
|
||||||
const [showStressfreiEmailModal, setShowStressfreiEmailModal] = useState(false);
|
const [showStressfreiEmailModal, setShowStressfreiEmailModal] = useState(false);
|
||||||
const [showBirthdayModal, setShowBirthdayModal] = useState(false);
|
|
||||||
const [showInactive, setShowInactive] = useState(false);
|
const [showInactive, setShowInactive] = useState(false);
|
||||||
const [editingBankCard, setEditingBankCard] = useState<BankCard | null>(null);
|
const [editingBankCard, setEditingBankCard] = useState<BankCard | null>(null);
|
||||||
const [editingDocument, setEditingDocument] = useState<IdentityDocument | null>(null);
|
const [editingDocument, setEditingDocument] = useState<IdentityDocument | null>(null);
|
||||||
@@ -151,7 +146,7 @@ export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?
|
|||||||
},
|
},
|
||||||
...(!isCustomerPortal ? [{
|
...(!isCustomerPortal ? [{
|
||||||
id: 'stressfrei',
|
id: 'stressfrei',
|
||||||
label: customerEmailLabel,
|
label: 'Stressfrei-Wechseln',
|
||||||
content: (
|
content: (
|
||||||
<StressfreiEmailsTab
|
<StressfreiEmailsTab
|
||||||
customerId={customerId}
|
customerId={customerId}
|
||||||
@@ -272,14 +267,6 @@ export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<dt className="text-sm text-gray-500">Vorname</dt>
|
<dt className="text-sm text-gray-500">Vorname</dt>
|
||||||
<dd className="flex items-center gap-1">
|
<dd className="flex items-center gap-1">
|
||||||
@@ -318,16 +305,6 @@ export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?
|
|||||||
<dd className="flex items-center gap-1">
|
<dd className="flex items-center gap-1">
|
||||||
{new Date(c.birthDate).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })}
|
{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' })} />
|
<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>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -466,13 +443,6 @@ export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?
|
|||||||
email={editingStressfreiEmail}
|
email={editingStressfreiEmail}
|
||||||
customerEmail={customer?.data?.email}
|
customerEmail={customer?.data?.email}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showBirthdayModal && c && (
|
|
||||||
<BirthdayManagementModal
|
|
||||||
customer={c}
|
|
||||||
onClose={() => setShowBirthdayModal(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -565,7 +535,7 @@ function BusinessDataCard({
|
|||||||
{customer.businessRegistrationPath ? (
|
{customer.businessRegistrationPath ? (
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<a
|
<a
|
||||||
href={fileUrl(customer.businessRegistrationPath)}
|
href={`/api${customer.businessRegistrationPath}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
||||||
@@ -574,7 +544,7 @@ function BusinessDataCard({
|
|||||||
Anzeigen
|
Anzeigen
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={fileUrl(customer.businessRegistrationPath)}
|
href={`/api${customer.businessRegistrationPath}`}
|
||||||
download
|
download
|
||||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
||||||
>
|
>
|
||||||
@@ -616,7 +586,7 @@ function BusinessDataCard({
|
|||||||
{customer.commercialRegisterPath ? (
|
{customer.commercialRegisterPath ? (
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<a
|
<a
|
||||||
href={fileUrl(customer.commercialRegisterPath)}
|
href={`/api${customer.commercialRegisterPath}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
||||||
@@ -625,7 +595,7 @@ function BusinessDataCard({
|
|||||||
Anzeigen
|
Anzeigen
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={fileUrl(customer.commercialRegisterPath)}
|
href={`/api${customer.commercialRegisterPath}`}
|
||||||
download
|
download
|
||||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
||||||
>
|
>
|
||||||
@@ -936,7 +906,7 @@ function BankCardsTab({
|
|||||||
{card.documentPath ? (
|
{card.documentPath ? (
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<a
|
<a
|
||||||
href={fileUrl(card.documentPath)}
|
href={`/api${card.documentPath}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
||||||
@@ -945,7 +915,7 @@ function BankCardsTab({
|
|||||||
Anzeigen
|
Anzeigen
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={fileUrl(card.documentPath)}
|
href={`/api${card.documentPath}`}
|
||||||
download
|
download
|
||||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
||||||
>
|
>
|
||||||
@@ -1172,7 +1142,7 @@ function DocumentsTab({
|
|||||||
{doc.documentPath ? (
|
{doc.documentPath ? (
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<a
|
<a
|
||||||
href={fileUrl(doc.documentPath)}
|
href={`/api${doc.documentPath}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
||||||
@@ -1181,7 +1151,7 @@ function DocumentsTab({
|
|||||||
Anzeigen
|
Anzeigen
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={fileUrl(doc.documentPath)}
|
href={`/api${doc.documentPath}`}
|
||||||
download
|
download
|
||||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
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({
|
function StressfreiEmailsTab({
|
||||||
customerId,
|
customerId,
|
||||||
@@ -2951,7 +2923,6 @@ function StressfreiEmailsTab({
|
|||||||
onEdit: (email: StressfreiEmail) => void;
|
onEdit: (email: StressfreiEmail) => void;
|
||||||
}) {
|
}) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { customerEmailLabel } = useProviderSettings();
|
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: ({ id, data }: { id: number; data: Partial<StressfreiEmail> }) =>
|
mutationFn: ({ id, data }: { id: number; data: Partial<StressfreiEmail> }) =>
|
||||||
@@ -3069,7 +3040,7 @@ function StressfreiEmailsTab({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-500">Keine {customerEmailLabel} Adressen vorhanden.</p>
|
<p className="text-gray-500">Keine Stressfrei-Wechseln Adressen vorhanden.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -3242,10 +3213,6 @@ function StressfreiEmailModal({
|
|||||||
const [provisionError, setProvisionError] = useState<string | null>(null);
|
const [provisionError, setProvisionError] = useState<string | null>(null);
|
||||||
const [providerStatus, setProviderStatus] = useState<'idle' | 'checking' | 'exists' | 'not_exists' | 'error'>('idle');
|
const [providerStatus, setProviderStatus] = useState<'idle' | 'checking' | 'exists' | 'not_exists' | 'error'>('idle');
|
||||||
const [isProvisioning, setIsProvisioning] = useState(false);
|
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 [isEnablingMailbox, setIsEnablingMailbox] = useState(false);
|
||||||
const [mailboxEnabled, setMailboxEnabled] = useState(false);
|
const [mailboxEnabled, setMailboxEnabled] = useState(false);
|
||||||
const [showCredentials, setShowCredentials] = useState(false);
|
const [showCredentials, setShowCredentials] = useState(false);
|
||||||
@@ -3452,7 +3419,7 @@ function StressfreiEmailModal({
|
|||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setProvisionError(null);
|
setProvisionError(null);
|
||||||
const fullEmail = localPart + domainSuffix;
|
const fullEmail = localPart + STRESSFREI_DOMAIN;
|
||||||
|
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
updateMutation.mutate({
|
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"
|
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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -3926,7 +3893,7 @@ function ConsentTab({
|
|||||||
{customer.privacyPolicyPath ? (
|
{customer.privacyPolicyPath ? (
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<a
|
<a
|
||||||
href={fileUrl(customer.privacyPolicyPath)}
|
href={`/api${customer.privacyPolicyPath}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
||||||
@@ -3935,7 +3902,7 @@ function ConsentTab({
|
|||||||
Anzeigen
|
Anzeigen
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={fileUrl(customer.privacyPolicyPath)}
|
href={`/api${customer.privacyPolicyPath}`}
|
||||||
download
|
download
|
||||||
className="text-blue-600 hover:underline text-sm flex items-center gap-1"
|
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 ? (
|
{auth.documentPath ? (
|
||||||
<>
|
<>
|
||||||
<a
|
<a
|
||||||
href={fileUrl(auth.documentPath)}
|
href={`/api${auth.documentPath}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline text-xs flex items-center gap-1"
|
className="text-blue-600 hover:underline text-xs flex items-center gap-1"
|
||||||
|
|||||||
@@ -39,8 +39,6 @@ export default function CustomerForm() {
|
|||||||
if (data.foundingDate) {
|
if (data.foundingDate) {
|
||||||
data.foundingDate = data.foundingDate.split('T')[0] as any;
|
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);
|
reset(data);
|
||||||
}
|
}
|
||||||
}, [customer, reset]);
|
}, [customer, reset]);
|
||||||
@@ -67,9 +65,6 @@ export default function CustomerForm() {
|
|||||||
const submitData: any = {
|
const submitData: any = {
|
||||||
type: data.type,
|
type: data.type,
|
||||||
salutation: data.salutation || '',
|
salutation: data.salutation || '',
|
||||||
useInformalAddress:
|
|
||||||
data.useInformalAddress === true ||
|
|
||||||
(data.useInformalAddress as any) === 'true',
|
|
||||||
firstName: data.firstName,
|
firstName: data.firstName,
|
||||||
lastName: data.lastName,
|
lastName: data.lastName,
|
||||||
companyName: data.companyName || '',
|
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
|
<Input
|
||||||
label="Vorname"
|
label="Vorname"
|
||||||
{...register('firstName', { required: 'Vorname erforderlich' })}
|
{...register('firstName', { required: 'Vorname erforderlich' })}
|
||||||
@@ -180,6 +164,8 @@ export default function CustomerForm() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{customerType !== 'BUSINESS' && (
|
||||||
|
<>
|
||||||
<Input
|
<Input
|
||||||
label="Geburtsdatum"
|
label="Geburtsdatum"
|
||||||
type="date"
|
type="date"
|
||||||
@@ -192,6 +178,8 @@ export default function CustomerForm() {
|
|||||||
label="Geburtsort"
|
label="Geburtsort"
|
||||||
{...register('birthPlace')}
|
{...register('birthPlace')}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,6 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { gdprApi } from '../../services/api';
|
import { gdprApi } from '../../services/api';
|
||||||
import Card from '../../components/ui/Card';
|
import Card from '../../components/ui/Card';
|
||||||
import { Building } from 'lucide-react';
|
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() {
|
export default function PortalImprint() {
|
||||||
const { data, isLoading, isError } = useQuery({
|
const { data, isLoading, isError } = useQuery({
|
||||||
@@ -28,7 +22,7 @@ export default function PortalImprint() {
|
|||||||
<h1 className="text-2xl font-bold">Impressum</h1>
|
<h1 className="text-2xl font-bold">Impressum</h1>
|
||||||
</div>
|
</div>
|
||||||
<Card>
|
<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>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,12 +11,6 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import Card from '../../components/ui/Card';
|
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 }> = {
|
const CONSENT_TYPE_LABELS: Record<ConsentType, { label: string; description: string }> = {
|
||||||
DATA_PROCESSING: {
|
DATA_PROCESSING: {
|
||||||
@@ -184,7 +178,7 @@ export default function PortalPrivacy() {
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="prose prose-sm max-w-none"
|
className="prose prose-sm max-w-none"
|
||||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(privacyPolicyHtml, SANITIZE_OPTIONS) }}
|
dangerouslySetInnerHTML={{ __html: privacyPolicyHtml }}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,6 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { gdprApi } from '../../services/api';
|
import { gdprApi } from '../../services/api';
|
||||||
import Card from '../../components/ui/Card';
|
import Card from '../../components/ui/Card';
|
||||||
import { Shield } from 'lucide-react';
|
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() {
|
export default function PortalWebsitePrivacy() {
|
||||||
const { data, isLoading, isError } = useQuery({
|
const { data, isLoading, isError } = useQuery({
|
||||||
@@ -28,7 +22,7 @@ export default function PortalWebsitePrivacy() {
|
|||||||
<h1 className="text-2xl font-bold">Datenschutzerklärung</h1>
|
<h1 className="text-2xl font-bold">Datenschutzerklärung</h1>
|
||||||
</div>
|
</div>
|
||||||
<Card>
|
<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>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,12 +4,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { publicApi } from '../../services/api';
|
import { publicApi } from '../../services/api';
|
||||||
import { formatDate } from '../../utils/dateFormat';
|
import { formatDate } from '../../utils/dateFormat';
|
||||||
import { Shield, CheckCircle2, FileDown, Loader2 } from 'lucide-react';
|
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() {
|
export default function ConsentPage() {
|
||||||
const { hash } = useParams<{ hash: string }>();
|
const { hash } = useParams<{ hash: string }>();
|
||||||
@@ -156,7 +150,7 @@ export default function ConsentPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="p-6 prose prose-sm max-w-none"
|
className="p-6 prose prose-sm max-w-none"
|
||||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(privacyPolicyHtml, SANITIZE_OPTIONS) }}
|
dangerouslySetInnerHTML={{ __html: privacyPolicyHtml }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,6 @@ const CONTEXT_LABELS: Record<string, string> = {
|
|||||||
'consent-link': 'Datenschutz-Link',
|
'consent-link': 'Datenschutz-Link',
|
||||||
'authorization-request': 'Vollmacht-Anfrage',
|
'authorization-request': 'Vollmacht-Anfrage',
|
||||||
'customer-email': 'Kunden-E-Mail',
|
'customer-email': 'Kunden-E-Mail',
|
||||||
'birthday-greeting': 'Geburtstagsgruß (manuell)',
|
|
||||||
'birthday-greeting-auto': 'Geburtstagsgruß (automatisch)',
|
|
||||||
'password-reset': 'Passwort-Reset',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function EmailLogs() {
|
export default function EmailLogs() {
|
||||||
|
|||||||
@@ -80,13 +80,6 @@ export default function EmailProviders() {
|
|||||||
// Test-Status pro Provider in der Liste
|
// Test-Status pro Provider in der Liste
|
||||||
const [providerTestResults, setProviderTestResults] = useState<Record<number, TestResult | null>>({});
|
const [providerTestResults, setProviderTestResults] = useState<Record<number, TestResult | null>>({});
|
||||||
const [testingProviderId, setTestingProviderId] = useState<number | null>(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({
|
const { data: configsData, isLoading } = useQuery({
|
||||||
queryKey: ['email-provider-configs'],
|
queryKey: ['email-provider-configs'],
|
||||||
@@ -98,7 +91,6 @@ export default function EmailProviders() {
|
|||||||
emailProviderApi.createConfig(data),
|
emailProviderApi.createConfig(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['email-provider-configs'] });
|
queryClient.invalidateQueries({ queryKey: ['email-provider-configs'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['email-provider-public-settings'] });
|
|
||||||
closeModal();
|
closeModal();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -108,7 +100,6 @@ export default function EmailProviders() {
|
|||||||
emailProviderApi.updateConfig(id, data),
|
emailProviderApi.updateConfig(id, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['email-provider-configs'] });
|
queryClient.invalidateQueries({ queryKey: ['email-provider-configs'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['email-provider-public-settings'] });
|
|
||||||
closeModal();
|
closeModal();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -117,7 +108,6 @@ export default function EmailProviders() {
|
|||||||
mutationFn: (id: number) => emailProviderApi.deleteConfig(id),
|
mutationFn: (id: number) => emailProviderApi.deleteConfig(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['email-provider-configs'] });
|
queryClient.invalidateQueries({ queryKey: ['email-provider-configs'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['email-provider-public-settings'] });
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -161,7 +151,6 @@ export default function EmailProviders() {
|
|||||||
setFormData(emptyForm);
|
setFormData(emptyForm);
|
||||||
setShowPassword(false);
|
setShowPassword(false);
|
||||||
setModalTestResult(null);
|
setModalTestResult(null);
|
||||||
setMailAccessResult(null);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Test für einen gespeicherten Provider in der Liste
|
// 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) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -339,9 +277,9 @@ export default function EmailProviders() {
|
|||||||
|
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
<p className="text-gray-600 mb-4">
|
<p className="text-gray-600 mb-4">
|
||||||
Hier konfigurieren Sie die automatische Erstellung von Kunden-E-Mail-Adressen auf Ihrer
|
Hier konfigurieren Sie die automatische Erstellung von Stressfrei-Wechseln E-Mail-Adressen.
|
||||||
eigenen Domain. Wenn beim Anlegen einer Adresse die Option "Bei Provider anlegen"
|
Wenn beim Anlegen einer Stressfrei-Adresse die Option "Bei Provider anlegen" aktiviert ist,
|
||||||
aktiviert ist, wird die E-Mail-Weiterleitung automatisch erstellt.
|
wird die E-Mail-Weiterleitung automatisch erstellt.
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={openCreateModal}>
|
<Button onClick={openCreateModal}>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
@@ -545,32 +483,13 @@ export default function EmailProviders() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<Input
|
<Input
|
||||||
label="Domain *"
|
label="Domain *"
|
||||||
value={formData.domain}
|
value={formData.domain}
|
||||||
onChange={(e) =>
|
onChange={(e) => setFormData({ ...formData, domain: e.target.value })}
|
||||||
setFormData({ ...formData, domain: e.target.value.toLowerCase().trim() })
|
|
||||||
}
|
|
||||||
placeholder="stressfrei-wechseln.de"
|
placeholder="stressfrei-wechseln.de"
|
||||||
required
|
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
|
<Input
|
||||||
label="Standard-Weiterleitungsadresse"
|
label="Standard-Weiterleitungsadresse"
|
||||||
@@ -740,89 +659,6 @@ export default function EmailProviders() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 pt-4 border-t">
|
<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 Button from '../../components/ui/Button';
|
||||||
import Select from '../../components/ui/Select';
|
import Select from '../../components/ui/Select';
|
||||||
import { ArrowLeft, FileText, Users, CheckCircle, Clock, XCircle, AlertTriangle, Download, X, ChevronRight } from 'lucide-react';
|
import { ArrowLeft, FileText, Users, CheckCircle, Clock, XCircle, AlertTriangle, Download, X, ChevronRight } from 'lucide-react';
|
||||||
import { fileUrl } from '../../utils/fileUrl';
|
|
||||||
|
|
||||||
const STATUS_OPTIONS = [
|
const STATUS_OPTIONS = [
|
||||||
{ value: '', label: 'Alle Status' },
|
{ value: '', label: 'Alle Status' },
|
||||||
@@ -363,7 +362,7 @@ export default function GDPRDashboard() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => window.open(fileUrl(`/uploads/${request.proofDocument}`), '_blank')}
|
onClick={() => window.open(`/api/uploads/${request.proofDocument}`, '_blank')}
|
||||||
title="Löschnachweis anzeigen"
|
title="Löschnachweis anzeigen"
|
||||||
>
|
>
|
||||||
<FileText className="w-4 h-4 text-blue-500" />
|
<FileText className="w-4 h-4 text-blue-500" />
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ import Input from '../../components/ui/Input';
|
|||||||
import Badge from '../../components/ui/Badge';
|
import Badge from '../../components/ui/Badge';
|
||||||
import Modal from '../../components/ui/Modal';
|
import Modal from '../../components/ui/Modal';
|
||||||
import { ArrowLeft, Plus, Edit, Trash2, FileText, Upload, Link2, Eye, Play } from 'lucide-react';
|
import { ArrowLeft, Plus, Edit, Trash2, FileText, Upload, Link2, Eye, Play } from 'lucide-react';
|
||||||
import { fileUrl } from '../../utils/fileUrl';
|
|
||||||
|
|
||||||
export default function PdfTemplates() {
|
export default function PdfTemplates() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -96,7 +95,7 @@ export default function PdfTemplates() {
|
|||||||
<Button variant="ghost" size="sm" onClick={() => setTestTemplate(t)} title="Testvorschau mit Vertragsdaten">
|
<Button variant="ghost" size="sm" onClick={() => setTestTemplate(t)} title="Testvorschau mit Vertragsdaten">
|
||||||
<Play className="w-4 h-4 text-green-500" />
|
<Play className="w-4 h-4 text-green-500" />
|
||||||
</Button>
|
</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">
|
<Button variant="ghost" size="sm" title="Leere Vorlage anzeigen">
|
||||||
<Eye className="w-4 h-4" />
|
<Eye className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -585,18 +585,6 @@ export const cachedEmailApi = {
|
|||||||
);
|
);
|
||||||
return res.data;
|
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
|
// Contracts - Vertragsbaum für Kundenansicht
|
||||||
@@ -683,12 +671,11 @@ export const contractApi = {
|
|||||||
const res = await api.get<ApiResponse<import('../types').ContractDocument[]>>(`/contracts/${contractId}/documents`);
|
const res = await api.get<ApiResponse<import('../types').ContractDocument[]>>(`/contracts/${contractId}/documents`);
|
||||||
return res.data;
|
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();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
formData.append('documentType', documentType);
|
formData.append('documentType', documentType);
|
||||||
if (notes) formData.append('notes', notes);
|
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, {
|
const res = await api.post<ApiResponse<import('../types').ContractDocument>>(`/contracts/${contractId}/documents`, formData, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
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`);
|
const res = await api.delete<ApiResponse<void>>(`/upload/contracts/${contractId}/cancellation-letter`);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
uploadCancellationConfirmation: async (contractId: number, file: File, confirmationDate?: string) => {
|
uploadCancellationConfirmation: async (contractId: number, file: File) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('document', file);
|
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, {
|
const res = await api.post<ApiResponse<{ path: string; filename: string }>>(`/upload/contracts/${contractId}/cancellation-confirmation`, formData, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
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`);
|
const res = await api.delete<ApiResponse<void>>(`/upload/contracts/${contractId}/cancellation-letter-options`);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
uploadCancellationConfirmationOptions: async (contractId: number, file: File, confirmationDate?: string) => {
|
uploadCancellationConfirmationOptions: async (contractId: number, file: File) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('document', file);
|
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, {
|
const res = await api.post<ApiResponse<{ path: string; filename: string }>>(`/upload/contracts/${contractId}/cancellation-confirmation-options`, formData, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
});
|
});
|
||||||
@@ -1203,8 +1188,6 @@ export interface EmailProviderConfig {
|
|||||||
// System-E-Mail für automatisierten Versand
|
// System-E-Mail für automatisierten Versand
|
||||||
systemEmailAddress?: string;
|
systemEmailAddress?: string;
|
||||||
systemEmailPasswordEncrypted?: string;
|
systemEmailPasswordEncrypted?: string;
|
||||||
// UI-Label für Kunden-E-Mail-Adressen (z.B. "Stressfrei-Wechseln")
|
|
||||||
customerEmailLabel?: string | null;
|
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -1259,35 +1242,10 @@ export const emailProviderApi = {
|
|||||||
const res = await api.post<ApiResponse<EmailOperationResult>>('/email-providers/test-connection', body);
|
const res = await api.post<ApiResponse<EmailOperationResult>>('/email-providers/test-connection', body);
|
||||||
return res.data;
|
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 () => {
|
getDomain: async () => {
|
||||||
const res = await api.get<ApiResponse<{ domain: string | null }>>('/email-providers/domain');
|
const res = await api.get<ApiResponse<{ domain: string | null }>>('/email-providers/domain');
|
||||||
return res.data;
|
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) => {
|
checkEmailExists: async (localPart: string) => {
|
||||||
const res = await api.get<ApiResponse<{ exists: boolean; email?: string }>>(`/email-providers/check/${localPart}`);
|
const res = await api.get<ApiResponse<{ exists: boolean; email?: string }>>(`/email-providers/check/${localPart}`);
|
||||||
return res.data;
|
return res.data;
|
||||||
@@ -1426,76 +1384,6 @@ export interface EmailLog {
|
|||||||
sentAt: string;
|
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 = {
|
export const emailLogApi = {
|
||||||
getLogs: async (params?: { page?: number; limit?: number; success?: string; search?: string; context?: string }) => {
|
getLogs: async (params?: { page?: number; limit?: number; success?: string; search?: string; context?: string }) => {
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
@@ -1711,9 +1599,6 @@ export interface MyBirthdayCheck {
|
|||||||
isToday: boolean;
|
isToday: boolean;
|
||||||
daysAgo: number;
|
daysAgo: number;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
|
||||||
salutation: string | null;
|
|
||||||
useInformalAddress: boolean;
|
|
||||||
age: number;
|
age: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1732,17 +1617,6 @@ export const birthdayApi = {
|
|||||||
const res = await api.post<ApiResponse<void>>('/birthdays/my-birthday/acknowledge');
|
const res = await api.post<ApiResponse<void>>('/birthdays/my-birthday/acknowledge');
|
||||||
return res.data;
|
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;
|
export default api;
|
||||||
|
|||||||
@@ -62,9 +62,6 @@ export interface Customer {
|
|||||||
customerNumber: string;
|
customerNumber: string;
|
||||||
type: 'PRIVATE' | 'BUSINESS';
|
type: 'PRIVATE' | 'BUSINESS';
|
||||||
salutation?: string;
|
salutation?: string;
|
||||||
useInformalAddress?: boolean;
|
|
||||||
autoBirthdayGreeting?: boolean;
|
|
||||||
autoBirthdayChannel?: string | null;
|
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
companyName?: string;
|
companyName?: string;
|
||||||
|
|||||||
@@ -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)}`;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user