Compare commits
109 Commits
92c3b0dc95
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b23ebeefc3 | |||
| 818f801939 | |||
| 386d206ff1 | |||
| 67d6fd4941 | |||
| 1680dcb0fe | |||
| a4895374b9 | |||
| ebaee024b6 | |||
| f1b05c56e5 | |||
| 5293af18a5 | |||
| 4ab0340473 | |||
| f1102a24b7 | |||
| c013e1e747 | |||
| caa283e66f | |||
| 18a2e1173b | |||
| 993f2d10f0 | |||
| f02824fe7d | |||
| 8b10316683 | |||
| 26959ec909 | |||
| c8b86ca9a7 | |||
| 0b7bb89ebc | |||
| 9274c0adaf | |||
| dfe2a4b241 | |||
| 194c86409f | |||
| 2becf6cb6a | |||
| 24e152b201 | |||
| a83358bbe6 | |||
| 5bb048c534 | |||
| b3469483ca | |||
| 8992bb7a5d | |||
| 246999be01 | |||
| 96a054aa1a | |||
| 36beac98c9 | |||
| 60851450f6 | |||
| 523eab30d5 | |||
| 2fee13d09e | |||
| 84cbf01706 | |||
| fcc3b04725 | |||
| 101369c205 | |||
| e792fe4185 | |||
| 7c18343a95 | |||
| 5508d59652 | |||
| 431792e8d9 | |||
| d5dd3f5e7f | |||
| a235c43f40 | |||
| 9cfd2e4a64 | |||
| ec577e6d76 | |||
| 30f528596c | |||
| 358688db9e | |||
| ffb0d81b6a | |||
| 25681075b4 | |||
| ad81a7c93e | |||
| 518139438e | |||
| 5fa9d4d4f3 | |||
| f4ac1c29db | |||
| 6b1d493f0b | |||
| 9482424ade | |||
| a023e96012 | |||
| 72de2f00f3 | |||
| da1934aa2d | |||
| 0f4ffe3c32 | |||
| 71d3ea7a2e | |||
| c3321a2aa9 | |||
| 61daff8df9 | |||
| 57eb29c2a6 | |||
| 5d21574c81 | |||
| 2c0166ed99 | |||
| d0d2715baa | |||
| c58a60db23 | |||
| 9519f0dbca | |||
| cd7075e96f | |||
| 4acfd9de1c | |||
| 9e3bce85f0 | |||
| 4fb700cf57 | |||
| 5269092d2a | |||
| 83f1984f12 | |||
| b9a6d99d50 | |||
| a20e331f83 | |||
| 43aaf697a1 | |||
| b0e45c0ea0 | |||
| 95b7261227 | |||
| 0d024b94c2 | |||
| b4b0dbb004 | |||
| 2ee06630b9 | |||
| 3a9cece929 | |||
| e527aebb84 | |||
| 13213846f4 | |||
| 61ce35821d | |||
| 34e106f253 | |||
| ad4c2bae1d | |||
| 6f378d750c | |||
| c099b41796 | |||
| d92d0b1eaf | |||
| a3fef8891a | |||
| 617022e492 | |||
| c93d4375ab | |||
| 0bd2f9be7e | |||
| 6a670df1c4 | |||
| 7dcdf9d6ef | |||
| 100147107c | |||
| 2d4e4cdcc7 | |||
| ee4ca9df07 | |||
| 9385fc0f11 | |||
| c9f4fcf8de | |||
| f41d1843e4 | |||
| aa0900410b | |||
| 897abc7b21 | |||
| 69a52ffe03 | |||
| 771f46d2ac | |||
| 20d42c5270 |
+10
-2
@@ -86,9 +86,17 @@ SSRF_BLOCK_PRIVATE_IPS=false
|
|||||||
ADMINER_DESIGN=dracula
|
ADMINER_DESIGN=dracula
|
||||||
|
|
||||||
# ============== SEED ==============
|
# ============== SEED ==============
|
||||||
# Bei leerer DB seedet der Container automatisch (legt admin@admin.com / admin
|
# Bei leerer DB seedet der Container automatisch (legt admin@admin.com +
|
||||||
# + Stammdaten an) – nichts zu konfigurieren.
|
# Stammdaten an) – nichts zu konfigurieren.
|
||||||
# Nur wenn man eine NICHT-leere DB nochmal forciert seeden will (z.B. nach
|
# Nur wenn man eine NICHT-leere DB nochmal forciert seeden will (z.B. nach
|
||||||
# Reset / Stammdaten-Update), kurz auf 'true' setzen, neu starten, dann
|
# Reset / Stammdaten-Update), kurz auf 'true' setzen, neu starten, dann
|
||||||
# wieder zurück.
|
# wieder zurück.
|
||||||
RUN_SEED=false
|
RUN_SEED=false
|
||||||
|
|
||||||
|
# Initial-Passwort für admin@admin.com beim Seed. Mindestens 25 Zeichen,
|
||||||
|
# sonst wird der Wert ignoriert und stattdessen ein 28-Zeichen-Zufalls-
|
||||||
|
# passwort erzeugt und EINMAL nach stdout geschrieben:
|
||||||
|
# docker logs opencrm-app 2>&1 | grep -A 5 "Initial-Passwort"
|
||||||
|
# Wer keine Lust auf die Log-Suche hat, setzt hier ein eigenes starkes
|
||||||
|
# Passwort vor dem ersten `docker compose up -d`.
|
||||||
|
# SEED_ADMIN_PASSWORD=
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ Web-basiertes CRM-System für Kundenverwaltung mit Verträgen (Energie, Telekomm
|
|||||||
- **Adressen**: Mehrere Liefer-/Melde- und Rechnungsadressen pro Kunde
|
- **Adressen**: Mehrere Liefer-/Melde- und Rechnungsadressen pro Kunde
|
||||||
- **Bankkarten**: Mit Ablaufdatum, Aktiv-Status und Dokument-Upload (PDF)
|
- **Bankkarten**: Mit Ablaufdatum, Aktiv-Status und Dokument-Upload (PDF)
|
||||||
- **Ausweise**: Personalausweis, Reisepass, etc. mit Ablaufdatum und Dokument-Upload (PDF)
|
- **Ausweise**: Personalausweis, Reisepass, etc. mit Ablaufdatum und Dokument-Upload (PDF)
|
||||||
- **Zähler**: Strom-/Gaszähler mit Zählerstandhistorie
|
- **Zähler**: Strom-/Gaszähler mit Zählerstandhistorie,
|
||||||
|
Pflicht-Zuordnung zur Lieferadresse und Folgezähler-Kette
|
||||||
|
(Auto-Propagation auf alle Verträge mit dem Vorgänger)
|
||||||
- **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),
|
- **Auto-Vertragsstatus**: Lieferbestätigung-Upload setzt `DRAFT` → `ACTIVE` (mit Vertragsbeginn),
|
||||||
@@ -61,7 +63,7 @@ docker compose up -d
|
|||||||
```
|
```
|
||||||
|
|
||||||
Browser:
|
Browser:
|
||||||
- **CRM**: http://localhost:3010 (Login: `admin@admin.com` / `admin`)
|
- **CRM**: http://localhost:3010 (Login: `admin@admin.com` – Initial-Passwort siehe unten)
|
||||||
- **Datenbank-UI** (Adminer): http://localhost:8081 (Server: `db`, User: `root`, DB: `opencrm`)
|
- **Datenbank-UI** (Adminer): http://localhost:8081 (Server: `db`, User: `root`, DB: `opencrm`)
|
||||||
|
|
||||||
Alle persistenten Daten liegen in `./data/`:
|
Alle persistenten Daten liegen in `./data/`:
|
||||||
@@ -75,9 +77,22 @@ Alle persistenten Daten liegen in `./data/`:
|
|||||||
|
|
||||||
Ports + Pfade konfigurierst du in `./.env` (Default-Werte siehe `.env.example`).
|
Ports + Pfade konfigurierst du in `./.env` (Default-Werte siehe `.env.example`).
|
||||||
|
|
||||||
> **Erste Inbetriebnahme:** In der `.env` einmalig `RUN_SEED=true` setzen,
|
> **Erste Inbetriebnahme:** Bei leerer DB läuft der Seed automatisch
|
||||||
> `docker compose up -d` ausführen, dann wieder auf `false`. Danach existiert
|
> (kein Eingriff nötig). Nur wenn du eine bereits befüllte DB erneut
|
||||||
> der initiale Admin-User `admin@admin.com` / `admin`.
|
> seeden willst, einmalig `RUN_SEED=true` in der `.env` setzen,
|
||||||
|
> `docker compose up -d` ausführen, dann wieder auf `false`.
|
||||||
|
>
|
||||||
|
> Der initiale Admin-User ist **`admin@admin.com`**. Das Passwort wird
|
||||||
|
> beim Seed zufällig generiert (28 Zeichen) und genau einmal in den
|
||||||
|
> Container-Logs ausgegeben:
|
||||||
|
> ```bash
|
||||||
|
> docker logs opencrm-app 2>&1 | grep -A 5 "Initial-Passwort"
|
||||||
|
> ```
|
||||||
|
> Alternativ kannst du **vor dem ersten Start** ein eigenes Passwort
|
||||||
|
> setzen, indem du `SEED_ADMIN_PASSWORD=…` (mindestens 25 Zeichen, sonst
|
||||||
|
> wird's ignoriert) in der `.env` einträgst. Nach dem ersten Login
|
||||||
|
> sollte das Passwort über *Einstellungen → Mein Profil* geändert
|
||||||
|
> werden.
|
||||||
|
|
||||||
## Voraussetzungen
|
## Voraussetzungen
|
||||||
|
|
||||||
@@ -186,11 +201,33 @@ Das Frontend läuft auf `http://localhost:5173`
|
|||||||
|
|
||||||
Nach dem Seed sind folgende Zugangsdaten verfügbar:
|
Nach dem Seed sind folgende Zugangsdaten verfügbar:
|
||||||
|
|
||||||
- **E-Mail:** admin@admin.com
|
- **E-Mail:** `admin@admin.com`
|
||||||
- **Passwort:** admin
|
- **Passwort:** wird beim Seed zufällig generiert (28 Zeichen) und einmal
|
||||||
|
in den Container-Logs ausgegeben:
|
||||||
|
```bash
|
||||||
|
docker logs opencrm-app 2>&1 | grep -A 5 "Initial-Passwort"
|
||||||
|
```
|
||||||
|
Beispiel-Ausgabe:
|
||||||
|
```
|
||||||
|
========================================================
|
||||||
|
Admin-User: admin@admin.com
|
||||||
|
Initial-Passwort: <28-zeichen-zufalls-passwort>
|
||||||
|
⚠️ Dieses Passwort wird hier EINMAL ausgegeben!
|
||||||
|
Bitte sofort nach dem ersten Login ändern.
|
||||||
|
========================================================
|
||||||
|
```
|
||||||
|
Wer das Log nicht mehr findet (z.B. Container schon länger gerollt),
|
||||||
|
setzt `SEED_ADMIN_PASSWORD=<min. 25 Zeichen>` in der `.env` und startet
|
||||||
|
einmalig mit `RUN_SEED=true` neu – der Seed ist idempotent und
|
||||||
|
überschreibt das Admin-Passwort. Danach `RUN_SEED` wieder auf `false`.
|
||||||
|
|
||||||
> **Wichtig:** Vor dem ersten Production-Deployment das Default-Passwort sofort
|
> **Tipp:** Wer von Anfang an ein eigenes Admin-Passwort haben möchte,
|
||||||
> ändern und Secrets rotieren – siehe [Production-Deployment](#production-deployment).
|
> setzt `SEED_ADMIN_PASSWORD=<min. 25 Zeichen>` **bevor** der erste
|
||||||
|
> `docker compose up -d` läuft. Der Wert wird dann statt des Zufalls-
|
||||||
|
> passworts verwendet (siehe `.env.example`).
|
||||||
|
|
||||||
|
> **Wichtig:** Vor dem ersten Production-Deployment Secrets rotieren –
|
||||||
|
> siehe [Production-Deployment](#production-deployment).
|
||||||
|
|
||||||
## Production-Deployment
|
## Production-Deployment
|
||||||
|
|
||||||
@@ -240,7 +277,9 @@ Plus:
|
|||||||
und der `/auth/refresh`-Endpoint kann den User nicht mehr nachladen
|
und der `/auth/refresh`-Endpoint kann den User nicht mehr nachladen
|
||||||
(= alle 15 min Re-Login). Beim NPM-Setup landen Frontend und API automatisch
|
(= alle 15 min Re-Login). Beim NPM-Setup landen Frontend und API automatisch
|
||||||
auf derselben Domain via Proxy-Path.
|
auf derselben Domain via Proxy-Path.
|
||||||
- **Default-Admin-Passwort ändern** (admin@admin.com / admin).
|
- **Admin-Initial-Passwort ändern**: das beim Seed generierte Zufalls-Passwort
|
||||||
|
(siehe [Erster Login](#erster-login)) im Profil überschreiben – oder gleich
|
||||||
|
per `SEED_ADMIN_PASSWORD` in der `.env` vor dem ersten Start setzen.
|
||||||
- **Manuelle Test-Checkliste** aus [docs/TESTING.md](docs/TESTING.md) einmal komplett
|
- **Manuelle Test-Checkliste** aus [docs/TESTING.md](docs/TESTING.md) einmal komplett
|
||||||
durchklicken.
|
durchklicken.
|
||||||
- **Monitoring konfigurieren**: Einstellungen → Sicherheits-Monitoring → Alert-E-Mail
|
- **Monitoring konfigurieren**: Einstellungen → Sicherheits-Monitoring → Alert-E-Mail
|
||||||
@@ -687,11 +726,15 @@ Je nach Vertragstyp werden unterschiedliche Felder im Formular angezeigt:
|
|||||||
|
|
||||||
#### Strom & Gas (ELECTRICITY, GAS)
|
#### Strom & Gas (ELECTRICITY, GAS)
|
||||||
|
|
||||||
- Zähler-Auswahl
|
- Zähler-Auswahl (gefiltert auf die Lieferadresse des Vertrags)
|
||||||
- Jahresverbrauch (kWh/m³)
|
- Jahresverbrauch (kWh/m³) – bei Folgeverträgen mit Schätzwert
|
||||||
- Grundpreis, Arbeitspreis
|
aus dem Vorvertrag und 1-Klick-Übernahme
|
||||||
- Bonus
|
- Grundpreis, Arbeitspreis (HT/NT bei Zweitarif)
|
||||||
|
- Sofort-Bonus, Neukunden-Bonus
|
||||||
- Vorversorger, Kundennummer beim Vorversorger
|
- Vorversorger, Kundennummer beim Vorversorger
|
||||||
|
- Folgezähler-Workflow (Zählerwechsel): Wechseldatum + optionaler
|
||||||
|
Endstand des alten Zählers, der automatisch als Zählerstand
|
||||||
|
erfasst wird und in die Verbrauchsberechnung einfließt
|
||||||
|
|
||||||
#### Internet (DSL, CABLE, FIBER)
|
#### Internet (DSL, CABLE, FIBER)
|
||||||
|
|
||||||
@@ -715,6 +758,8 @@ Je nach Vertragstyp werden unterschiedliche Felder im Formular angezeigt:
|
|||||||
- Rufnummer, SIM-Kartennummer (ICCID)
|
- Rufnummer, SIM-Kartennummer (ICCID)
|
||||||
- PIN, PUK (verschlüsselt)
|
- PIN, PUK (verschlüsselt)
|
||||||
- Multisim-Flag, Hauptkarte-Flag
|
- Multisim-Flag, Hauptkarte-Flag
|
||||||
|
- Kartennutzer (optional) – tatsächlicher Nutzer bei Firmen-/
|
||||||
|
Familienverträgen, kann vom Vertragsinhaber abweichen
|
||||||
|
|
||||||
> **Hinweis Multisim:** Nicht buchbar bei Klarmobil, Congstar, Otelo. Benötigt Freenet oder vergleichbar.
|
> **Hinweis Multisim:** Nicht buchbar bei Klarmobil, Congstar, Otelo. Benötigt Freenet oder vergleichbar.
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,14 @@ if ! npx prisma migrate deploy; then
|
|||||||
fi
|
fi
|
||||||
echo "[entrypoint] DB-Schema aktuell"
|
echo "[entrypoint] DB-Schema aktuell"
|
||||||
|
|
||||||
|
# Pentest 53.3 (2026-06-01): wenn ein veraltetes Image gestartet wird
|
||||||
|
# (kein `docker compose build` nach Schema-Änderung), fehlten neue Felder
|
||||||
|
# wie `areaCode` im generierten Prisma-Client → PUT/POST crash. `prisma
|
||||||
|
# generate` am Start regeneriert den Client gegen das aktuelle Schema
|
||||||
|
# und kostet ~5–10 s – tradeoff für Robustheit.
|
||||||
|
echo "[entrypoint] Prisma-Client regenerieren (falls Image älter als Schema)…"
|
||||||
|
npx prisma generate || echo "[entrypoint] prisma generate fehlgeschlagen – nicht kritisch, Client bleibt aus Image"
|
||||||
|
|
||||||
# Auto-Seed: wenn die User-Tabelle leer ist (= Erstinstallation), automatisch seeden.
|
# Auto-Seed: wenn die User-Tabelle leer ist (= Erstinstallation), automatisch seeden.
|
||||||
# RUN_SEED=true erzwingt Seed auch bei nicht-leerer DB (z.B. nach Reset).
|
# RUN_SEED=true erzwingt Seed auch bei nicht-leerer DB (z.B. nach Reset).
|
||||||
USER_COUNT=$(node -e "
|
USER_COUNT=$(node -e "
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
* mehrfach aufrufbar.
|
* mehrfach aufrufbar.
|
||||||
*/
|
*/
|
||||||
import prisma from '../src/lib/prisma.js';
|
import prisma from '../src/lib/prisma.js';
|
||||||
import { stripHtml } from '../src/utils/sanitize.js';
|
import { stripHtml, isValidDocumentPath as isValidDocumentPathShared } from '../src/utils/sanitize.js';
|
||||||
import { ALLOWED_SETTING_KEYS } from '../src/services/appSetting.service.js';
|
import { ALLOWED_SETTING_KEYS } from '../src/services/appSetting.service.js';
|
||||||
|
|
||||||
const CUSTOMER_STRING_FIELDS = [
|
const CUSTOMER_STRING_FIELDS = [
|
||||||
@@ -93,18 +93,10 @@ const ALLOWED_CONSENT_SOURCES = new Set([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Legitimer documentPath: relativer Pfad unter uploads/, keine ".."-Segmente.
|
// Legitimer documentPath: relativer Pfad unter uploads/, keine ".."-Segmente.
|
||||||
// Schreibend werden Pfade ausschließlich vom multer-Upload erzeugt
|
// Pentest 27.1 (2026-05-20). Helper jetzt zentral in utils/sanitize – wir
|
||||||
// (server-kontrollierter Dateiname), bestehende Pentest-Hinterlassenschaften
|
// re-exportieren hier nur, damit der bestehende Code keinen Schaden nimmt
|
||||||
// wie "../../../etc/passwd" oder "javascript:alert(1)" müssen aus der DB
|
// und wir nur EINE Quelle der Wahrheit pflegen müssen.
|
||||||
// raus (Pentest 2026-05-20 LOW 27.1).
|
const isValidDocumentPath = isValidDocumentPathShared;
|
||||||
function isValidDocumentPath(v: string | null | undefined): boolean {
|
|
||||||
if (!v) return true; // null/leer ist OK
|
|
||||||
if (v.includes('..')) return false;
|
|
||||||
if (/(?:javascript|data|vbscript)\s*:/i.test(v)) return false;
|
|
||||||
if (/<[a-z!\/]/i.test(v)) return false; // HTML im Pfad
|
|
||||||
// erlaubt: "uploads/...", "/uploads/..."; keine Kontrollzeichen
|
|
||||||
return /^\/?uploads\/[A-Za-z0-9._\-\/]+$/.test(v);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cleanupConsents() {
|
async function cleanupConsents() {
|
||||||
// version + documentPath: HTML strippen (waren ohne Validierung).
|
// version + documentPath: HTML strippen (waren ohne Validierung).
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- Energie-Bonus in Sofort-Bonus + Neukunden-Bonus aufteilen.
|
||||||
|
-- Bestehende Werte werden nach `instantBonus` migriert (Annahme: bei
|
||||||
|
-- Bestandsverträgen war "Bonus" üblicherweise der Sofort-Bonus).
|
||||||
|
-- Wer das anders hatte, kann die Werte über die UI nachträglich verschieben.
|
||||||
|
--
|
||||||
|
-- IF NOT EXISTS macht den Re-Deploy auf Prod sicher, falls jemand schon
|
||||||
|
-- `prisma db push` gefahren hat.
|
||||||
|
|
||||||
|
ALTER TABLE `EnergyContractDetails`
|
||||||
|
ADD COLUMN IF NOT EXISTS `instantBonus` DOUBLE NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS `newCustomerBonus` DOUBLE NULL;
|
||||||
|
|
||||||
|
-- Daten kopieren, sofern die alte Spalte noch existiert und das Ziel leer ist
|
||||||
|
SET @col_exists := (
|
||||||
|
SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'EnergyContractDetails'
|
||||||
|
AND COLUMN_NAME = 'bonus'
|
||||||
|
);
|
||||||
|
SET @sql := IF(
|
||||||
|
@col_exists > 0,
|
||||||
|
'UPDATE `EnergyContractDetails` SET `instantBonus` = `bonus` WHERE `bonus` IS NOT NULL AND `instantBonus` IS NULL',
|
||||||
|
'SELECT "bonus-Spalte existiert nicht mehr, nichts zu migrieren"'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- Alte Spalte droppen, falls vorhanden
|
||||||
|
ALTER TABLE `EnergyContractDetails` DROP COLUMN IF EXISTS `bonus`;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
-- Zähler bekommen eine Lieferadresse, an der sie installiert sind.
|
||||||
|
-- Optional, damit Bestandszähler (vor diesem Feature) nicht brechen –
|
||||||
|
-- die werden manuell nachgepflegt. ON DELETE SET NULL, damit ein
|
||||||
|
-- gelöschter Adresseintrag den Zähler nicht killt.
|
||||||
|
--
|
||||||
|
-- IF NOT EXISTS macht den Re-Deploy auf Prod sicher, falls jemand schon
|
||||||
|
-- `prisma db push` gefahren hat.
|
||||||
|
|
||||||
|
ALTER TABLE `Meter`
|
||||||
|
ADD COLUMN IF NOT EXISTS `addressId` INT NULL;
|
||||||
|
|
||||||
|
-- Index nur anlegen, wenn er noch nicht da ist
|
||||||
|
SET @idx_exists := (
|
||||||
|
SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'Meter'
|
||||||
|
AND INDEX_NAME = 'Meter_addressId_fkey'
|
||||||
|
);
|
||||||
|
SET @sql := IF(
|
||||||
|
@idx_exists = 0,
|
||||||
|
'CREATE INDEX `Meter_addressId_fkey` ON `Meter`(`addressId`)',
|
||||||
|
'SELECT "Index existiert bereits"'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- Foreign Key nur anlegen, wenn er noch nicht da ist
|
||||||
|
SET @fk_exists := (
|
||||||
|
SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'Meter'
|
||||||
|
AND CONSTRAINT_NAME = 'Meter_addressId_fkey'
|
||||||
|
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
|
||||||
|
);
|
||||||
|
SET @sql := IF(
|
||||||
|
@fk_exists = 0,
|
||||||
|
'ALTER TABLE `Meter` ADD CONSTRAINT `Meter_addressId_fkey` FOREIGN KEY (`addressId`) REFERENCES `Address`(`id`) ON DELETE SET NULL ON UPDATE CASCADE',
|
||||||
|
'SELECT "FK existiert bereits"'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
-- Folgezähler-Kette: Meter zeigt optional auf den Vorgänger.
|
||||||
|
-- Beim Wechsel können wir dann sowohl die Kette für die UI anzeigen
|
||||||
|
-- als auch alle Verträge mit dem Vorgänger automatisch auf den
|
||||||
|
-- Nachfolger umstellen.
|
||||||
|
--
|
||||||
|
-- ON DELETE SET NULL, damit ein versehentlich gelöschter Vorgänger
|
||||||
|
-- den Nachfolger nicht killt.
|
||||||
|
--
|
||||||
|
-- IF NOT EXISTS macht den Re-Deploy auf Prod sicher, falls jemand
|
||||||
|
-- schon `prisma db push` gefahren hat.
|
||||||
|
|
||||||
|
ALTER TABLE `Meter`
|
||||||
|
ADD COLUMN IF NOT EXISTS `predecessorMeterId` INT NULL;
|
||||||
|
|
||||||
|
-- Index nur anlegen, wenn er noch nicht da ist
|
||||||
|
SET @idx_exists := (
|
||||||
|
SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'Meter'
|
||||||
|
AND INDEX_NAME = 'Meter_predecessorMeterId_fkey'
|
||||||
|
);
|
||||||
|
SET @sql := IF(
|
||||||
|
@idx_exists = 0,
|
||||||
|
'CREATE INDEX `Meter_predecessorMeterId_fkey` ON `Meter`(`predecessorMeterId`)',
|
||||||
|
'SELECT "Index existiert bereits"'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- Foreign Key nur anlegen, wenn er noch nicht da ist
|
||||||
|
SET @fk_exists := (
|
||||||
|
SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'Meter'
|
||||||
|
AND CONSTRAINT_NAME = 'Meter_predecessorMeterId_fkey'
|
||||||
|
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
|
||||||
|
);
|
||||||
|
SET @sql := IF(
|
||||||
|
@fk_exists = 0,
|
||||||
|
'ALTER TABLE `Meter` ADD CONSTRAINT `Meter_predecessorMeterId_fkey` FOREIGN KEY (`predecessorMeterId`) REFERENCES `Meter`(`id`) ON DELETE SET NULL ON UPDATE CASCADE',
|
||||||
|
'SELECT "FK existiert bereits"'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- SIM-Karte bekommt einen optionalen "Kartennutzer" – relevant bei Firmen-
|
||||||
|
-- und Familienverträgen, wo der Vertragsinhaber (Firma/Eltern) nicht
|
||||||
|
-- gleich dem tatsächlichen Nutzer (Mitarbeiter/Kind) ist.
|
||||||
|
--
|
||||||
|
-- IF NOT EXISTS macht den Re-Deploy auf Prod sicher, falls jemand schon
|
||||||
|
-- `prisma db push` gefahren hat.
|
||||||
|
|
||||||
|
ALTER TABLE `SimCard`
|
||||||
|
ADD COLUMN IF NOT EXISTS `cardUser` VARCHAR(191) NULL;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- Telefonnummern bekommen eine zusätzliche Spalte `areaCode`, damit
|
||||||
|
-- Wechselauftrag-PDFs die Vorwahl verlässlich befüllen können, ohne
|
||||||
|
-- den heuristischen String-Split, der bei Sonderformaten danebenfasst.
|
||||||
|
--
|
||||||
|
-- `phoneNumber` bleibt unverändert die komplette Nummer (Vorwahl +
|
||||||
|
-- Anschlussnummer), damit Reports/Listen/Suchen weiter funktionieren.
|
||||||
|
-- `areaCode` ist optional, weil Bestandsnummern erst beim nächsten
|
||||||
|
-- Edit nachgepflegt werden.
|
||||||
|
--
|
||||||
|
-- IF NOT EXISTS macht den Re-Deploy auf Prod sicher, falls jemand
|
||||||
|
-- schon `prisma db push` gefahren hat.
|
||||||
|
|
||||||
|
ALTER TABLE `PhoneNumber`
|
||||||
|
ADD COLUMN IF NOT EXISTS `areaCode` VARCHAR(191) NULL;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Consent-Hash bekommt eine Ablauffrist (Pentest 57.7 MEDIUM).
|
||||||
|
-- Public-Consent-Links liefen vorher nie ab – DSGVO-Risiko, weil ein
|
||||||
|
-- weitergegebener Link Jahre später noch Einwilligungen erteilen konnte.
|
||||||
|
-- 30 Tage Default; nach Ablauf liefert getCustomerByConsentHash null.
|
||||||
|
-- Bestandsdaten ohne Ablaufzeit bekommen `NOW() + 30 Tage` als Frist,
|
||||||
|
-- damit existierende, frisch versendete Links nicht sofort tot sind.
|
||||||
|
--
|
||||||
|
-- IF NOT EXISTS macht den Re-Deploy auf Prod sicher.
|
||||||
|
|
||||||
|
ALTER TABLE `Customer`
|
||||||
|
ADD COLUMN IF NOT EXISTS `consentHashExpiresAt` DATETIME(3) NULL;
|
||||||
|
|
||||||
|
UPDATE `Customer`
|
||||||
|
SET `consentHashExpiresAt` = DATE_ADD(NOW(), INTERVAL 30 DAY)
|
||||||
|
WHERE `consentHash` IS NOT NULL
|
||||||
|
AND `consentHashExpiresAt` IS NULL;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- SIM-Karte bekommt ein optionales `isEsim`-Flag – Hardware-Plastikkarte
|
||||||
|
-- vs. eSIM-Profil. UI-Position: zwischen "Hauptkarte" und "Multisim".
|
||||||
|
--
|
||||||
|
-- IF NOT EXISTS macht den Re-Deploy auf Prod sicher, falls jemand schon
|
||||||
|
-- `prisma db push` gefahren hat.
|
||||||
|
|
||||||
|
ALTER TABLE `SimCard`
|
||||||
|
ADD COLUMN IF NOT EXISTS `isEsim` BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Vertrieb-/Provider-Trennung: viele Plattformen vergeben eigene Kunden-/
|
||||||
|
-- Vertragsnummern, die nicht mit denen beim Endanbieter identisch sind.
|
||||||
|
-- Zwei neue optionale Felder unter "Anbieter & Tarif".
|
||||||
|
|
||||||
|
ALTER TABLE `Contract`
|
||||||
|
ADD COLUMN IF NOT EXISTS `customerNumberAtSalesPlatform` VARCHAR(191) NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS `contractNumberAtSalesPlatform` VARCHAR(191) NULL;
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
-- Zusätzliche Weiterleitungs-E-Mails pro StressfreiEmail-Adresse.
|
||||||
|
-- JSON-Array (z.B. `["info@partner.de","cc@kanzlei.de"]`), wird beim
|
||||||
|
-- Sync zusammen mit customer.email + config.defaultForwardEmail an den
|
||||||
|
-- Provider gepusht (`set:`-Befehl überschreibt die Liste).
|
||||||
|
|
||||||
|
ALTER TABLE `StressfreiEmail`
|
||||||
|
ADD COLUMN IF NOT EXISTS `additionalForwardingEmails` TEXT NULL;
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
-- Zusätzliches optionales Feld unter "Anbieter & Tarif": Auftragsnummer bei
|
||||||
|
-- der Vertriebsplattform (vor der Kundennummer). Plattformen liefern beim
|
||||||
|
-- Abschluss oft eine eigene Auftrags-/Vorgangsnummer, die fürs Reklamations-
|
||||||
|
-- handling gebraucht wird.
|
||||||
|
|
||||||
|
ALTER TABLE `Contract`
|
||||||
|
ADD COLUMN IF NOT EXISTS `orderNumberAtSalesPlatform` VARCHAR(191) NULL;
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
-- Provider: separate Kontakt- + Kündigungs-Daten als Stammsatz.
|
||||||
|
-- Vorher musste der CRM-Mitarbeiter Tel/Email/Adresse pro Anbieter
|
||||||
|
-- selbst nachschlagen; jetzt direkt im Anbieter-Datensatz hinterlegt.
|
||||||
|
-- Postadressen sind TEXT (mehrzeilig), alle anderen VARCHAR(191).
|
||||||
|
|
||||||
|
ALTER TABLE `Provider`
|
||||||
|
ADD COLUMN IF NOT EXISTS `contactEmail` VARCHAR(191) NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS `contactPhone` VARCHAR(191) NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS `contactFax` VARCHAR(191) NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS `contactAddress` TEXT NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS `cancellationEmail` VARCHAR(191) NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS `cancellationFax` VARCHAR(191) NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS `cancellationAddress` TEXT NULL;
|
||||||
@@ -157,7 +157,8 @@ model Customer {
|
|||||||
commercialRegisterPath String? // PDF-Pfad zum Handelsregisterauszug
|
commercialRegisterPath String? // PDF-Pfad zum Handelsregisterauszug
|
||||||
commercialRegisterNumber String? // Handelsregisternummer (Text)
|
commercialRegisterNumber String? // Handelsregisternummer (Text)
|
||||||
privacyPolicyPath String? // PDF-Pfad zur Datenschutzerklärung (für alle Kunden)
|
privacyPolicyPath String? // PDF-Pfad zur Datenschutzerklärung (für alle Kunden)
|
||||||
consentHash String? @unique // Permanenter Hash für öffentlichen Einwilligungslink /datenschutz/<hash>
|
consentHash String? @unique // Hash für öffentlichen Einwilligungslink /datenschutz/<hash>
|
||||||
|
consentHashExpiresAt DateTime? // Pentest 57.7: TTL für Public-Consent-Link (30 Tage Default); nach Ablauf ist getCustomerByConsentHash null und der Link muss neu generiert werden.
|
||||||
notes String? @db.Text
|
notes String? @db.Text
|
||||||
|
|
||||||
// ===== Portal-Zugangsdaten =====
|
// ===== Portal-Zugangsdaten =====
|
||||||
@@ -281,6 +282,7 @@ model Address {
|
|||||||
ownerEmail String?
|
ownerEmail String?
|
||||||
contractsAsDelivery Contract[] @relation("DeliveryAddress")
|
contractsAsDelivery Contract[] @relation("DeliveryAddress")
|
||||||
contractsAsBilling Contract[] @relation("BillingAddress")
|
contractsAsBilling Contract[] @relation("BillingAddress")
|
||||||
|
meters Meter[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
@@ -400,6 +402,12 @@ model StressfreiEmail {
|
|||||||
hasMailbox Boolean @default(false) // Hat echte Mailbox (nicht nur Weiterleitung)?
|
hasMailbox Boolean @default(false) // Hat echte Mailbox (nicht nur Weiterleitung)?
|
||||||
emailPasswordEncrypted String? // Verschlüsseltes Mailbox-Passwort (AES-256-GCM)
|
emailPasswordEncrypted String? // Verschlüsseltes Mailbox-Passwort (AES-256-GCM)
|
||||||
|
|
||||||
|
// Zusätzliche Weiterleitungsziele (über die Stamm-E-Mail des Kunden
|
||||||
|
// hinaus). Wird beim Sync zusammen mit customer.email +
|
||||||
|
// config.defaultForwardEmail an den Provider geschickt. JSON-Array
|
||||||
|
// von Strings, z.B. `["info@partner.de","cc@kanzlei.de"]`.
|
||||||
|
additionalForwardingEmails String? @db.Text
|
||||||
|
|
||||||
contracts Contract[] // Verträge die diese E-Mail als Benutzername verwenden
|
contracts Contract[] // Verträge die diese E-Mail als Benutzername verwenden
|
||||||
cachedEmails CachedEmail[] // Gecachte E-Mails aus dieser Mailbox
|
cachedEmails CachedEmail[] // Gecachte E-Mails aus dieser Mailbox
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -479,11 +487,21 @@ model Meter {
|
|||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
customerId Int
|
customerId Int
|
||||||
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
||||||
|
// Lieferadresse, an der der Zähler hängt. Optional, weil Bestandszähler
|
||||||
|
// vor dem Feature noch keine Adresse haben (werden manuell nachgepflegt).
|
||||||
|
addressId Int?
|
||||||
|
address Address? @relation(fields: [addressId], references: [id], onDelete: SetNull)
|
||||||
meterNumber String
|
meterNumber String
|
||||||
type MeterType
|
type MeterType
|
||||||
tariffModel MeterTariffModel @default(SINGLE) // Eintarif oder Zweitarif (HT/NT)
|
tariffModel MeterTariffModel @default(SINGLE) // Eintarif oder Zweitarif (HT/NT)
|
||||||
location String?
|
location String?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
|
// Folgezähler-Kette: zeigt auf den Vorgänger, den dieser Zähler abgelöst hat.
|
||||||
|
// Wird beim Anlegen als Folgezähler gesetzt; informational + zum Anzeigen
|
||||||
|
// der Kette. Auto-Propagation auf Verträge passiert beim Create.
|
||||||
|
predecessorMeterId Int?
|
||||||
|
predecessor Meter? @relation("MeterSuccessor", fields: [predecessorMeterId], references: [id], onDelete: SetNull)
|
||||||
|
successors Meter[] @relation("MeterSuccessor")
|
||||||
readings MeterReading[]
|
readings MeterReading[]
|
||||||
energyDetails EnergyContractDetails[]
|
energyDetails EnergyContractDetails[]
|
||||||
contractMeters ContractMeter[] @relation("ContractMeters")
|
contractMeters ContractMeter[] @relation("ContractMeters")
|
||||||
@@ -558,6 +576,15 @@ model Provider {
|
|||||||
portalUrl String? // Kundenkontourl (Login-Seite)
|
portalUrl String? // Kundenkontourl (Login-Seite)
|
||||||
usernameFieldName String? // Benutzernamefeld (z.B. "email", "username")
|
usernameFieldName String? // Benutzernamefeld (z.B. "email", "username")
|
||||||
passwordFieldName String? // Kennwortfeld (z.B. "password", "pwd")
|
passwordFieldName String? // Kennwortfeld (z.B. "password", "pwd")
|
||||||
|
// Kontaktdaten beim Anbieter (für CRM-Mitarbeiter zum Nachschlagen)
|
||||||
|
contactEmail String? // Allgemeine Kontakt-Emailadresse
|
||||||
|
contactPhone String? // Kontakt-Telefonnummer
|
||||||
|
contactFax String? // Kontakt-Faxnummer
|
||||||
|
contactAddress String? @db.Text // Kontakt-Postadresse (mehrzeilig)
|
||||||
|
// Dedizierte Kündigungs-Endpunkte (wenn separat vom allgemeinen Kontakt)
|
||||||
|
cancellationEmail String? // Kündigungs-Emailadresse
|
||||||
|
cancellationFax String? // Kündigungs-Faxnummer
|
||||||
|
cancellationAddress String? @db.Text // Kündigungs-Postadresse (mehrzeilig)
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
tariffs Tariff[]
|
tariffs Tariff[]
|
||||||
contracts Contract[]
|
contracts Contract[]
|
||||||
@@ -675,6 +702,9 @@ model Contract {
|
|||||||
tariffName String?
|
tariffName String?
|
||||||
customerNumberAtProvider String?
|
customerNumberAtProvider String?
|
||||||
contractNumberAtProvider String? // Vertragsnummer beim Anbieter
|
contractNumberAtProvider String? // Vertragsnummer beim Anbieter
|
||||||
|
orderNumberAtSalesPlatform String? // Auftragsnummer bei der Vertriebsplattform
|
||||||
|
customerNumberAtSalesPlatform String? // Kundennummer bei der Vertriebsplattform
|
||||||
|
contractNumberAtSalesPlatform String? // Vertragsnummer bei der Vertriebsplattform
|
||||||
priceFirst12Months String? // Preis erste 12 Monate
|
priceFirst12Months String? // Preis erste 12 Monate
|
||||||
priceFrom13Months String? // Preis ab 13. Monat
|
priceFrom13Months String? // Preis ab 13. Monat
|
||||||
priceAfter24Months String? // Preis nach 24 Monaten
|
priceAfter24Months String? // Preis nach 24 Monaten
|
||||||
@@ -805,7 +835,14 @@ model EnergyContractDetails {
|
|||||||
basePrice Float? // €/Monat
|
basePrice Float? // €/Monat
|
||||||
unitPrice Float? // €/kWh (Arbeitspreis) - bei HT/NT: HT-Preis
|
unitPrice Float? // €/kWh (Arbeitspreis) - bei HT/NT: HT-Preis
|
||||||
unitPriceNt Float? // €/kWh NT-Preis (nur bei Zweitarifzähler)
|
unitPriceNt Float? // €/kWh NT-Preis (nur bei Zweitarifzähler)
|
||||||
bonus Float?
|
// Bonus wurde 2026-05-24 in zwei Felder aufgeteilt: Sofort-Bonus
|
||||||
|
// (Auszahlung kurz nach Wechsel) + Neukunden-Bonus (Auszahlung am
|
||||||
|
// Vertragsende / nach 12 Monaten). Beide werden im Detail als
|
||||||
|
// Gesamtbonus aufsummiert und in der Kostenvorschau einzeln
|
||||||
|
// dargestellt. Migration kopiert das alte `bonus` nach
|
||||||
|
// `instantBonus` (Annahme: meistgenutzte Variante).
|
||||||
|
instantBonus Float? // Sofort-Bonus
|
||||||
|
newCustomerBonus Float? // Neukunden-Bonus
|
||||||
previousProviderName String?
|
previousProviderName String?
|
||||||
previousCustomerNumber String?
|
previousCustomerNumber String?
|
||||||
invoices Invoice[] // Rechnungen
|
invoices Invoice[] // Rechnungen
|
||||||
@@ -874,7 +911,12 @@ model PhoneNumber {
|
|||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
internetContractDetailsId Int
|
internetContractDetailsId Int
|
||||||
internetDetails InternetContractDetails @relation(fields: [internetContractDetailsId], references: [id], onDelete: Cascade)
|
internetDetails InternetContractDetails @relation(fields: [internetContractDetailsId], references: [id], onDelete: Cascade)
|
||||||
|
// phoneNumber bleibt die komplette Nummer (Vorwahl + Anschluss) für
|
||||||
|
// bestehende Reports/Listen. Vorwahl wird zusätzlich getrennt erfasst,
|
||||||
|
// damit Auftragsformulare (Wechselauftrag PDF) sie verlässlich
|
||||||
|
// befüllen können – ohne heuristischen String-Split.
|
||||||
phoneNumber String
|
phoneNumber String
|
||||||
|
areaCode String?
|
||||||
isMain Boolean @default(false)
|
isMain Boolean @default(false)
|
||||||
// SIP-Zugangsdaten
|
// SIP-Zugangsdaten
|
||||||
sipUsername String?
|
sipUsername String?
|
||||||
@@ -910,6 +952,10 @@ model SimCard {
|
|||||||
puk String? // PUK (verschlüsselt gespeichert)
|
puk String? // PUK (verschlüsselt gespeichert)
|
||||||
isMultisim Boolean @default(false) // Ist dies eine Multisim-Karte?
|
isMultisim Boolean @default(false) // Ist dies eine Multisim-Karte?
|
||||||
isMain Boolean @default(false) // Ist dies die Hauptkarte?
|
isMain Boolean @default(false) // Ist dies die Hauptkarte?
|
||||||
|
isEsim Boolean @default(false) // Ist dies eine eSIM?
|
||||||
|
// Tatsächlicher Nutzer der SIM-Karte (z.B. Mitarbeiter bei Firmenverträgen,
|
||||||
|
// Kind bei Eltern-Vertrag) – kann vom Vertragsinhaber abweichen.
|
||||||
|
cardUser String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,17 @@ export async function updateSetting(req: AuthRequest, res: Response): Promise<vo
|
|||||||
// HTML-Tags aus Plain-Text-Keys strippen, bevor sie in der DB landen.
|
// HTML-Tags aus Plain-Text-Keys strippen, bevor sie in der DB landen.
|
||||||
// Pentest 2026-05-19, MEDIUM: companyName="<img onerror=...>" landete
|
// Pentest 2026-05-19, MEDIUM: companyName="<img onerror=...>" landete
|
||||||
// sonst ungefiltert in E-Mail-Templates / PDFs.
|
// sonst ungefiltert in E-Mail-Templates / PDFs.
|
||||||
const newValue = appSettingService.sanitizeSettingValue(key, String(value));
|
const stripped = appSettingService.sanitizeSettingValue(key, String(value));
|
||||||
|
// Schema-spezifische Validierung (URL/Email/Int/Bool). Pentest
|
||||||
|
// 2026-05-28, LOW 34.5: portalLoginUrl nahm `/relative/path` und
|
||||||
|
// `http://192.168.1.1` ungefiltert entgegen → Open-Redirect /
|
||||||
|
// SSRF in der versendeten Mail.
|
||||||
|
const validation = appSettingService.validateSettingValue(key, stripped);
|
||||||
|
if (!validation.ok) {
|
||||||
|
res.status(400).json({ success: false, error: validation.error } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newValue = validation.value;
|
||||||
|
|
||||||
await appSettingService.setSetting(key, newValue);
|
await appSettingService.setSetting(key, newValue);
|
||||||
|
|
||||||
@@ -102,12 +112,23 @@ export async function updateSettings(req: AuthRequest, res: Response): Promise<v
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vorherige Werte laden für Audit
|
// Vorherige Werte laden für Audit. Validierung erfolgt vor dem
|
||||||
|
// ersten Schreibzugriff, damit ein Bulk-PUT mit einem ungültigen
|
||||||
|
// Wert nicht die anderen Werte halb-committed liegen lässt.
|
||||||
const changes: Record<string, { von: unknown; nach: unknown }> = {};
|
const changes: Record<string, { von: unknown; nach: unknown }> = {};
|
||||||
|
const sanitizedEntries: Array<{ key: string; oldValue: string; newValue: string }> = [];
|
||||||
for (const [key, value] of Object.entries(settings)) {
|
for (const [key, value] of Object.entries(settings)) {
|
||||||
const before = await prisma.appSetting.findUnique({ where: { key } });
|
const before = await prisma.appSetting.findUnique({ where: { key } });
|
||||||
const oldValue = before?.value ?? '-';
|
const oldValue = before?.value ?? '-';
|
||||||
const newValue = appSettingService.sanitizeSettingValue(key, String(value));
|
const stripped = appSettingService.sanitizeSettingValue(key, String(value));
|
||||||
|
const validation = appSettingService.validateSettingValue(key, stripped);
|
||||||
|
if (!validation.ok) {
|
||||||
|
res.status(400).json({ success: false, error: `${key}: ${validation.error}` } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sanitizedEntries.push({ key, oldValue, newValue: validation.value });
|
||||||
|
}
|
||||||
|
for (const { key, oldValue, newValue } of sanitizedEntries) {
|
||||||
if (oldValue !== newValue) {
|
if (oldValue !== newValue) {
|
||||||
changes[key] = { von: oldValue, nach: newValue };
|
changes[key] = { von: oldValue, nach: newValue };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import { sendEmail, SmtpCredentials, SendEmailParams, EmailAttachment } from '..
|
|||||||
import { fetchAttachment, appendToSent, ImapCredentials } from '../services/imapService.js';
|
import { fetchAttachment, appendToSent, ImapCredentials } from '../services/imapService.js';
|
||||||
import { getImapSmtpSettings } from '../services/emailProvider/emailProviderService.js';
|
import { getImapSmtpSettings } from '../services/emailProvider/emailProviderService.js';
|
||||||
import { decrypt } from '../utils/encryption.js';
|
import { decrypt } from '../utils/encryption.js';
|
||||||
|
import { sanitizeNotes, stripHtml, validateContractDocumentType, validateOptionalIsoDate, assertSafePdf } from '../utils/sanitize.js';
|
||||||
|
import { ApiError } from '../utils/apiError.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 { 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 { maybeActivateOnDeliveryConfirmation, withContractDocumentLock } 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';
|
||||||
@@ -39,12 +41,80 @@ function parseDateParam(v: unknown): Date | undefined {
|
|||||||
return isNaN(d.getTime()) ? undefined : d;
|
return isNaN(d.getTime()) ? undefined : d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pentest 91.1 (LOW, 2026-06-21): `accountId=abc` → `parseInt` = `NaN`
|
||||||
|
// → der Ternary gab `NaN` an den Service. `if (NaN)` ist falsy → der
|
||||||
|
// Postfach-Filter fiel weg, und der Vertrag zeigte Mails aus ALLEN
|
||||||
|
// Postfächern.
|
||||||
|
//
|
||||||
|
// Pentest 92 (LOW, 2026-06-21): Bei `accountId=abc` auf Vertrags-
|
||||||
|
// Endpunkten reichte das silent-undefined nicht – die Mailbox-Isolation
|
||||||
|
// brach (man sah Mails aus allen Postfächern statt 0). Strict-400, weil
|
||||||
|
// Verträge per Design IMMER ein bestimmtes Postfach meinen.
|
||||||
|
//
|
||||||
|
// Helper hat zwei Modi:
|
||||||
|
// - default (optional): fehlend/leer → undefined (kein Filter)
|
||||||
|
// invalid → 400
|
||||||
|
// - { required: true }: fehlend/leer → 400
|
||||||
|
// invalid → 400
|
||||||
|
// Bei 400 schreibt der Helper direkt die Response und gibt `null`
|
||||||
|
// zurück; der Caller bricht dann mit `return` ab.
|
||||||
|
function parsePositiveIntQuery(
|
||||||
|
v: unknown,
|
||||||
|
fieldLabel: string,
|
||||||
|
res: Response,
|
||||||
|
options?: { required?: boolean },
|
||||||
|
): number | undefined | null {
|
||||||
|
// Pentest 93.1 (INFO, 2026-06-21): `?accountId=` (explizit-leer) wurde
|
||||||
|
// wie `?accountId` weggelassen behandelt → 200 statt 400 auf optionalen
|
||||||
|
// Endpunkten. Spec sagt aber: leerer String ist KEINE gültige Zahl.
|
||||||
|
// Trennung jetzt strikt:
|
||||||
|
// - Param fehlt komplett (`undefined`) → "absent"
|
||||||
|
// - Param da, aber Wert leer/Whitespace/keine Zahl → invalid → 400
|
||||||
|
if (v === undefined) {
|
||||||
|
if (options?.required) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: `${fieldLabel} ist erforderlich (positive Ganzzahl).`,
|
||||||
|
} as ApiResponse);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (typeof v !== 'string') {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: `${fieldLabel} muss als Zahl übergeben werden.`,
|
||||||
|
} as ApiResponse);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const trimmed = v.trim();
|
||||||
|
if (trimmed === '') {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: `${fieldLabel} darf nicht leer sein – bitte weglassen oder positive Ganzzahl angeben.`,
|
||||||
|
} as ApiResponse);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const n = parseInt(trimmed, 10);
|
||||||
|
if (!Number.isFinite(n) || n < 1 || !Number.isInteger(n)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: `${fieldLabel} muss eine positive Ganzzahl sein.`,
|
||||||
|
} as ApiResponse);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
// 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: AuthRequest, 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;
|
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||||
const stressfreiEmailId = req.query.accountId ? parseInt(req.query.accountId as string) : undefined;
|
// Customer-Inbox: accountId ist legitim optional (cross-mailbox-Ansicht
|
||||||
|
// ist erwünscht), aber invalide Werte → 400.
|
||||||
|
const stressfreiEmailId = parsePositiveIntQuery(req.query.accountId, 'accountId', res);
|
||||||
|
if (stressfreiEmailId === null) 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;
|
||||||
@@ -79,17 +149,26 @@ export async function getEmailsForCustomer(req: AuthRequest, res: Response): Pro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// E-Mails für einen Vertrag abrufen
|
// E-Mails für einen Vertrag abrufen.
|
||||||
|
// `accountId` (optional) schränkt zusätzlich auf ein bestimmtes Postfach
|
||||||
|
// ein – ohne, sieht man im Vertrags-Tab Mails aus ALLEN Postfächern des
|
||||||
|
// Kunden, die dem Vertrag zugeordnet sind (User-Bug 2026-06-21).
|
||||||
export async function getEmailsForContract(req: AuthRequest, res: Response): Promise<void> {
|
export async function getEmailsForContract(req: AuthRequest, 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;
|
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
|
||||||
|
// Vertrags-Endpunkte sind per Design IMMER pro Postfach – fehlt
|
||||||
|
// accountId, ist die Abfrage semantisch ungültig. Strict-400.
|
||||||
|
// Frontend hat eh ein `enabled: !!selectedAccountId`-Guard.
|
||||||
|
const stressfreiEmailId = parsePositiveIntQuery(req.query.accountId, 'accountId', res, { required: true });
|
||||||
|
if (stressfreiEmailId === null || stressfreiEmailId === undefined) return;
|
||||||
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;
|
||||||
|
|
||||||
const emails = await cachedEmailService.getCachedEmails({
|
const emails = await cachedEmailService.getCachedEmails({
|
||||||
contractId,
|
contractId,
|
||||||
|
stressfreiEmailId,
|
||||||
folder,
|
folder,
|
||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
@@ -236,13 +315,17 @@ export async function getFolderCounts(req: AuthRequest, res: Response): Promise<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// E-Mail-Anzahl pro Ordner für einen Vertrag
|
// E-Mail-Anzahl pro Ordner für einen Vertrag (optional pro Postfach)
|
||||||
export async function getContractFolderCounts(req: AuthRequest, res: Response): Promise<void> {
|
export async function getContractFolderCounts(req: AuthRequest, 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;
|
if (!(await canAccessContract(req, res, contractId))) return;
|
||||||
|
// Wie getEmailsForContract: Postfach ist required (sonst zeigt der
|
||||||
|
// Badge eine andere Zahl als die Liste).
|
||||||
|
const stressfreiEmailId = parsePositiveIntQuery(req.query.accountId, 'accountId', res, { required: true });
|
||||||
|
if (stressfreiEmailId === null || stressfreiEmailId === undefined) return;
|
||||||
|
|
||||||
const counts = await cachedEmailService.getFolderCountsForContract(contractId);
|
const counts = await cachedEmailService.getFolderCountsForContract(contractId, stressfreiEmailId);
|
||||||
|
|
||||||
res.json({ success: true, data: counts } as ApiResponse);
|
res.json({ success: true, data: counts } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -260,6 +343,16 @@ export async function getContractFolderCounts(req: AuthRequest, res: Response):
|
|||||||
export async function syncAccount(req: AuthRequest, res: Response): Promise<void> {
|
export async function syncAccount(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const stressfreiEmailId = parseInt(req.params.id);
|
const stressfreiEmailId = parseInt(req.params.id);
|
||||||
|
// Mitarbeiter brauchen customers:update (wie früher), Portal-Kunden
|
||||||
|
// brauchen keine Perm – nur Eigentum am Konto (Owner-Check unten).
|
||||||
|
// Trennung der Threat-Modelle: Portal-User dürfen IHR eigenes
|
||||||
|
// Postfach syncen, sollen aber nicht Mitarbeiter-Updates triggern.
|
||||||
|
const isPortal = !!req.user?.isCustomerPortal;
|
||||||
|
const hasUpdatePerm = req.user?.permissions?.includes('customers:update') ?? false;
|
||||||
|
if (!isPortal && !hasUpdatePerm) {
|
||||||
|
res.status(403).json({ success: false, error: 'Keine Berechtigung' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!(await canAccessStressfreiEmail(req, res, stressfreiEmailId))) return;
|
if (!(await canAccessStressfreiEmail(req, res, stressfreiEmailId))) return;
|
||||||
const fullSync = req.query.full === 'true';
|
const fullSync = req.query.full === 'true';
|
||||||
|
|
||||||
@@ -299,6 +392,87 @@ function hasCRLF(value: unknown): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pentest 97.1 + 97.2 (LOW/INFO, 2026-06-21): Attachment-Validierung
|
||||||
|
// vor `Buffer.from(...)` damit malformed Content (null, true, leerer
|
||||||
|
// String, falsches Base64) nicht erst tief im SMTP-Service kracht und
|
||||||
|
// dort die rohe Node.js-Fehlermeldung in der Response landet. Außerdem
|
||||||
|
// explizite App-Level-Caps (10 MB pro Datei, 25 MB total, 25 Anhänge),
|
||||||
|
// damit die Frontend-Doku auch ohne bodyParser-Heroics gilt.
|
||||||
|
const MAX_ATTACHMENT_COUNT = 25;
|
||||||
|
const MAX_PER_FILE_BYTES = 10 * 1024 * 1024;
|
||||||
|
const MAX_TOTAL_BYTES = 25 * 1024 * 1024;
|
||||||
|
// Standard-Base64-Alphabet inkl. optionalem `=`-Padding (kein URL-safe).
|
||||||
|
const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
|
||||||
|
|
||||||
|
interface AttachmentValidationError {
|
||||||
|
status: 400;
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAttachments(
|
||||||
|
attachments: unknown,
|
||||||
|
): { ok: true } | AttachmentValidationError {
|
||||||
|
if (attachments === undefined) return { ok: true };
|
||||||
|
if (!Array.isArray(attachments)) {
|
||||||
|
return { status: 400, error: 'attachments muss ein Array sein.' };
|
||||||
|
}
|
||||||
|
if (attachments.length > MAX_ATTACHMENT_COUNT) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
error: `Maximal ${MAX_ATTACHMENT_COUNT} Anhänge pro E-Mail erlaubt (${attachments.length} übermittelt).`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let totalBytes = 0;
|
||||||
|
for (let i = 0; i < attachments.length; i++) {
|
||||||
|
const a = attachments[i];
|
||||||
|
const label = `Anhang ${i + 1}`;
|
||||||
|
if (!a || typeof a !== 'object') {
|
||||||
|
return { status: 400, error: `${label} hat das falsche Format.` };
|
||||||
|
}
|
||||||
|
const filename = (a as Record<string, unknown>).filename;
|
||||||
|
const content = (a as Record<string, unknown>).content;
|
||||||
|
const contentType = (a as Record<string, unknown>).contentType;
|
||||||
|
if (typeof filename !== 'string' || filename.trim() === '') {
|
||||||
|
return { status: 400, error: `${label} hat keinen Dateinamen.` };
|
||||||
|
}
|
||||||
|
if (typeof content !== 'string' || content.length === 0) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
error: `Anhang "${filename}" hat keinen Inhalt (content muss ein nicht-leerer Base64-String sein).`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!BASE64_RE.test(content)) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
error: `Anhang "${filename}" ist kein gültiger Base64-String.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (contentType !== undefined && typeof contentType !== 'string') {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
error: `Anhang "${filename}" hat ein ungültiges contentType-Feld.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Größen-Abschätzung anhand der Base64-Länge: jedes 4-Zeichen-Quartett
|
||||||
|
// dekodiert zu max 3 Bytes. So vermeiden wir Buffer.from() schon hier.
|
||||||
|
const approxBytes = Math.ceil(content.length * 0.75);
|
||||||
|
if (approxBytes > MAX_PER_FILE_BYTES) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
error: `Anhang "${filename}" überschreitet die Maximalgröße von ${MAX_PER_FILE_BYTES / 1024 / 1024} MB.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
totalBytes += approxBytes;
|
||||||
|
if (totalBytes > MAX_TOTAL_BYTES) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
error: `Gesamtgröße aller Anhänge überschreitet ${MAX_TOTAL_BYTES / 1024 / 1024} MB.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
// E-Mail senden
|
// E-Mail senden
|
||||||
export async function sendEmailFromAccount(req: AuthRequest, res: Response): Promise<void> {
|
export async function sendEmailFromAccount(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -315,6 +489,18 @@ export async function sendEmailFromAccount(req: AuthRequest, res: Response): Pro
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pentest 97.1 + 97.2: Attachment-Validierung vor Buffer.from()
|
||||||
|
// (Format, Größe, Anzahl) – sonst leakte der rohe Node.js-Fehler
|
||||||
|
// in die Response und Limits waren nur Frontend-Doku.
|
||||||
|
const attachmentCheck = validateAttachments(attachments);
|
||||||
|
if (!('ok' in attachmentCheck)) {
|
||||||
|
res.status(attachmentCheck.status).json({
|
||||||
|
success: false,
|
||||||
|
error: attachmentCheck.error,
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// StressfreiEmail laden
|
// StressfreiEmail laden
|
||||||
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(stressfreiEmailId);
|
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(stressfreiEmailId);
|
||||||
|
|
||||||
@@ -568,24 +754,44 @@ export async function downloadAttachment(req: AuthRequest, res: Response): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Security: Content-Type aus IMAP kommt vom Absender und kann `text/html`
|
// Security: Content-Type aus IMAP kommt vom Absender und kann `text/html`
|
||||||
// o.ä. sein. Für inline-Preview nur eine Whitelist "harmloser" Typen
|
// o.ä. sein. Für inline-Preview verlässt sich der Server nicht auf den
|
||||||
// zulassen, sonst zwingend als Download (attachment) ausliefern, um XSS
|
// gemeldeten Type, sondern prüft die Magic-Bytes des Buffer-Inhalts.
|
||||||
// via inline-HTML-Anhang zu verhindern. Zusätzlich nosniff setzen.
|
// Real-world-Problem (intern gemeldet 2026-05-30): manche Mail-Clients
|
||||||
const INLINE_SAFE_TYPES = new Set([
|
// setzen für PDF-Anhänge `application/octet-stream` → unser alter
|
||||||
'application/pdf',
|
// Whitelist-Check fiel auf attachment zurück, der Browser öffnete
|
||||||
'image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp',
|
// trotz target="_blank" keinen neuen Tab. Mit Magic-Byte-Detection
|
||||||
'image/svg+xml' /* wird unten trotzdem als download erzwungen */,
|
// wird der echte Typ erkannt und inline-Preview klappt zuverlässig.
|
||||||
'text/plain',
|
const buf: Buffer = attachment.content;
|
||||||
]);
|
let detectedType: string | null = null;
|
||||||
const rawType = (attachment.contentType || 'application/octet-stream').toLowerCase();
|
if (buf.length >= 5 && buf.subarray(0, 5).toString('latin1') === '%PDF-') {
|
||||||
// SVG kann Skripte enthalten → niemals inline
|
detectedType = 'application/pdf';
|
||||||
const isSafeInline = INLINE_SAFE_TYPES.has(rawType) && rawType !== 'image/svg+xml';
|
} else if (buf.length >= 8 && buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
|
||||||
|
detectedType = 'image/png';
|
||||||
|
} else if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
|
||||||
|
detectedType = 'image/jpeg';
|
||||||
|
} else if (buf.length >= 6 && (buf.subarray(0, 6).toString('latin1') === 'GIF87a' || buf.subarray(0, 6).toString('latin1') === 'GIF89a')) {
|
||||||
|
detectedType = 'image/gif';
|
||||||
|
} else if (buf.length >= 12 && buf.subarray(0, 4).toString('latin1') === 'RIFF' && buf.subarray(8, 12).toString('latin1') === 'WEBP') {
|
||||||
|
detectedType = 'image/webp';
|
||||||
|
} else if (buf.length >= 1 && (attachment.contentType || '').toLowerCase().startsWith('text/plain')) {
|
||||||
|
// text/plain hat keine eindeutige Magic-Byte – akzeptieren wenn
|
||||||
|
// der IMAP-Header das so meldet und Inhalt nur druckbare ASCII/UTF-8 ist.
|
||||||
|
// Konservative Prüfung: keine HTML-Tag-Anfänge.
|
||||||
|
const sample = buf.subarray(0, Math.min(buf.length, 256)).toString('utf8');
|
||||||
|
if (!/<[a-z!\/?]/i.test(sample)) {
|
||||||
|
detectedType = 'text/plain; charset=utf-8';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const isSafeInline = detectedType !== null;
|
||||||
const requestedDisposition = req.query.view === 'true' ? 'inline' : 'attachment';
|
const requestedDisposition = req.query.view === 'true' ? 'inline' : 'attachment';
|
||||||
const disposition = requestedDisposition === 'inline' && isSafeInline ? 'inline' : 'attachment';
|
const disposition = requestedDisposition === 'inline' && isSafeInline ? 'inline' : 'attachment';
|
||||||
// Filename: Steuerzeichen entfernen (CRLF-Injection in Header)
|
// Filename: Steuerzeichen entfernen (CRLF-Injection in Header)
|
||||||
const safeFilename = (attachment.filename || 'attachment').replace(/[\r\n"\\]/g, '_');
|
const safeFilename = (attachment.filename || 'attachment').replace(/[\r\n"\\]/g, '_');
|
||||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
res.setHeader('Content-Type', isSafeInline ? rawType : 'application/octet-stream');
|
// Bei sicherem Inline-Typ: erkannten Type setzen (überschreibt
|
||||||
|
// eventuell falsches application/octet-stream aus IMAP). Sonst
|
||||||
|
// octet-stream erzwingen, damit der Browser nichts erraten kann.
|
||||||
|
res.setHeader('Content-Type', isSafeInline ? detectedType! : 'application/octet-stream');
|
||||||
res.setHeader('Content-Disposition', `${disposition}; filename="${encodeURIComponent(safeFilename)}"`);
|
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);
|
||||||
@@ -850,13 +1056,25 @@ export async function deleteEmail(req: AuthRequest, res: Response): Promise<void
|
|||||||
|
|
||||||
// ==================== TRASH OPERATIONS ====================
|
// ==================== TRASH OPERATIONS ====================
|
||||||
|
|
||||||
// Papierkorb-E-Mails für einen Kunden abrufen
|
// Papierkorb-E-Mails für einen Kunden abrufen.
|
||||||
|
// Optional `accountId` (Postfach-Filter) und `contractId` (Vertrags-Filter)
|
||||||
|
// – beide aus User-Bug 2026-06-21. Wenn beide leer sind, Verhalten wie
|
||||||
|
// vorher: alle gelöschten E-Mails des Kunden.
|
||||||
export async function getTrashEmails(req: AuthRequest, res: Response): Promise<void> {
|
export async function getTrashEmails(req: AuthRequest, 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;
|
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||||
|
// Trash auf Kundenebene: Filter sind optional (Cross-Mailbox-Trash-
|
||||||
|
// View ist legitim), invalide Werte → 400.
|
||||||
|
const stressfreiEmailId = parsePositiveIntQuery(req.query.accountId, 'accountId', res);
|
||||||
|
if (stressfreiEmailId === null) return;
|
||||||
|
const contractId = parsePositiveIntQuery(req.query.contractId, 'contractId', res);
|
||||||
|
if (contractId === null) return;
|
||||||
|
|
||||||
const emails = await cachedEmailService.getTrashEmails(customerId);
|
const emails = await cachedEmailService.getTrashEmails(customerId, {
|
||||||
|
stressfreiEmailId,
|
||||||
|
contractId,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({ success: true, data: emails } as ApiResponse);
|
res.json({ success: true, data: emails } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -868,13 +1086,20 @@ export async function getTrashEmails(req: AuthRequest, res: Response): Promise<v
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Papierkorb-Anzahl für einen Kunden
|
// Papierkorb-Anzahl für einen Kunden (gleiche Filter wie getTrashEmails)
|
||||||
export async function getTrashCount(req: AuthRequest, res: Response): Promise<void> {
|
export async function getTrashCount(req: AuthRequest, 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;
|
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||||
|
const stressfreiEmailId = parsePositiveIntQuery(req.query.accountId, 'accountId', res);
|
||||||
|
if (stressfreiEmailId === null) return;
|
||||||
|
const contractId = parsePositiveIntQuery(req.query.contractId, 'contractId', res);
|
||||||
|
if (contractId === null) return;
|
||||||
|
|
||||||
const count = await cachedEmailService.getTrashCount(customerId);
|
const count = await cachedEmailService.getTrashCount(customerId, {
|
||||||
|
stressfreiEmailId,
|
||||||
|
contractId,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({ success: true, data: { count } } as ApiResponse);
|
res.json({ success: true, data: { count } } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1251,6 +1476,9 @@ export async function saveAttachmentTo(req: AuthRequest, res: Response): Promise
|
|||||||
const filePath = path.join(uploadsDir, newFilename);
|
const filePath = path.join(uploadsDir, newFilename);
|
||||||
const relativePath = `/uploads/${targetDir}/${newFilename}`;
|
const relativePath = `/uploads/${targetDir}/${newFilename}`;
|
||||||
|
|
||||||
|
// Pentest 68.1: PDF-Anhänge auf aktive Inhalte scannen (JS/Launch/Embed).
|
||||||
|
assertSafePdf(attachment.content);
|
||||||
|
|
||||||
// Datei speichern
|
// Datei speichern
|
||||||
fs.writeFileSync(filePath, attachment.content);
|
fs.writeFileSync(filePath, attachment.content);
|
||||||
|
|
||||||
@@ -1401,8 +1629,9 @@ export async function saveAttachmentTo(req: AuthRequest, res: Response): Promise
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('saveAttachmentTo error:', error);
|
console.error('saveAttachmentTo error:', error);
|
||||||
// Detailliertere Fehlermeldung für Debugging
|
// Detailliertere Fehlermeldung für Debugging
|
||||||
|
const status = error instanceof ApiError ? error.statusCode : 500;
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||||||
res.status(500).json({
|
res.status(status).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: `Fehler beim Speichern des Anhangs: ${errorMessage}`,
|
error: `Fehler beim Speichern des Anhangs: ${errorMessage}`,
|
||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
@@ -1784,6 +2013,113 @@ export async function saveEmailAsInvoice(req: AuthRequest, res: Response): Promi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== SAVE EMAIL AS CONTRACT DOCUMENT ====================
|
||||||
|
|
||||||
|
// E-Mail als PDF exportieren und als Vertragsdokument hinterlegen.
|
||||||
|
// Parallel zu saveAttachmentAsContractDocument (Anhang-Variante) – damit
|
||||||
|
// auch reine Mail-Bestätigungen ohne Anhang als Auftragsformular/
|
||||||
|
// Lieferbestätigung etc. an einem Vertrag landen können.
|
||||||
|
export async function saveEmailAsContractDocument(req: AuthRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const emailId = parseInt(req.params.id);
|
||||||
|
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
||||||
|
const { documentType, notes } = req.body;
|
||||||
|
|
||||||
|
// Pentest 58.1: Whitelist-Validierung des documentType.
|
||||||
|
let validatedType: string;
|
||||||
|
try {
|
||||||
|
validatedType = validateContractDocumentType(documentType);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültiger documentType' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pentest 62.7: deliveryDate früh validieren, bevor wir Dateien schreiben.
|
||||||
|
let deliveryDate: string | null;
|
||||||
|
try {
|
||||||
|
deliveryDate = validateOptionalIsoDate(req.body?.deliveryDate, 'deliveryDate');
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültiges Lieferdatum' } 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;
|
||||||
|
}
|
||||||
|
if (!(await canAccessContract(req as AuthRequest, res, contract.id))) return;
|
||||||
|
|
||||||
|
// Empfänger-Adressen parsen
|
||||||
|
let toAddresses: string[] = [];
|
||||||
|
let ccAddresses: string[] = [];
|
||||||
|
try { toAddresses = JSON.parse(email.toAddresses); } catch { toAddresses = [email.toAddresses]; }
|
||||||
|
try { if (email.ccAddresses) ccAddresses = JSON.parse(email.ccAddresses); } catch { /* ignore */ }
|
||||||
|
|
||||||
|
const pdfBuffer = await generateEmailPdf({
|
||||||
|
from: email.fromAddress,
|
||||||
|
to: toAddresses.join(', '),
|
||||||
|
cc: ccAddresses.length > 0 ? ccAddresses.join(', ') : undefined,
|
||||||
|
subject: email.subject || '(Kein Betreff)',
|
||||||
|
date: email.receivedAt,
|
||||||
|
bodyText: email.textBody || undefined,
|
||||||
|
bodyHtml: email.htmlBody || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadsDir = path.join(process.cwd(), 'uploads', 'contract-documents');
|
||||||
|
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
|
||||||
|
|
||||||
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||||
|
const safeType = validatedType.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
||||||
|
const newFilename = `${safeType}-email-${uniqueSuffix}.pdf`;
|
||||||
|
const filePath = path.join(uploadsDir, newFilename);
|
||||||
|
const relativePath = `/uploads/contract-documents/${newFilename}`;
|
||||||
|
|
||||||
|
// Pentest 55.4: Lock vor Schreiben, damit parallele Requests blockieren.
|
||||||
|
// PDF erst INNERHALB des Locks auf Disk schreiben → kein verwaister
|
||||||
|
// Datei-Müll bei Race-Reject.
|
||||||
|
const doc = await withContractDocumentLock(contract.id, validatedType, async () => {
|
||||||
|
fs.writeFileSync(filePath, pdfBuffer);
|
||||||
|
return prisma.contractDocument.create({
|
||||||
|
data: {
|
||||||
|
contractId: contract.id,
|
||||||
|
documentType: validatedType,
|
||||||
|
documentPath: relativePath,
|
||||||
|
originalName: `${email.subject || 'email'}.pdf`,
|
||||||
|
notes: sanitizeNotes(notes),
|
||||||
|
uploadedBy: (req as any).user?.email || 'email-import',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer.
|
||||||
|
// deliveryDate wurde oben schon validiert (Pentest 62.7).
|
||||||
|
await maybeActivateOnDeliveryConfirmation(contract.id, validatedType, req, deliveryDate);
|
||||||
|
|
||||||
|
res.json({ success: true, data: doc } as ApiResponse);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('saveEmailAsContractDocument error:', error);
|
||||||
|
// Pentest 64.1: ApiError mit eigenem statusCode (z.B. 400 vom Race-
|
||||||
|
// Lock) statt pauschal 500.
|
||||||
|
const status = error instanceof ApiError ? error.statusCode : 500;
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||||||
|
res.status(status).json({ success: false, error: `Fehler beim Speichern: ${errorMessage}` } as ApiResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== SAVE ATTACHMENT AS INVOICE ====================
|
// ==================== SAVE ATTACHMENT AS INVOICE ====================
|
||||||
|
|
||||||
// E-Mail-Anhang als Rechnung speichern
|
// E-Mail-Anhang als Rechnung speichern
|
||||||
@@ -1915,6 +2251,9 @@ export async function saveAttachmentAsInvoice(req: AuthRequest, res: Response):
|
|||||||
const filePath = path.join(uploadsDir, newFilename);
|
const filePath = path.join(uploadsDir, newFilename);
|
||||||
const relativePath = `/uploads/invoices/${newFilename}`;
|
const relativePath = `/uploads/invoices/${newFilename}`;
|
||||||
|
|
||||||
|
// Pentest 68.1: PDF-Anhänge auf aktive Inhalte scannen.
|
||||||
|
assertSafePdf(attachment.content);
|
||||||
|
|
||||||
// Datei speichern
|
// Datei speichern
|
||||||
fs.writeFileSync(filePath, attachment.content);
|
fs.writeFileSync(filePath, attachment.content);
|
||||||
|
|
||||||
@@ -1939,8 +2278,9 @@ export async function saveAttachmentAsInvoice(req: AuthRequest, res: Response):
|
|||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('saveAttachmentAsInvoice error:', error);
|
console.error('saveAttachmentAsInvoice error:', error);
|
||||||
|
const status = error instanceof ApiError ? error.statusCode : 500;
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||||||
res.status(500).json({
|
res.status(status).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: `Fehler beim Erstellen der Rechnung: ${errorMessage}`,
|
error: `Fehler beim Erstellen der Rechnung: ${errorMessage}`,
|
||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
@@ -1958,14 +2298,27 @@ export async function saveAttachmentAsContractDocument(req: AuthRequest, res: Re
|
|||||||
const filename = decodeURIComponent(req.params.filename);
|
const filename = decodeURIComponent(req.params.filename);
|
||||||
const { documentType, notes } = req.body;
|
const { documentType, notes } = req.body;
|
||||||
|
|
||||||
if (!documentType || typeof documentType !== 'string') {
|
// Pentest 58.1: Whitelist-Validierung des documentType.
|
||||||
|
let validatedType: string;
|
||||||
|
try {
|
||||||
|
validatedType = validateContractDocumentType(documentType);
|
||||||
|
} catch (err) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'documentType ist erforderlich',
|
error: err instanceof Error ? err.message : 'Ungültiger documentType',
|
||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pentest 62.7: deliveryDate früh validieren, bevor wir Dateien schreiben.
|
||||||
|
let deliveryDate: string | null;
|
||||||
|
try {
|
||||||
|
deliveryDate = validateOptionalIsoDate(req.body?.deliveryDate, 'deliveryDate');
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültiges Lieferdatum' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const email = await cachedEmailService.getCachedEmailById(emailId);
|
const email = await cachedEmailService.getCachedEmailById(emailId);
|
||||||
if (!email) {
|
if (!email) {
|
||||||
res.status(404).json({ success: false, error: 'E-Mail nicht gefunden' } as ApiResponse);
|
res.status(404).json({ success: false, error: 'E-Mail nicht gefunden' } as ApiResponse);
|
||||||
@@ -2051,33 +2404,40 @@ export async function saveAttachmentAsContractDocument(req: AuthRequest, res: Re
|
|||||||
|
|
||||||
const ext = path.extname(filename) || '.pdf';
|
const ext = path.extname(filename) || '.pdf';
|
||||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||||
const safeType = documentType.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
const safeType = validatedType.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
||||||
const newFilename = `${safeType}-${uniqueSuffix}${ext}`;
|
const newFilename = `${safeType}-${uniqueSuffix}${ext}`;
|
||||||
const filePath = path.join(uploadsDir, newFilename);
|
const filePath = path.join(uploadsDir, newFilename);
|
||||||
const relativePath = `/uploads/contract-documents/${newFilename}`;
|
const relativePath = `/uploads/contract-documents/${newFilename}`;
|
||||||
|
|
||||||
fs.writeFileSync(filePath, attachment.content);
|
// Pentest 68.1: PDF-Anhänge auf aktive Inhalte scannen.
|
||||||
|
assertSafePdf(attachment.content);
|
||||||
|
|
||||||
const doc = await prisma.contractDocument.create({
|
// Pentest 55.4: Lock vor Schreiben (siehe saveEmailAsContractDocument).
|
||||||
data: {
|
const doc = await withContractDocumentLock(contract.id, validatedType, async () => {
|
||||||
contractId: contract.id,
|
fs.writeFileSync(filePath, attachment.content);
|
||||||
documentType,
|
return prisma.contractDocument.create({
|
||||||
documentPath: relativePath,
|
data: {
|
||||||
originalName: filename,
|
contractId: contract.id,
|
||||||
notes: notes || null,
|
documentType: validatedType,
|
||||||
uploadedBy: (req as any).user?.email || 'email-import',
|
documentPath: relativePath,
|
||||||
},
|
originalName: filename,
|
||||||
|
notes: sanitizeNotes(notes),
|
||||||
|
uploadedBy: (req as any).user?.email || 'email-import',
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer
|
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer.
|
||||||
const deliveryDate = typeof req.body?.deliveryDate === 'string' ? req.body.deliveryDate : null;
|
// deliveryDate wurde oben schon validiert (Pentest 62.7).
|
||||||
await maybeActivateOnDeliveryConfirmation(contract.id, documentType, req, deliveryDate);
|
await maybeActivateOnDeliveryConfirmation(contract.id, validatedType, req, deliveryDate);
|
||||||
|
|
||||||
res.json({ success: true, data: doc } as ApiResponse);
|
res.json({ success: true, data: doc } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('saveAttachmentAsContractDocument error:', error);
|
console.error('saveAttachmentAsContractDocument error:', error);
|
||||||
|
// Pentest 64.1: ApiError mit eigenem statusCode statt pauschal 500.
|
||||||
|
const status = error instanceof ApiError ? error.statusCode : 500;
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||||||
res.status(500).json({
|
res.status(status).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: `Fehler beim Speichern: ${errorMessage}`,
|
error: `Fehler beim Speichern: ${errorMessage}`,
|
||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
|
|||||||
@@ -1,14 +1,76 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
import fs from 'fs';
|
||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import * as contractService from '../services/contract.service.js';
|
import * as contractService from '../services/contract.service.js';
|
||||||
import * as contractCockpitService from '../services/contractCockpit.service.js';
|
import * as contractCockpitService from '../services/contractCockpit.service.js';
|
||||||
import * as contractHistoryService from '../services/contractHistory.service.js';
|
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 { recordPredecessorFinalReading } from '../services/customer.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 { sanitizeContract, sanitizeContractStrict, sanitizeContracts, sanitizeContractsStrict } from '../utils/sanitize.js';
|
import { sanitizeContract, sanitizeContractStrict, sanitizeContracts, sanitizeContractsStrict, stripHtml, sanitizeNotes, validateContractDocumentType, validateOptionalIsoDate, isContractIdentifierField, validateContractIdentifier, validatePortalUsername } from '../utils/sanitize.js';
|
||||||
|
import { ApiError } from '../utils/apiError.js';
|
||||||
import { canAccessContract } from '../utils/accessControl.js';
|
import { canAccessContract } from '../utils/accessControl.js';
|
||||||
import { maybeActivateOnDeliveryConfirmation } from '../services/contractStatusScheduler.service.js';
|
import { maybeActivateOnDeliveryConfirmation, withContractDocumentLock } from '../services/contractStatusScheduler.service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk-and-clean: strippt HTML/Script-/URI-Schemata in allen String-Werten
|
||||||
|
* eines Body-Objekts (rekursiv über energyDetails, internetDetails etc.).
|
||||||
|
* Pentest 2026-05-24 (MEDIUM, 31.1): providerName, tariffName und die
|
||||||
|
* price*-Felder nahmen rohe HTML-Payloads an (`<script>`, `<svg onload>`)
|
||||||
|
* und lieferten sie 1:1 an Portal-User zurück. Verträge enthalten KEINE
|
||||||
|
* HTML-Felder (Richtige HTML-Texte liegen in AppSettings), deshalb ist
|
||||||
|
* Strip safe.
|
||||||
|
*
|
||||||
|
* AUSNAHME: Passwort-/Secret-Felder. `stripHtml` filtert `<…>`-Sequenzen
|
||||||
|
* und URI-Schemata wie `data:`, also würde ein PW wie `Pass<TAG>word!`
|
||||||
|
* zu `Password!` mutilieren oder `data:secret` zu `blocked:secret`.
|
||||||
|
* Das Passwort wird sowieso verschlüsselt persistiert (`encrypt()`),
|
||||||
|
* niemals als HTML ausgegeben – also kein XSS-Risk, und die Mangling
|
||||||
|
* ist ein Bug (2026-05-27, intern gemeldet: "Portal-Passwörter werden
|
||||||
|
* nicht gespeichert").
|
||||||
|
*/
|
||||||
|
const PASSTHROUGH_KEYS = new Set(['portalPassword', 'password']);
|
||||||
|
|
||||||
|
function sanitizeContractBody(body: unknown, parentKey?: string): unknown {
|
||||||
|
if (body === null || body === undefined) return body;
|
||||||
|
if (typeof body === 'string') {
|
||||||
|
if (parentKey && PASSTHROUGH_KEYS.has(parentKey)) return body;
|
||||||
|
// Pentest 86.1/86.2 (LOW, 2026-06-19): Längen- + Whitelist-Check auf
|
||||||
|
// Kunden-/Vertrags-/Auftragsnummer-Feldern. validateContractIdentifier
|
||||||
|
// wirft ApiError(400) bei Verstoß → saubere 400-Antwort statt 500.
|
||||||
|
//
|
||||||
|
// Pentest 87.1 (LOW, 2026-06-19): Identifier-Felder MÜSSEN gegen den
|
||||||
|
// Raw-Input geprüft werden, NICHT gegen den stripHtml-Output. Sonst
|
||||||
|
// verschluckt der Sanitizer Tag-Verstöße still: `<b>bold</b>` würde
|
||||||
|
// als `"bold"` mit 200 OK durchgehen, `<script>alert(1)</script>`
|
||||||
|
// sogar zu `null` und damit den vorherigen Wert überschreiben.
|
||||||
|
// Die strikte Whitelist (`^[A-Za-z0-9_\-/. ]{0,100}$`) deckt alle
|
||||||
|
// Bypässe ab, die stripHtml normalerweise auffangen würde
|
||||||
|
// (Tags, Schemes, Zero-Width-Chars, Homoglyphe, Percent-Encoding) –
|
||||||
|
// sie sind alle nicht in der Allowlist und fliegen mit 400 raus.
|
||||||
|
if (parentKey && isContractIdentifierField(parentKey)) {
|
||||||
|
return validateContractIdentifier(body, parentKey);
|
||||||
|
}
|
||||||
|
// Pentest 95.1/95.3/95.4 (LOW–MEDIUM, 2026-06-21): portalUsername
|
||||||
|
// (Manual-Modus) hatte gar keine Validierung – CRLF/Header-Injection,
|
||||||
|
// silent stripHtml-Mutation und VARCHAR-Overflow möglich. Gleiches
|
||||||
|
// Raw-Input-Pattern wie R87.
|
||||||
|
if (parentKey === 'portalUsername') {
|
||||||
|
return validatePortalUsername(body, parentKey);
|
||||||
|
}
|
||||||
|
return stripHtml(body);
|
||||||
|
}
|
||||||
|
if (Array.isArray(body)) return body.map((v) => sanitizeContractBody(v, parentKey));
|
||||||
|
if (typeof body === 'object') {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(body as Record<string, unknown>)) {
|
||||||
|
out[k] = sanitizeContractBody(v, k);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getContracts(req: AuthRequest, res: Response): Promise<void> {
|
export async function getContracts(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -122,7 +184,8 @@ export async function createContract(req: AuthRequest, res: Response): Promise<v
|
|||||||
res.status(400).json({ success: false, error: 'Kunde (customerId) ist erforderlich' } as ApiResponse);
|
res.status(400).json({ success: false, error: 'Kunde (customerId) ist erforderlich' } as ApiResponse);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const contract = await contractService.createContract(req.body);
|
const sanitizedBody = sanitizeContractBody(body);
|
||||||
|
const contract = await contractService.createContract(sanitizedBody as any);
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'CREATE', resourceType: 'Contract',
|
req, action: 'CREATE', resourceType: 'Contract',
|
||||||
resourceId: contract.id.toString(),
|
resourceId: contract.id.toString(),
|
||||||
@@ -143,19 +206,30 @@ export async function createContract(req: AuthRequest, res: Response): Promise<v
|
|||||||
export async function updateContract(req: AuthRequest, res: Response): Promise<void> {
|
export async function updateContract(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const contractId = parseInt(req.params.id);
|
const contractId = parseInt(req.params.id);
|
||||||
|
// Pentest 56.3 (latent, 2026-06-01): Defense-in-Depth –
|
||||||
|
// canAccessContract explizit aufrufen, statt sich nur auf die
|
||||||
|
// Route-Permission zu verlassen. Portal-User mit kompromittierter
|
||||||
|
// Token-Permission würden sonst beliebige Verträge editieren können.
|
||||||
|
if (!(await canAccessContract(req, res, contractId))) return;
|
||||||
// Vorherigen Stand laden für Audit-Vergleich
|
// Vorherigen Stand laden für Audit-Vergleich
|
||||||
const before = await prisma.contract.findUnique({
|
const before = await prisma.contract.findUnique({
|
||||||
where: { id: contractId },
|
where: { id: contractId },
|
||||||
include: { energyDetails: true, internetDetails: true, mobileDetails: true, tvDetails: true, carInsuranceDetails: true },
|
include: { energyDetails: true, internetDetails: true, mobileDetails: true, tvDetails: true, carInsuranceDetails: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const contract = await contractService.updateContract(contractId, req.body);
|
// HTML/JS-Strip auf allen String-Werten (Pentest 2026-05-24, 31.1)
|
||||||
|
const sanitizedBody = sanitizeContractBody(req.body);
|
||||||
|
const contract = await contractService.updateContract(contractId, sanitizedBody as any);
|
||||||
|
|
||||||
// Geänderte Felder ermitteln
|
// Geänderte Felder ermitteln
|
||||||
const changes: Record<string, { von: unknown; nach: unknown }> = {};
|
const changes: Record<string, { von: unknown; nach: unknown }> = {};
|
||||||
const fieldLabels: Record<string, string> = {
|
const fieldLabels: Record<string, string> = {
|
||||||
status: 'Status', startDate: 'Vertragsbeginn', endDate: 'Vertragsende',
|
status: 'Status', startDate: 'Vertragsbeginn', endDate: 'Vertragsende',
|
||||||
portalUsername: 'Portal-Benutzername', customerNumberAtProvider: 'Kundennummer beim Anbieter',
|
portalUsername: 'Portal-Benutzername', customerNumberAtProvider: 'Kundennummer beim Anbieter',
|
||||||
|
contractNumberAtProvider: 'Vertragsnummer beim Anbieter',
|
||||||
|
orderNumberAtSalesPlatform: 'Auftragsnummer bei Vertriebsplattform',
|
||||||
|
customerNumberAtSalesPlatform: 'Kundennummer bei Vertriebsplattform',
|
||||||
|
contractNumberAtSalesPlatform: 'Vertragsnummer bei Vertriebsplattform',
|
||||||
providerId: 'Anbieter', tariffId: 'Tarif', cancellationPeriodId: 'Kündigungsfrist',
|
providerId: 'Anbieter', tariffId: 'Tarif', cancellationPeriodId: 'Kündigungsfrist',
|
||||||
contractDurationId: 'Vertragslaufzeit', platformId: 'Vertriebsplattform',
|
contractDurationId: 'Vertragslaufzeit', platformId: 'Vertriebsplattform',
|
||||||
cancellationDate: 'Kündigungsdatum', cancellationSentDate: 'Kündigung gesendet am',
|
cancellationDate: 'Kündigungsdatum', cancellationSentDate: 'Kündigung gesendet am',
|
||||||
@@ -164,11 +238,14 @@ export async function updateContract(req: AuthRequest, res: Response): Promise<v
|
|||||||
};
|
};
|
||||||
const energyLabels: Record<string, string> = {
|
const energyLabels: Record<string, string> = {
|
||||||
meterId: 'Zähler', maloId: 'MaLo-ID', annualConsumption: 'Jahresverbrauch',
|
meterId: 'Zähler', maloId: 'MaLo-ID', annualConsumption: 'Jahresverbrauch',
|
||||||
basePrice: 'Grundpreis', unitPrice: 'Arbeitspreis', unitPriceNt: 'NT-Arbeitspreis', bonus: 'Bonus',
|
basePrice: 'Grundpreis', unitPrice: 'Arbeitspreis', unitPriceNt: 'NT-Arbeitspreis',
|
||||||
|
instantBonus: 'Sofort-Bonus', newCustomerBonus: 'Neukunden-Bonus',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Hauptfelder vergleichen
|
// Hauptfelder vergleichen – gegen die SANITISIERTE Version, damit
|
||||||
const body = req.body;
|
// das Audit-Log die echten DB-Werte widerspiegelt, nicht den
|
||||||
|
// rohen Request-Body mit ggf. gestrippter HTML.
|
||||||
|
const body = sanitizedBody as any;
|
||||||
if (before) {
|
if (before) {
|
||||||
for (const [key, newVal] of Object.entries(body)) {
|
for (const [key, newVal] of Object.entries(body)) {
|
||||||
if (['energyDetails', 'internetDetails', 'mobileDetails', 'tvDetails', 'carInsuranceDetails', 'password'].includes(key)) continue;
|
if (['energyDetails', 'internetDetails', 'mobileDetails', 'tvDetails', 'carInsuranceDetails', 'password'].includes(key)) continue;
|
||||||
@@ -221,6 +298,8 @@ export async function updateContract(req: AuthRequest, res: Response): Promise<v
|
|||||||
export async function deleteContract(req: Request, res: Response): Promise<void> {
|
export async function deleteContract(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const contractId = parseInt(req.params.id);
|
const contractId = parseInt(req.params.id);
|
||||||
|
// Pentest 56.3 (latent): Defense-in-Depth – Ownership-Check vor Delete.
|
||||||
|
if (!(await canAccessContract(req as AuthRequest, res, contractId))) return;
|
||||||
const contract = await prisma.contract.findUnique({ where: { id: contractId }, select: { contractNumber: true, customerId: true } });
|
const contract = await prisma.contract.findUnique({ where: { id: contractId }, select: { contractNumber: true, customerId: true } });
|
||||||
await contractService.deleteContract(contractId);
|
await contractService.deleteContract(contractId);
|
||||||
await logChange({
|
await logChange({
|
||||||
@@ -496,7 +575,7 @@ export async function getCockpit(req: AuthRequest, res: Response): Promise<void>
|
|||||||
export async function addSuccessorMeter(req: AuthRequest, res: Response): Promise<void> {
|
export async function addSuccessorMeter(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const contractId = parseInt(req.params.id);
|
const contractId = parseInt(req.params.id);
|
||||||
const { meterId, installedAt, finalReadingPrevious } = req.body;
|
const { meterId, installedAt, finalReadingPrevious, deactivatePredecessor } = req.body;
|
||||||
|
|
||||||
const contract = await prisma.contract.findUnique({
|
const contract = await prisma.contract.findUnique({
|
||||||
where: { id: contractId },
|
where: { id: contractId },
|
||||||
@@ -509,19 +588,72 @@ export async function addSuccessorMeter(req: AuthRequest, res: Response): Promis
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ecdId = contract.energyDetails.id;
|
const ecdId = contract.energyDetails.id;
|
||||||
const existingMeters = contract.energyDetails.contractMeters;
|
const existingMeters = [...contract.energyDetails.contractMeters];
|
||||||
|
const switchAt = installedAt ? new Date(installedAt) : new Date();
|
||||||
|
|
||||||
|
// Vorgänger ermitteln (letzter ContractMeter oder Single-Meter-Vertrag)
|
||||||
|
const predecessorMeterId = existingMeters.length > 0
|
||||||
|
? existingMeters[existingMeters.length - 1].meterId
|
||||||
|
: contract.energyDetails.meterId;
|
||||||
|
|
||||||
|
// Endstand bereits hier validieren (monoton-steigend gegen vorhandene
|
||||||
|
// Zählerstände des Vorgängers), damit wir nicht halb-geschriebene
|
||||||
|
// Zustände hinterlassen.
|
||||||
|
if (finalReadingPrevious !== undefined && finalReadingPrevious !== null && predecessorMeterId) {
|
||||||
|
const finalReadingValue = parseFloat(finalReadingPrevious);
|
||||||
|
// recordPredecessorFinalReading läuft erst NACH den Writes – Pre-Check
|
||||||
|
// ohne Write hier separat über die Service-Validierung (idempotent, weil
|
||||||
|
// sie keinen Reading anlegt, wenn am Wechseltag schon einer existiert).
|
||||||
|
// Wir lassen den eigentlichen Write am Ende laufen, damit ein Fehler
|
||||||
|
// beim Reading die Kette nicht zerreißt.
|
||||||
|
const dayStart = new Date(switchAt); dayStart.setHours(0, 0, 0, 0);
|
||||||
|
const dayEnd = new Date(dayStart); dayEnd.setDate(dayEnd.getDate() + 1);
|
||||||
|
const sameDay = await prisma.meterReading.findFirst({
|
||||||
|
where: { meterId: predecessorMeterId, readingDate: { gte: dayStart, lt: dayEnd } },
|
||||||
|
});
|
||||||
|
if (!sameDay) {
|
||||||
|
const lastBefore = await prisma.meterReading.findFirst({
|
||||||
|
where: { meterId: predecessorMeterId, readingDate: { lte: switchAt } },
|
||||||
|
orderBy: { readingDate: 'desc' },
|
||||||
|
});
|
||||||
|
if (lastBefore && finalReadingValue < lastBefore.value) {
|
||||||
|
const fmtDate = (d: Date) => d.toLocaleDateString('de-DE');
|
||||||
|
throw new Error(`Endstand (${finalReadingValue}) darf nicht kleiner sein als der Stand vom ${fmtDate(lastBefore.readingDate)} (${lastBefore.value})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backfill: Bei Single-Meter-Verträgen (kein ContractMeter-Eintrag) den
|
||||||
|
// bisherigen `energyDetails.meterId` als position 0 nachtragen, damit die
|
||||||
|
// Folgezähler-Kette lückenlos ist und der alte Zähler nicht aus dem
|
||||||
|
// Vertrag verschwindet, wenn `energyDetails.meterId` gleich auf den Neuen
|
||||||
|
// gedreht wird.
|
||||||
|
if (existingMeters.length === 0 && contract.energyDetails.meterId) {
|
||||||
|
const backfilled = await prisma.contractMeter.create({
|
||||||
|
data: {
|
||||||
|
energyContractDetailsId: ecdId,
|
||||||
|
meterId: contract.energyDetails.meterId,
|
||||||
|
position: 0,
|
||||||
|
installedAt: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
existingMeters.push(backfilled);
|
||||||
|
}
|
||||||
|
|
||||||
const nextPosition = existingMeters.length > 0
|
const nextPosition = existingMeters.length > 0
|
||||||
? Math.max(...existingMeters.map(m => m.position)) + 1
|
? Math.max(...existingMeters.map(m => m.position)) + 1
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
// Vorherigen Zähler als gewechselt markieren
|
// Vorherigen Zähler als gewechselt markieren
|
||||||
if (existingMeters.length > 0 && finalReadingPrevious !== undefined) {
|
if (existingMeters.length > 0) {
|
||||||
const prevMeter = existingMeters[existingMeters.length - 1];
|
const prevMeter = existingMeters[existingMeters.length - 1];
|
||||||
await prisma.contractMeter.update({
|
await prisma.contractMeter.update({
|
||||||
where: { id: prevMeter.id },
|
where: { id: prevMeter.id },
|
||||||
data: {
|
data: {
|
||||||
removedAt: installedAt ? new Date(installedAt) : new Date(),
|
removedAt: switchAt,
|
||||||
finalReading: parseFloat(finalReadingPrevious),
|
finalReading: finalReadingPrevious !== undefined
|
||||||
|
? parseFloat(finalReadingPrevious)
|
||||||
|
: prevMeter.finalReading,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -531,7 +663,7 @@ export async function addSuccessorMeter(req: AuthRequest, res: Response): Promis
|
|||||||
energyContractDetailsId: ecdId,
|
energyContractDetailsId: ecdId,
|
||||||
meterId: parseInt(meterId),
|
meterId: parseInt(meterId),
|
||||||
position: nextPosition,
|
position: nextPosition,
|
||||||
installedAt: installedAt ? new Date(installedAt) : new Date(),
|
installedAt: switchAt,
|
||||||
},
|
},
|
||||||
include: { meter: { include: { readings: true } } },
|
include: { meter: { include: { readings: true } } },
|
||||||
});
|
});
|
||||||
@@ -542,6 +674,25 @@ export async function addSuccessorMeter(req: AuthRequest, res: Response): Promis
|
|||||||
data: { meterId: parseInt(meterId) },
|
data: { meterId: parseInt(meterId) },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Endstand des Vorgängers als regulären Zählerstand erfassen, damit er in
|
||||||
|
// die Verbrauchsberechnung einfließt und in der Zählerstände-Liste auftaucht.
|
||||||
|
if (finalReadingPrevious !== undefined && finalReadingPrevious !== null && predecessorMeterId) {
|
||||||
|
await recordPredecessorFinalReading(
|
||||||
|
predecessorMeterId,
|
||||||
|
switchAt,
|
||||||
|
parseFloat(finalReadingPrevious),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alten Zähler deaktivieren (Default), sofern der Aufrufer das nicht
|
||||||
|
// explizit auf false setzt – ein-klick-fähiger Zählerwechsel.
|
||||||
|
if (predecessorMeterId && deactivatePredecessor !== false) {
|
||||||
|
await prisma.meter.update({
|
||||||
|
where: { id: predecessorMeterId },
|
||||||
|
data: { isActive: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'CREATE', resourceType: 'ContractMeter',
|
req, action: 'CREATE', resourceType: 'ContractMeter',
|
||||||
resourceId: contractMeter.id.toString(),
|
resourceId: contractMeter.id.toString(),
|
||||||
@@ -599,7 +750,7 @@ export async function uploadContractDocument(req: AuthRequest, res: Response): P
|
|||||||
try {
|
try {
|
||||||
const contractId = parseInt(req.params.id);
|
const contractId = parseInt(req.params.id);
|
||||||
if (!(await canAccessContract(req, res, contractId))) return;
|
if (!(await canAccessContract(req, res, contractId))) return;
|
||||||
const { documentType, notes, deliveryDate } = req.body;
|
const { documentType, notes } = 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);
|
||||||
@@ -611,33 +762,62 @@ export async function uploadContractDocument(req: AuthRequest, res: Response): P
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pentest 62.7: deliveryDate validieren (ISO-8601 oder null).
|
||||||
|
let deliveryDate: string | null;
|
||||||
|
try {
|
||||||
|
deliveryDate = validateOptionalIsoDate(req.body?.deliveryDate, 'deliveryDate');
|
||||||
|
} catch (err) {
|
||||||
|
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
|
||||||
|
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültiges Lieferdatum' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const documentPath = `/uploads/contract-documents/${req.file.filename}`;
|
const documentPath = `/uploads/contract-documents/${req.file.filename}`;
|
||||||
const doc = await prisma.contractDocument.create({
|
// Pentest 58.1: Whitelist-Validierung statt nur stripHtml. Multer hat
|
||||||
data: {
|
// die Datei schon geschrieben – bei Reject räumen wir sie wieder weg.
|
||||||
contractId,
|
let cleanType: string;
|
||||||
documentType,
|
try {
|
||||||
documentPath,
|
cleanType = validateContractDocumentType(documentType);
|
||||||
originalName: req.file.originalname,
|
} catch (err) {
|
||||||
notes: notes || null,
|
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
|
||||||
uploadedBy: req.user?.email,
|
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültiger Dokumenttyp' } as ApiResponse);
|
||||||
},
|
return;
|
||||||
});
|
}
|
||||||
|
// Pentest 55.4: Race-Schutz – Lock + Recent-Duplicate-Check.
|
||||||
|
const doc = await withContractDocumentLock(contractId, cleanType, () =>
|
||||||
|
prisma.contractDocument.create({
|
||||||
|
data: {
|
||||||
|
contractId,
|
||||||
|
documentType: cleanType,
|
||||||
|
documentPath,
|
||||||
|
originalName: req.file!.originalname,
|
||||||
|
notes: sanitizeNotes(notes),
|
||||||
|
uploadedBy: req.user?.email,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const contract = await prisma.contract.findUnique({ where: { id: contractId }, select: { contractNumber: true, customerId: true } });
|
const contract = await prisma.contract.findUnique({ where: { id: contractId }, select: { contractNumber: true, customerId: true } });
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'CREATE', resourceType: 'ContractDocument',
|
req, action: 'CREATE', resourceType: 'ContractDocument',
|
||||||
resourceId: doc.id.toString(),
|
resourceId: doc.id.toString(),
|
||||||
label: `Dokument "${documentType}" hochgeladen für Vertrag ${contract?.contractNumber}`,
|
label: `Dokument "${cleanType}" hochgeladen für Vertrag ${contract?.contractNumber}`,
|
||||||
details: { typ: documentType, datei: req.file.originalname },
|
details: { typ: cleanType, datei: req.file.originalname },
|
||||||
customerId: contract?.customerId,
|
customerId: contract?.customerId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer
|
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer
|
||||||
await maybeActivateOnDeliveryConfirmation(contractId, documentType, req, deliveryDate);
|
await maybeActivateOnDeliveryConfirmation(contractId, cleanType, 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({
|
// Pentest 64.1: ApiError mit eigenem statusCode honorieren (z.B. 400
|
||||||
|
// vom Race-Lock); fallback bleibt 400 für sonstige ContractDocument-
|
||||||
|
// Schreibfehler.
|
||||||
|
const status = error instanceof ApiError ? error.statusCode : 400;
|
||||||
|
// Multer hat die Datei schon geschrieben – bei Reject räumen.
|
||||||
|
if (req.file?.path) try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
|
||||||
|
res.status(status).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Hochladen',
|
error: error instanceof Error ? error.message : 'Fehler beim Hochladen',
|
||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
sanitizeCustomerStrict,
|
sanitizeCustomerStrict,
|
||||||
pickCustomerCreate,
|
pickCustomerCreate,
|
||||||
pickCustomerUpdate,
|
pickCustomerUpdate,
|
||||||
|
sanitizePhoneField,
|
||||||
isValidEmail,
|
isValidEmail,
|
||||||
} from '../utils/sanitize.js';
|
} from '../utils/sanitize.js';
|
||||||
import {
|
import {
|
||||||
@@ -90,6 +91,20 @@ export async function createCustomer(req: Request, res: Response): Promise<void>
|
|||||||
res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse);
|
res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 60.3: Phone/Mobile auch beim Create gegen Header-Injection sichern.
|
||||||
|
try {
|
||||||
|
if ('phone' in data) {
|
||||||
|
const cleaned = sanitizePhoneField(data.phone, 'Telefon');
|
||||||
|
data.phone = cleaned ?? null;
|
||||||
|
}
|
||||||
|
if ('mobile' in data) {
|
||||||
|
const cleaned = sanitizePhoneField(data.mobile, 'Mobil');
|
||||||
|
data.mobile = cleaned ?? null;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültige Telefonnummer' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// 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);
|
||||||
@@ -132,6 +147,23 @@ export async function updateCustomer(req: Request, res: Response): Promise<void>
|
|||||||
}
|
}
|
||||||
const data: any = pickCustomerUpdate(req.body);
|
const data: any = pickCustomerUpdate(req.body);
|
||||||
|
|
||||||
|
// Pentest 60.3 (MEDIUM, 2026-06-01): pickCustomerUpdate macht nur
|
||||||
|
// stripHtml; CRLF und andere Control-Chars überlebten. Phone/Mobile
|
||||||
|
// jetzt zusätzlich durch sanitizePhoneField (Allowlist).
|
||||||
|
try {
|
||||||
|
if ('phone' in data) {
|
||||||
|
const cleaned = sanitizePhoneField(data.phone, 'Telefon');
|
||||||
|
data.phone = cleaned ?? null;
|
||||||
|
}
|
||||||
|
if ('mobile' in data) {
|
||||||
|
const cleaned = sanitizePhoneField(data.mobile, 'Mobil');
|
||||||
|
data.mobile = cleaned ?? null;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültige Telefonnummer' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 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 } });
|
||||||
|
|
||||||
@@ -603,10 +635,13 @@ export async function createMeter(req: AuthRequest, res: Response): Promise<void
|
|||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||||
const meter = await customerService.createMeter(customerId, req.body);
|
const meter = await customerService.createMeter(customerId, req.body);
|
||||||
|
const successorLabel = meter.predecessor
|
||||||
|
? ` als Folgezähler von ${meter.predecessor.meterNumber}`
|
||||||
|
: '';
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'CREATE', resourceType: 'Meter',
|
req, action: 'CREATE', resourceType: 'Meter',
|
||||||
resourceId: meter.id.toString(),
|
resourceId: meter.id.toString(),
|
||||||
label: `Zähler angelegt für Kunde #${customerId}`,
|
label: `Zähler angelegt${successorLabel} für Kunde #${customerId}`,
|
||||||
customerId,
|
customerId,
|
||||||
});
|
});
|
||||||
res.status(201).json({ success: true, data: meter } as ApiResponse);
|
res.status(201).json({ success: true, data: meter } as ApiResponse);
|
||||||
@@ -635,7 +670,7 @@ export async function updateMeter(req: AuthRequest, res: Response): Promise<void
|
|||||||
const changes: Record<string, { von: unknown; nach: unknown }> = {};
|
const changes: Record<string, { von: unknown; nach: unknown }> = {};
|
||||||
const fieldLabels: Record<string, string> = {
|
const fieldLabels: Record<string, string> = {
|
||||||
meterNumber: 'Zählernummer', type: 'Typ', tariffModel: 'Tarifmodell',
|
meterNumber: 'Zählernummer', type: 'Typ', tariffModel: 'Tarifmodell',
|
||||||
location: 'Standort', isActive: 'Aktiv',
|
location: 'Standort', isActive: 'Aktiv', addressId: 'Lieferadresse',
|
||||||
};
|
};
|
||||||
for (const [key, newVal] of Object.entries(data)) {
|
for (const [key, newVal] of Object.entries(data)) {
|
||||||
if (['id', 'createdAt', 'updatedAt'].includes(key)) continue;
|
if (['id', 'createdAt', 'updatedAt'].includes(key)) continue;
|
||||||
|
|||||||
@@ -123,10 +123,15 @@ export async function testConnection(req: Request, res: Response): Promise<void>
|
|||||||
// SSRF-Guard inkl. DNS-Rebinding: testData.apiUrl-Hostname zu IP auflösen
|
// 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
|
// 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.
|
// ein zweiter Lookup zur Connection-Zeit eine andere IP liefern könnte.
|
||||||
|
// Pentest 50.1: strict=true – test-connection darf NIE auf private IPs,
|
||||||
|
// Loopback oder Cloud-Metadata zeigen, unabhängig von
|
||||||
|
// SSRF_BLOCK_PRIVATE_IPS. On-Prem mit echtem internen Plesk kann das
|
||||||
|
// per SSRF_ALLOW_INTERNAL_TESTING=true opt-outen (Default: blockiert).
|
||||||
|
const allowInternalTesting = (process.env.SSRF_ALLOW_INTERNAL_TESTING || '').toLowerCase() === 'true';
|
||||||
if (testData?.apiUrl) {
|
if (testData?.apiUrl) {
|
||||||
try {
|
try {
|
||||||
const url = new URL(testData.apiUrl);
|
const url = new URL(testData.apiUrl);
|
||||||
await safeResolveHost(url.hostname, 'apiUrl-Host');
|
await safeResolveHost(url.hostname, 'apiUrl-Host', { strict: !allowInternalTesting });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Error && (err.message.includes('geblockte') || err.message.includes('DNS'))) {
|
if (err instanceof Error && (err.message.includes('geblockte') || err.message.includes('DNS'))) {
|
||||||
const ctx = contextFromRequest(req);
|
const ctx = contextFromRequest(req);
|
||||||
@@ -247,12 +252,14 @@ export async function testMailAccess(req: Request, res: Response): Promise<void>
|
|||||||
// geblockte IPs prüfen. Connection läuft danach gegen die IP, der
|
// geblockte IPs prüfen. Connection läuft danach gegen die IP, der
|
||||||
// ursprüngliche Hostname wird als TLS-servername gesetzt – damit kann
|
// ursprüngliche Hostname wird als TLS-servername gesetzt – damit kann
|
||||||
// ein zweiter DNS-Lookup keine andere IP unterschieben.
|
// ein zweiter DNS-Lookup keine andere IP unterschieben.
|
||||||
|
// Pentest 50.1 analog testConnection: strict, opt-out via env.
|
||||||
|
const allowInternalTesting = (process.env.SSRF_ALLOW_INTERNAL_TESTING || '').toLowerCase() === 'true';
|
||||||
let smtpResolved: { ip: string; servername: string };
|
let smtpResolved: { ip: string; servername: string };
|
||||||
let imapResolved: { ip: string; servername: string };
|
let imapResolved: { ip: string; servername: string };
|
||||||
try {
|
try {
|
||||||
[smtpResolved, imapResolved] = await Promise.all([
|
[smtpResolved, imapResolved] = await Promise.all([
|
||||||
safeResolveHost(smtpServer, 'SMTP-Server'),
|
safeResolveHost(smtpServer, 'SMTP-Server', { strict: !allowInternalTesting }),
|
||||||
safeResolveHost(imapServer, 'IMAP-Server'),
|
safeResolveHost(imapServer, 'IMAP-Server', { strict: !allowInternalTesting }),
|
||||||
]);
|
]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const ctx = contextFromRequest(req);
|
const ctx = contextFromRequest(req);
|
||||||
|
|||||||
@@ -84,15 +84,75 @@ export async function downloadFile(req: AuthRequest, res: Response): Promise<voi
|
|||||||
// durch und wurde mit Original-Extension auf Disk geschrieben.
|
// durch und wurde mit Original-Extension auf Disk geschrieben.
|
||||||
// Beim Download bestimmt res.sendFile() den Content-Type aus der
|
// Beim Download bestimmt res.sendFile() den Content-Type aus der
|
||||||
// Extension – also `text/html` – und der Browser hätte das als
|
// Extension – also `text/html` – und der Browser hätte das als
|
||||||
// Stored-XSS gerendert. `X-Content-Type-Options: nosniff` schützt
|
// Stored-XSS gerendert.
|
||||||
// nicht, wenn der Server selbst text/html liefert.
|
|
||||||
//
|
//
|
||||||
// Fix: alle Files via Content-Disposition: attachment ausliefern.
|
// Default: Content-Disposition: attachment → Browser lädt nur runter.
|
||||||
// Der Browser lädt herunter statt zu rendern, egal welcher Type.
|
// Opt-in inline-Vorschau (Bank-Karten/Ausweis-Anzeigen-Button,
|
||||||
// Für legitime PDF/Bild-Vorschau ist das vertretbar – Browser
|
// Vertragsdokumente-Vorschau) per ?disposition=inline, ABER nur wenn
|
||||||
// öffnen den Download dann eben aus dem Datei-Manager.
|
// die ersten Bytes der Datei das Magic eines bekannten safe Typs
|
||||||
|
// (PDF, PNG, JPEG, GIF, WebP) zeigen. Bei Mismatch fällt's auf
|
||||||
|
// attachment zurück – Stored XSS bleibt weiterhin unmöglich.
|
||||||
|
//
|
||||||
|
// Pentest 101.1 (INFO, 2026-06-22): R101.1 berichtete, dass inline
|
||||||
|
// nie greift. Die Logik selbst ist OK; um künftige Regressionen
|
||||||
|
// sichtbar zu machen, loggen wir jetzt, wenn `inline` zwar angefragt
|
||||||
|
// wurde, aber wegen Magic-Byte-Mismatch oder Read-Fehler abgelehnt
|
||||||
|
// wird (passiert im Normalfall NIE bei echten PDFs/Images).
|
||||||
const filename = path.basename(absolute).replace(/[^A-Za-z0-9._-]/g, '_');
|
const filename = path.basename(absolute).replace(/[^A-Za-z0-9._-]/g, '_');
|
||||||
|
const wantsInline = req.query.disposition === 'inline';
|
||||||
|
const safeContentType = wantsInline ? detectSafeContentType(absolute) : null;
|
||||||
|
|
||||||
|
if (wantsInline && !safeContentType) {
|
||||||
|
console.warn(
|
||||||
|
`[fileDownload] inline angefragt, aber Magic-Byte-Check fehlgeschlagen: ${requested}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
if (safeContentType) {
|
||||||
|
res.setHeader('Content-Type', safeContentType);
|
||||||
|
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
||||||
|
} else {
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
|
}
|
||||||
res.sendFile(absolute);
|
res.sendFile(absolute);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest die ersten 12 Bytes der Datei und gibt einen MIME-Type zurück,
|
||||||
|
* wenn die Datei einer bekannten Whitelist (PDF, PNG, JPEG, GIF, WebP)
|
||||||
|
* entspricht. Sonst `null` – dann wird die Datei als attachment serviert.
|
||||||
|
*
|
||||||
|
* Wird nur aufgerufen, wenn `?disposition=inline` angefragt wurde, damit
|
||||||
|
* der Standardfluss (attachment) ohne zusätzlichen Disk-Read auskommt.
|
||||||
|
*/
|
||||||
|
function detectSafeContentType(absolute: string): string | null {
|
||||||
|
let fd: number | null = null;
|
||||||
|
try {
|
||||||
|
fd = fs.openSync(absolute, 'r');
|
||||||
|
const head = Buffer.alloc(12);
|
||||||
|
const bytesRead = fs.readSync(fd, head, 0, 12, 0);
|
||||||
|
if (bytesRead < 5) return null; // Datei zu klein für jede Magic-Sig
|
||||||
|
if (head.subarray(0, 5).toString('latin1') === '%PDF-') return 'application/pdf';
|
||||||
|
if (bytesRead >= 8
|
||||||
|
&& head.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
||||||
|
) return 'image/png';
|
||||||
|
if (bytesRead >= 3 && head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff) return 'image/jpeg';
|
||||||
|
if (bytesRead >= 6
|
||||||
|
&& (head.subarray(0, 6).toString('latin1') === 'GIF87a'
|
||||||
|
|| head.subarray(0, 6).toString('latin1') === 'GIF89a')
|
||||||
|
) return 'image/gif';
|
||||||
|
if (bytesRead >= 12
|
||||||
|
&& head.subarray(0, 4).toString('latin1') === 'RIFF'
|
||||||
|
&& head.subarray(8, 12).toString('latin1') === 'WEBP'
|
||||||
|
) return 'image/webp';
|
||||||
|
return null;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[fileDownload] Magic-Byte-Read fehlgeschlagen für ${absolute}:`, err);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
if (fd !== null) {
|
||||||
|
try { fs.closeSync(fd); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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 { getPublicUrl } from '../services/auth.service.js';
|
||||||
import { canAccessCustomer } from '../utils/accessControl.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';
|
||||||
@@ -65,7 +66,20 @@ export async function exportCustomerData(req: AuthRequest, res: Response) {
|
|||||||
*/
|
*/
|
||||||
export async function createDeletionRequest(req: AuthRequest, res: Response) {
|
export async function createDeletionRequest(req: AuthRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.id);
|
// Pentest 56.5 (LOW, 2026-06-01): customerId muss als gültige Zahl
|
||||||
|
// aus dem Body kommen (Route hat kein :id-Segment) – und der Caller
|
||||||
|
// braucht Zugriff auf den Kunden. Ohne den Check konnte jemand mit
|
||||||
|
// gdpr:delete-Permission Löschanfragen für beliebige Kunden stellen
|
||||||
|
// (Insider-Sabotage durch Portal-Vertreter ohne Vollmacht).
|
||||||
|
const bodyCustomerId = req.body?.customerId;
|
||||||
|
const customerId = typeof bodyCustomerId === 'number'
|
||||||
|
? bodyCustomerId
|
||||||
|
: parseInt(bodyCustomerId);
|
||||||
|
if (!Number.isFinite(customerId) || customerId < 1) {
|
||||||
|
res.status(400).json({ success: false, error: 'customerId fehlt oder ungültig' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||||
const { requestSource } = req.body;
|
const { requestSource } = req.body;
|
||||||
|
|
||||||
const request = await gdprService.createDeletionRequest({
|
const request = await gdprService.createDeletionRequest({
|
||||||
@@ -295,6 +309,18 @@ export async function updateCustomerConsent(req: AuthRequest, res: Response) {
|
|||||||
return res.status(400).json({ success: false, error: 'Ungültiger Consent-Typ' });
|
return res.status(400).json({ success: false, error: 'Ungültiger Consent-Typ' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pentest 24.6 (INFO, 2026-06-02): Portal-User durfte `PENDING`
|
||||||
|
// mitschicken und damit den Consent-Status auf den initialen System-
|
||||||
|
// Status zurücksetzen. PENDING ist nur intern (Default beim
|
||||||
|
// Customer-Anlegen); Portal darf nur GRANTED oder WITHDRAWN setzen.
|
||||||
|
// Verfälschte sonst die DSGVO-Auswertung.
|
||||||
|
if (status !== 'GRANTED' && status !== 'WITHDRAWN') {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Portal-Einwilligungen dürfen nur auf GRANTED oder WITHDRAWN gesetzt werden.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const consentLabels: Record<string, string> = {
|
const consentLabels: Record<string, string> = {
|
||||||
DATA_PROCESSING: 'Datenverarbeitung',
|
DATA_PROCESSING: 'Datenverarbeitung',
|
||||||
MARKETING_EMAIL: 'E-Mail-Marketing',
|
MARKETING_EMAIL: 'E-Mail-Marketing',
|
||||||
@@ -559,7 +585,10 @@ export async function sendConsentLink(req: AuthRequest, res: Response) {
|
|||||||
|
|
||||||
// ConsentHash sicherstellen
|
// ConsentHash sicherstellen
|
||||||
const hash = await consentPublicService.ensureConsentHash(customerId);
|
const hash = await consentPublicService.ensureConsentHash(customerId);
|
||||||
const baseUrl = process.env.PUBLIC_URL || req.headers.origin || 'http://localhost:5173';
|
// 59.4 Nebenbefund: nicht mehr direkt aus env, sondern über
|
||||||
|
// getPublicUrl – nimmt zuerst die admin-konfigurierte AppSetting
|
||||||
|
// `portalLoginUrl`, dann PUBLIC_URL, dann Localhost-Fallback.
|
||||||
|
const baseUrl = await getPublicUrl();
|
||||||
const consentUrl = `${baseUrl}/datenschutz/${hash}`;
|
const consentUrl = `${baseUrl}/datenschutz/${hash}`;
|
||||||
|
|
||||||
// Bei E-Mail: tatsächlich senden
|
// Bei E-Mail: tatsächlich senden
|
||||||
@@ -702,7 +731,10 @@ export async function sendAuthorizationRequest(req: AuthRequest, res: Response)
|
|||||||
return res.status(404).json({ success: false, error: 'Kunde oder Vertreter nicht gefunden' });
|
return res.status(404).json({ success: false, error: 'Kunde oder Vertreter nicht gefunden' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = process.env.PUBLIC_URL || req.headers.origin || 'http://localhost:5173';
|
// 59.4 Nebenbefund: nicht mehr direkt aus env, sondern über
|
||||||
|
// getPublicUrl – nimmt zuerst die admin-konfigurierte AppSetting
|
||||||
|
// `portalLoginUrl`, dann PUBLIC_URL, dann Localhost-Fallback.
|
||||||
|
const baseUrl = await getPublicUrl();
|
||||||
const portalUrl = `${baseUrl}/privacy`;
|
const portalUrl = `${baseUrl}/privacy`;
|
||||||
|
|
||||||
// E-Mail senden
|
// E-Mail senden
|
||||||
@@ -1114,3 +1146,35 @@ export async function getMyAuthorizationStatus(req: AuthRequest, res: Response)
|
|||||||
res.status(500).json({ success: false, error: 'Fehler beim Laden' });
|
res.status(500).json({ success: false, error: 'Fehler beim Laden' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unterschreibbare Datenschutzerklärung (Papierform) als PDF generieren.
|
||||||
|
* Verwendung: Mitarbeiter klickt im Tab "Einwilligungen / Datenschutz"
|
||||||
|
* auf "Vorlage zum Unterschreiben", PDF kommt mit personalisiertem
|
||||||
|
* Kopf + Unterschriftsfeld zum Ausdrucken zurück.
|
||||||
|
*
|
||||||
|
* GET /api/gdpr/customer/:customerId/privacy-pdf
|
||||||
|
*/
|
||||||
|
export async function getSignablePrivacyPdf(req: AuthRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const customerId = parseInt(req.params.customerId, 10);
|
||||||
|
if (!Number.isFinite(customerId) || customerId < 1) {
|
||||||
|
return res.status(400).json({ success: false, error: 'Ungültige Kunden-ID' });
|
||||||
|
}
|
||||||
|
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||||
|
|
||||||
|
const pdf = await consentPublicService.generateSignablePrivacyPdf(customerId);
|
||||||
|
const customer = await prisma.customer.findUnique({
|
||||||
|
where: { id: customerId },
|
||||||
|
select: { customerNumber: true },
|
||||||
|
});
|
||||||
|
const filename = `datenschutzerklaerung-${customer?.customerNumber || customerId}.pdf`;
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'application/pdf');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
|
res.send(pdf);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler bei Datenschutz-PDF:', error);
|
||||||
|
res.status(500).json({ success: false, error: 'Fehler beim Generieren der PDF' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,6 +53,23 @@ export async function getInvoice(req: AuthRequest, res: Response): Promise<void>
|
|||||||
/**
|
/**
|
||||||
* Neue Rechnung hinzufügen
|
* Neue Rechnung hinzufügen
|
||||||
*/
|
*/
|
||||||
|
// Pentest 56.4 (MEDIUM, 2026-06-01): invoiceType wurde an manchen
|
||||||
|
// Endpunkten nicht gegen die Enum-Whitelist validiert; ein beliebiger
|
||||||
|
// String landete als invoiceType in der DB und konnte Frontend-
|
||||||
|
// Filter/Reports verwirren oder XSS in Audit-Labels einschleusen.
|
||||||
|
type ValidInvoiceType = 'INTERIM' | 'FINAL' | 'NOT_AVAILABLE';
|
||||||
|
const VALID_INVOICE_TYPES: Set<ValidInvoiceType> = new Set(['INTERIM', 'FINAL', 'NOT_AVAILABLE']);
|
||||||
|
function assertValidInvoiceType(value: unknown, res: Response): value is ValidInvoiceType {
|
||||||
|
if (typeof value !== 'string' || !VALID_INVOICE_TYPES.has(value as ValidInvoiceType)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: `Ungültiger Rechnungstyp. Erlaubt: ${[...VALID_INVOICE_TYPES].join(', ')}.`,
|
||||||
|
} as ApiResponse);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export async function addInvoice(req: AuthRequest, res: Response): Promise<void> {
|
export async function addInvoice(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const ecdId = parseInt(req.params.ecdId);
|
const ecdId = parseInt(req.params.ecdId);
|
||||||
@@ -66,6 +83,7 @@ export async function addInvoice(req: AuthRequest, res: Response): Promise<void>
|
|||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!assertValidInvoiceType(invoiceType, res)) return;
|
||||||
|
|
||||||
const invoice = await invoiceService.addInvoice(ecdId, {
|
const invoice = await invoiceService.addInvoice(ecdId, {
|
||||||
invoiceDate: new Date(invoiceDate),
|
invoiceDate: new Date(invoiceDate),
|
||||||
@@ -99,6 +117,8 @@ export async function updateInvoice(req: AuthRequest, res: Response): Promise<vo
|
|||||||
const invoiceId = parseInt(req.params.invoiceId);
|
const invoiceId = parseInt(req.params.invoiceId);
|
||||||
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
|
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
|
||||||
const { invoiceDate, invoiceType, documentPath, notes } = req.body;
|
const { invoiceDate, invoiceType, documentPath, notes } = req.body;
|
||||||
|
// 56.4: invoiceType ist beim Update optional – nur prüfen wenn gesetzt.
|
||||||
|
if (invoiceType !== undefined && !assertValidInvoiceType(invoiceType, res)) return;
|
||||||
|
|
||||||
const invoice = await invoiceService.updateInvoice(ecdId, invoiceId, {
|
const invoice = await invoiceService.updateInvoice(ecdId, invoiceId, {
|
||||||
invoiceDate: invoiceDate ? new Date(invoiceDate) : undefined,
|
invoiceDate: invoiceDate ? new Date(invoiceDate) : undefined,
|
||||||
@@ -168,6 +188,14 @@ export async function addInvoiceByContract(req: AuthRequest, res: Response): Pro
|
|||||||
const contractId = parseInt(req.params.id);
|
const contractId = parseInt(req.params.id);
|
||||||
if (!(await canAccessContract(req, res, contractId))) return;
|
if (!(await canAccessContract(req, res, contractId))) return;
|
||||||
const { invoiceDate, invoiceType, notes } = req.body;
|
const { invoiceDate, invoiceType, notes } = req.body;
|
||||||
|
if (!invoiceDate || !invoiceType) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'invoiceDate und invoiceType sind erforderlich',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!assertValidInvoiceType(invoiceType, res)) return;
|
||||||
const invoice = await invoiceService.addInvoiceByContract(contractId, {
|
const invoice = await invoiceService.addInvoiceByContract(contractId, {
|
||||||
invoiceDate: new Date(invoiceDate),
|
invoiceDate: new Date(invoiceDate),
|
||||||
invoiceType,
|
invoiceType,
|
||||||
|
|||||||
@@ -1,7 +1,38 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import prisma from '../lib/prisma.js';
|
||||||
import * as providerService from '../services/provider.service.js';
|
import * as providerService from '../services/provider.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiResponse } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
|
|
||||||
|
// Pentest 47.1 (MEDIUM, 2026-06-01): Open Redirect / Phishing via
|
||||||
|
// provider.portalUrl. Bei kompromittiertem Admin-Account konnte ein
|
||||||
|
// Angreifer einen Phishing-Link auf einem real existierenden Provider
|
||||||
|
// hinterlegen – jeder Portal-User mit dem Provider sah ihn dauerhaft.
|
||||||
|
// Re-Auth-Pattern analog 47.3 (Staff-Password): bei Änderung der
|
||||||
|
// portalUrl-Domain muss der Admin sein eigenes Passwort mitsenden.
|
||||||
|
async function requireCallerPasswordReAuth(req: AuthRequest, providedPassword: unknown): Promise<{ ok: true } | { ok: false; status: number; error: string }> {
|
||||||
|
const callerId = req.user?.userId;
|
||||||
|
if (!callerId) return { ok: false, status: 401, error: 'Nicht authentifiziert' };
|
||||||
|
if (typeof providedPassword !== 'string' || providedPassword.length === 0) {
|
||||||
|
return { ok: false, status: 400, error: 'Bitte das eigene Passwort zur Bestätigung mitsenden.' };
|
||||||
|
}
|
||||||
|
const caller = await prisma.user.findUnique({ where: { id: callerId }, select: { password: true } });
|
||||||
|
// Timing-Schutz: immer einen bcrypt.compare laufen lassen
|
||||||
|
const callerHash = caller?.password ?? '$2a$10$0000000000000000000000000000000000000000000000000000';
|
||||||
|
const ok = await bcrypt.compare(providedPassword, callerHash);
|
||||||
|
if (!caller || !ok) return { ok: false, status: 403, error: 'Eigenes Passwort ist falsch.' };
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pentest 49.1 (LOW, 2026-06-01): nur Host-Vergleich ließ Pfad-Änderungen
|
||||||
|
// am gleichen Host (z.B. `https://1und1.de/neue/pfad`) ohne Re-Auth
|
||||||
|
// durchgehen – ein gestohlener JWT konnte Phishing-Pfade auf trusted
|
||||||
|
// Domains plazieren. Jetzt vergleichen wir die komplette normalisierte
|
||||||
|
// URL (Trailing-Slash, Whitespace).
|
||||||
|
function normalizeUrlForCompare(url: string | null | undefined): string {
|
||||||
|
return (url ?? '').trim().replace(/\/+$/, '').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
export async function getProviders(req: Request, res: Response): Promise<void> {
|
export async function getProviders(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -50,11 +81,21 @@ export async function getProvider(req: Request, res: Response): Promise<void> {
|
|||||||
|
|
||||||
export async function createProvider(req: Request, res: Response): Promise<void> {
|
export async function createProvider(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const provider = await providerService.createProvider(req.body);
|
const { currentPassword, ...providerData } = req.body || {};
|
||||||
|
// 47.1: Beim Create mit portalUrl ist Re-Auth Pflicht. Ohne portalUrl
|
||||||
|
// (rein interner Provider-Stammdatensatz) kein Zwang.
|
||||||
|
if (providerData.portalUrl) {
|
||||||
|
const reauth = await requireCallerPasswordReAuth(req as AuthRequest, currentPassword);
|
||||||
|
if (!reauth.ok) {
|
||||||
|
res.status(reauth.status).json({ success: false, error: reauth.error } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const provider = await providerService.createProvider(providerData);
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'CREATE', resourceType: 'Provider',
|
req, action: 'CREATE', resourceType: 'Provider',
|
||||||
resourceId: provider.id.toString(),
|
resourceId: provider.id.toString(),
|
||||||
label: `Anbieter ${provider.name} angelegt`,
|
label: `Anbieter ${provider.name} angelegt${provider.portalUrl ? ` mit Portal-URL ${provider.portalUrl}` : ''}`,
|
||||||
});
|
});
|
||||||
res.status(201).json({ success: true, data: provider } as ApiResponse);
|
res.status(201).json({ success: true, data: provider } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -67,11 +108,31 @@ export async function createProvider(req: Request, res: Response): Promise<void>
|
|||||||
|
|
||||||
export async function updateProvider(req: Request, res: Response): Promise<void> {
|
export async function updateProvider(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const provider = await providerService.updateProvider(parseInt(req.params.id), req.body);
|
const providerId = parseInt(req.params.id);
|
||||||
|
const { currentPassword, ...providerData } = req.body || {};
|
||||||
|
|
||||||
|
// 47.1 + 49.1: jede portalUrl-Änderung braucht Re-Auth – inkl. reiner
|
||||||
|
// Pfad-Änderungen am gleichen Host (Phishing-Pfade auf trusted Domain).
|
||||||
|
// Reine Namens-/Tarif-Edits bleiben friction-frei.
|
||||||
|
if (providerData.portalUrl !== undefined) {
|
||||||
|
const before = await providerService.getProviderById(providerId);
|
||||||
|
const isUrlChange = normalizeUrlForCompare(before?.portalUrl) !== normalizeUrlForCompare(providerData.portalUrl);
|
||||||
|
if (isUrlChange) {
|
||||||
|
const reauth = await requireCallerPasswordReAuth(req as AuthRequest, currentPassword);
|
||||||
|
if (!reauth.ok) {
|
||||||
|
res.status(reauth.status).json({ success: false, error: reauth.error } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = await providerService.updateProvider(providerId, providerData);
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'UPDATE', resourceType: 'Provider',
|
req, action: 'UPDATE', resourceType: 'Provider',
|
||||||
resourceId: provider.id.toString(),
|
resourceId: provider.id.toString(),
|
||||||
label: `Anbieter ${provider.name} aktualisiert`,
|
label: providerData.portalUrl !== undefined
|
||||||
|
? `Anbieter ${provider.name} aktualisiert (Portal-URL: ${provider.portalUrl ?? 'entfernt'})`
|
||||||
|
: `Anbieter ${provider.name} aktualisiert`,
|
||||||
});
|
});
|
||||||
res.json({ success: true, data: provider } as ApiResponse);
|
res.json({ success: true, data: provider } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -2,11 +2,40 @@ 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, AuthRequest } from '../types/index.js';
|
||||||
import { canAccessStressfreiEmail } from '../utils/accessControl.js';
|
import { canAccessCustomer, canAccessStressfreiEmail } from '../utils/accessControl.js';
|
||||||
|
import { ApiError } from '../utils/apiError.js';
|
||||||
|
|
||||||
export async function getEmailsByCustomer(req: Request, res: Response): Promise<void> {
|
// Pentest 71.3 (INFO): `parseInt(...)` ohne NaN-Check gab bei
|
||||||
|
// `/stressfrei-emails/abc/...` einen generischen 500 zurück.
|
||||||
|
//
|
||||||
|
// Pentest 77.3 (LOW): `Number.isInteger(parseInt(...))` ließ Floats
|
||||||
|
// und Exponential-Notation durch – `4.0`, `4.5`, `4e1` werden alle
|
||||||
|
// zu `4` geparst und treffen die echte ID 4. Fix: erst gegen
|
||||||
|
// `/^\d+$/` validieren, dann erst parsen.
|
||||||
|
function requireIdParam(req: AuthRequest, res: Response, paramName: string): number | null {
|
||||||
|
const raw = req.params[paramName];
|
||||||
|
if (typeof raw !== 'string' || !/^\d+$/.test(raw)) {
|
||||||
|
res.status(400).json({ success: false, error: `Ungültige ID: ${raw}` } as ApiResponse);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsed = Number.parseInt(raw, 10);
|
||||||
|
if (!Number.isInteger(parsed) || parsed < 1) {
|
||||||
|
res.status(400).json({ success: false, error: `Ungültige ID: ${raw}` } as ApiResponse);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEmailsByCustomer(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = requireIdParam(req, res, 'customerId');
|
||||||
|
if (customerId === null) return;
|
||||||
|
// requireCustomerAccess in der Route greift nicht ausreichend:
|
||||||
|
// Portal-User haben `customers:read` (für eigene Daten) und werden
|
||||||
|
// dort short-circuited, ohne Owner-Vergleich. Pentest 2026-05-24
|
||||||
|
// (MEDIUM 31.2) – IDOR auf fremde IMAP-Konten. Hier daher der
|
||||||
|
// explizite Per-Customer-Check analog zum POST-Handler.
|
||||||
|
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||||
const includeInactive = req.query.includeInactive === 'true';
|
const includeInactive = req.query.includeInactive === 'true';
|
||||||
const emails = await stressfreiEmailService.getEmailsByCustomerId(customerId, includeInactive);
|
const emails = await stressfreiEmailService.getEmailsByCustomerId(customerId, includeInactive);
|
||||||
res.json({ success: true, data: emails } as ApiResponse);
|
res.json({ success: true, data: emails } as ApiResponse);
|
||||||
@@ -20,7 +49,8 @@ export async function getEmailsByCustomer(req: Request, res: Response): Promise<
|
|||||||
|
|
||||||
export async function getEmail(req: AuthRequest, res: Response): Promise<void> {
|
export async function getEmail(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const emailId = parseInt(req.params.id);
|
const emailId = requireIdParam(req, res, 'id');
|
||||||
|
if (emailId === null) return;
|
||||||
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
||||||
|
|
||||||
const email = await stressfreiEmailService.getEmailById(emailId);
|
const email = await stressfreiEmailService.getEmailById(emailId);
|
||||||
@@ -48,7 +78,8 @@ export async function getEmail(req: AuthRequest, res: Response): Promise<void> {
|
|||||||
|
|
||||||
export async function createEmail(req: Request, res: Response): Promise<void> {
|
export async function createEmail(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = requireIdParam(req, res, 'customerId');
|
||||||
|
if (customerId === null) return;
|
||||||
const email = await stressfreiEmailService.createEmail({
|
const email = await stressfreiEmailService.createEmail({
|
||||||
...req.body,
|
...req.body,
|
||||||
customerId,
|
customerId,
|
||||||
@@ -61,7 +92,8 @@ export async function createEmail(req: Request, res: Response): Promise<void> {
|
|||||||
});
|
});
|
||||||
res.status(201).json({ success: true, data: email } as ApiResponse);
|
res.status(201).json({ success: true, data: email } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
const status = error instanceof ApiError ? error.statusCode : 400;
|
||||||
|
res.status(status).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Stressfrei-Wechseln Adresse',
|
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Stressfrei-Wechseln Adresse',
|
||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
@@ -70,7 +102,8 @@ export async function createEmail(req: Request, res: Response): Promise<void> {
|
|||||||
|
|
||||||
export async function updateEmail(req: AuthRequest, res: Response): Promise<void> {
|
export async function updateEmail(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const emailId = parseInt(req.params.id);
|
const emailId = requireIdParam(req, res, 'id');
|
||||||
|
if (emailId === null) return;
|
||||||
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
||||||
const email = await stressfreiEmailService.updateEmail(emailId, req.body);
|
const email = await stressfreiEmailService.updateEmail(emailId, req.body);
|
||||||
await logChange({
|
await logChange({
|
||||||
@@ -80,7 +113,8 @@ export async function updateEmail(req: AuthRequest, res: Response): Promise<void
|
|||||||
});
|
});
|
||||||
res.json({ success: true, data: email } as ApiResponse);
|
res.json({ success: true, data: email } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
const status = error instanceof ApiError ? error.statusCode : 400;
|
||||||
|
res.status(status).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Stressfrei-Wechseln Adresse',
|
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Stressfrei-Wechseln Adresse',
|
||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
@@ -89,7 +123,8 @@ export async function updateEmail(req: AuthRequest, res: Response): Promise<void
|
|||||||
|
|
||||||
export async function deleteEmail(req: AuthRequest, res: Response): Promise<void> {
|
export async function deleteEmail(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const emailId = parseInt(req.params.id);
|
const emailId = requireIdParam(req, res, 'id');
|
||||||
|
if (emailId === null) return;
|
||||||
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
||||||
await stressfreiEmailService.deleteEmail(emailId);
|
await stressfreiEmailService.deleteEmail(emailId);
|
||||||
await logChange({
|
await logChange({
|
||||||
@@ -108,7 +143,8 @@ export async function deleteEmail(req: AuthRequest, res: Response): Promise<void
|
|||||||
|
|
||||||
export async function syncForwarding(req: AuthRequest, res: Response): Promise<void> {
|
export async function syncForwarding(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const emailId = parseInt(req.params.id);
|
const emailId = requireIdParam(req, res, 'id');
|
||||||
|
if (emailId === null) return;
|
||||||
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
||||||
|
|
||||||
const result = await stressfreiEmailService.syncForwardingForEmail(emailId);
|
const result = await stressfreiEmailService.syncForwardingForEmail(emailId);
|
||||||
@@ -145,9 +181,59 @@ export async function syncForwarding(req: AuthRequest, res: Response): Promise<v
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zusätzliche Weiterleitungs-E-Mails der StressfreiEmail neu setzen.
|
||||||
|
* Body: `{ emails: string[] }`. Liste ersetzt komplett, Provider wird
|
||||||
|
* unmittelbar nachgezogen.
|
||||||
|
*/
|
||||||
|
export async function updateAdditionalForwards(req: AuthRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const emailId = requireIdParam(req, res, 'id');
|
||||||
|
if (emailId === null) return;
|
||||||
|
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
||||||
|
|
||||||
|
const body = req.body ?? {};
|
||||||
|
if (!Array.isArray(body.emails)) {
|
||||||
|
res.status(400).json({ success: false, error: '`emails` muss ein Array sein.' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (body.emails.length > 20) {
|
||||||
|
res.status(400).json({ success: false, error: 'Maximal 20 zusätzliche Weiterleitungen erlaubt.' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await stressfreiEmailService.setAdditionalForwards(emailId, body.emails);
|
||||||
|
if (!result.success) {
|
||||||
|
res.status(400).json({ success: false, error: result.error } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await logChange({
|
||||||
|
req,
|
||||||
|
action: 'UPDATE',
|
||||||
|
resourceType: 'StressfreiEmail',
|
||||||
|
resourceId: emailId.toString(),
|
||||||
|
label: `Zusatz-Weiterleitungen aktualisiert (${(result.forwardTargets || []).length} Ziele aktiv)`,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { forwardTargets: result.forwardTargets },
|
||||||
|
message: 'Weiterleitungen aktualisiert',
|
||||||
|
} as ApiResponse);
|
||||||
|
} catch (error) {
|
||||||
|
const status = error instanceof ApiError ? error.statusCode : 500;
|
||||||
|
res.status(status).json({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Weiterleitungen',
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function resetPassword(req: AuthRequest, res: Response): Promise<void> {
|
export async function resetPassword(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const emailId = parseInt(req.params.id);
|
const emailId = requireIdParam(req, res, 'id');
|
||||||
|
if (emailId === null) return;
|
||||||
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
if (!(await canAccessStressfreiEmail(req, res, emailId))) return;
|
||||||
const result = await stressfreiEmailService.resetMailboxPassword(emailId);
|
const result = await stressfreiEmailService.resetMailboxPassword(emailId);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
import prisma from '../lib/prisma.js';
|
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, AuthRequest } from '../types/index.js';
|
||||||
import { pickUserCreate, pickUserUpdate, isValidEmail } from '../utils/sanitize.js';
|
import { pickUserCreate, pickUserUpdate, isValidEmail, sanitizePhoneField } from '../utils/sanitize.js';
|
||||||
import { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH } from '../utils/passwordGenerator.js';
|
import { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH } from '../utils/passwordGenerator.js';
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
@@ -69,6 +70,20 @@ export async function createUser(req: Request, res: Response): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 60.3: WhatsApp- und Signal-Nummern gegen CRLF/Header-Injection sichern.
|
||||||
|
try {
|
||||||
|
if ('whatsappNumber' in data) {
|
||||||
|
const cleaned = sanitizePhoneField(data.whatsappNumber, 'WhatsApp-Nummer');
|
||||||
|
data.whatsappNumber = cleaned ?? null;
|
||||||
|
}
|
||||||
|
if ('signalNumber' in data) {
|
||||||
|
const cleaned = sanitizePhoneField(data.signalNumber, 'Signal-Nummer');
|
||||||
|
data.signalNumber = cleaned ?? null;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültige Nummer' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const user = await userService.createUser(data);
|
const user = await userService.createUser(data);
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'CREATE', resourceType: 'User',
|
req, action: 'CREATE', resourceType: 'User',
|
||||||
@@ -113,13 +128,27 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
|
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
|
||||||
const data = pickUserUpdate(req.body);
|
const data = pickUserUpdate(req.body) as Record<string, unknown>;
|
||||||
// Email-Validierung gegen SMTP-Header-Injection (Pentest 29.4).
|
// Email-Validierung gegen SMTP-Header-Injection (Pentest 29.4).
|
||||||
// null/leer ist OK (Email darf optional sein), nur falsches Format prüfen.
|
// null/leer ist OK (Email darf optional sein), nur falsches Format prüfen.
|
||||||
if (data?.email !== undefined && !isValidEmail(data.email)) {
|
if (data?.email !== undefined && !isValidEmail(data.email)) {
|
||||||
res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse);
|
res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 60.3: WhatsApp- und Signal-Nummern gegen CRLF/Header-Injection sichern.
|
||||||
|
try {
|
||||||
|
if ('whatsappNumber' in data) {
|
||||||
|
const cleaned = sanitizePhoneField(data.whatsappNumber, 'WhatsApp-Nummer');
|
||||||
|
data.whatsappNumber = cleaned ?? null;
|
||||||
|
}
|
||||||
|
if ('signalNumber' in data) {
|
||||||
|
const cleaned = sanitizePhoneField(data.signalNumber, 'Signal-Nummer');
|
||||||
|
data.signalNumber = cleaned ?? null;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültige Nummer' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Vorherigen Stand laden für Audit – inkl. Rollen, damit hasGdprAccess /
|
// Vorherigen Stand laden für Audit – inkl. Rollen, damit hasGdprAccess /
|
||||||
// hasDeveloperAccess (versteckte Rollen) korrekt verglichen werden.
|
// hasDeveloperAccess (versteckte Rollen) korrekt verglichen werden.
|
||||||
@@ -189,11 +218,44 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
|
|||||||
export async function setUserPassword(req: Request, res: Response): Promise<void> {
|
export async function setUserPassword(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const userId = parseInt(req.params.id);
|
const userId = parseInt(req.params.id);
|
||||||
const { password } = req.body || {};
|
const { password, currentPassword } = req.body || {};
|
||||||
if (!password || typeof password !== 'string') {
|
if (!password || typeof password !== 'string') {
|
||||||
res.status(400).json({ success: false, error: 'Passwort erforderlich' } as ApiResponse);
|
res.status(400).json({ success: false, error: 'Passwort erforderlich' } as ApiResponse);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pentest 47.3 (MEDIUM, 2026-06-01): Re-Auth verpflichtend.
|
||||||
|
// Ein gestohlener Admin-JWT reichte bisher, um Staff-Passwörter
|
||||||
|
// umzuschreiben. Jetzt muss der aufrufende Admin sein eigenes
|
||||||
|
// Passwort mitsenden – CSRF/Token-Klau allein reicht nicht mehr.
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const callerId = authReq.user?.userId;
|
||||||
|
if (!callerId) {
|
||||||
|
res.status(401).json({ success: false, error: 'Nicht authentifiziert' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!currentPassword || typeof currentPassword !== 'string') {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Bitte das eigene Passwort zur Bestätigung mitsenden.',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const caller = await prisma.user.findUnique({
|
||||||
|
where: { id: callerId },
|
||||||
|
select: { password: true },
|
||||||
|
});
|
||||||
|
// Timing-Schutz: immer einen bcrypt.compare laufen lassen
|
||||||
|
const callerHash = caller?.password ?? '$2a$10$0000000000000000000000000000000000000000000000000000';
|
||||||
|
const reAuthOk = await bcrypt.compare(currentPassword, callerHash);
|
||||||
|
if (!caller || !reAuthOk) {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Eigenes Passwort ist falsch.',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const c = validatePasswordComplexity(password, { minLength: STAFF_MIN_PASSWORD_LENGTH });
|
const c = validatePasswordComplexity(password, { minLength: STAFF_MIN_PASSWORD_LENGTH });
|
||||||
if (!c.ok) {
|
if (!c.ok) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
@@ -207,10 +269,20 @@ export async function setUserPassword(req: Request, res: Response): Promise<void
|
|||||||
res.status(404).json({ success: false, error: 'Benutzer nicht gefunden' } as ApiResponse);
|
res.status(404).json({ success: false, error: 'Benutzer nicht gefunden' } as ApiResponse);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Pentest 48.4 (INFO, 2026-06-01): Bestehende JWTs für den Ziel-User
|
||||||
|
// sofort invalidieren. Greift insbesondere bei Self-Reset (Admin setzt
|
||||||
|
// sich selbst zurück) – der gestohlene Token wird damit ungültig,
|
||||||
|
// statt noch bis zum natürlichen Ablauf brauchbar zu bleiben.
|
||||||
|
// Die Middleware-Auth liest tokenInvalidatedAt und vergleicht gegen
|
||||||
|
// den `iat`-Claim des JWT.
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { tokenInvalidatedAt: new Date() },
|
||||||
|
});
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'UPDATE', resourceType: 'User',
|
req, action: 'UPDATE', resourceType: 'User',
|
||||||
resourceId: user.id.toString(),
|
resourceId: user.id.toString(),
|
||||||
label: `Passwort für Benutzer ${user.firstName} ${user.lastName} (${user.email}) durch Admin gesetzt`,
|
label: `Passwort für Benutzer ${user.firstName} ${user.lastName} (${user.email}) durch Admin gesetzt (Re-Auth bestätigt, Sessions invalidiert)`,
|
||||||
});
|
});
|
||||||
res.json({ success: true, message: 'Passwort gesetzt' } as ApiResponse);
|
res.json({ success: true, message: 'Passwort gesetzt' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
+41
-3
@@ -253,6 +253,17 @@ app.get('/api/uploads/*', authenticate as any, (req, res, next) => {
|
|||||||
return (downloadFile as any)(req, res, next);
|
return (downloadFile as any)(req, res, next);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Pentest 55.3 (HIGH, 2026-06-01): /uploads/contract-documents/*.pdf
|
||||||
|
// kam ungeschützt durch, weil der nginx-Reverse-Proxy die Dateien
|
||||||
|
// direkt aus dem Filesystem auslieferte und der Backend-Auth-Check
|
||||||
|
// nur bei /api/uploads/* griff. Defense-in-Depth: dieselbe Route auch
|
||||||
|
// ohne /api-Präfix freischalten – damit der Backend-Owner-Check immer
|
||||||
|
// läuft, egal wie nginx konfiguriert ist.
|
||||||
|
app.get('/uploads/*', authenticate as any, (req, res, next) => {
|
||||||
|
req.query.path = req.originalUrl.split('?')[0];
|
||||||
|
return (downloadFile as any)(req, res, next);
|
||||||
|
});
|
||||||
|
|
||||||
// Cache-Control für alle API-Responses: `no-store` verhindert, dass Shared
|
// Cache-Control für alle API-Responses: `no-store` verhindert, dass Shared
|
||||||
// Caches (CDN/Reverse-Proxy/Browser-History) JSON mit sensiblen Daten
|
// Caches (CDN/Reverse-Proxy/Browser-History) JSON mit sensiblen Daten
|
||||||
// vorhalten. Statische Frontend-Assets unter /assets/* sind weiter cacheable
|
// vorhalten. Statische Frontend-Assets unter /assets/* sind weiter cacheable
|
||||||
@@ -422,9 +433,36 @@ if (process.env.NODE_ENV === 'production') {
|
|||||||
// body-parser wirft 413 (PayloadTooLargeError) bzw. 400 (SyntaxError) mit einem
|
// body-parser wirft 413 (PayloadTooLargeError) bzw. 400 (SyntaxError) mit einem
|
||||||
// `status`-Feld. Ohne Respektierung werden legitime Client-Fehler als 500
|
// `status`-Feld. Ohne Respektierung werden legitime Client-Fehler als 500
|
||||||
// kaschiert und landen als "Interner Serverfehler" beim User.
|
// 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);
|
// Multer-Errors werden hier sauber abgefangen statt als 500 zu enden:
|
||||||
const status = typeof err.status === 'number' && err.status >= 400 && err.status < 600 ? err.status : 500;
|
// - MulterError (z.B. LIMIT_FILE_SIZE) → 413
|
||||||
|
// - LIMIT_UNEXPECTED_FILE / -PART_COUNT etc. → 400
|
||||||
|
// - fileFilter-Errors (unzulässiger Typ) → 415
|
||||||
|
// Pentest 2026-05-30, INFO: WebP/GIF-Uploads (und alle anderen vom
|
||||||
|
// fileFilter abgelehnten Typen) lieferten vorher 500 mit Stack-Trace.
|
||||||
|
app.use((err: any, req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||||
|
console.error(err?.stack || err);
|
||||||
|
|
||||||
|
// Multer-spezifische Fehler (importiert as namespace)
|
||||||
|
if (err?.name === 'MulterError') {
|
||||||
|
const code = err.code;
|
||||||
|
if (code === 'LIMIT_FILE_SIZE') {
|
||||||
|
return res.status(413).json({ success: false, error: 'Datei ist zu groß' });
|
||||||
|
}
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Upload-Fehler: ' + (err.message || code || 'unbekannt'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileFilter hat cb(new Error(...), false) gerufen – kein MulterError,
|
||||||
|
// sondern unsere eigene Reject-Nachricht ("Nur PDF, JPG, ... erlaubt").
|
||||||
|
const msg = typeof err?.message === 'string' ? err.message : '';
|
||||||
|
if (/sind erlaubt|nicht erlaubt/i.test(msg)) {
|
||||||
|
return res.status(415).json({ success: false, error: msg });
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = typeof err?.status === 'number' && err.status >= 400 && err.status < 600 ? err.status : 500;
|
||||||
let message = 'Interner Serverfehler';
|
let message = 'Interner Serverfehler';
|
||||||
if (status === 413) message = 'Anfrage zu groß';
|
if (status === 413) message = 'Anfrage zu groß';
|
||||||
else if (status === 400 && (err.type === 'entity.parse.failed' || err instanceof SyntaxError)) {
|
else if (status === 400 && (err.type === 'entity.parse.failed' || err instanceof SyntaxError)) {
|
||||||
|
|||||||
@@ -158,9 +158,34 @@ export function requireCustomerAccess(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WICHTIG: erst die isCustomerPortal-Prüfung, DANN erst die Perm-Prüfung.
|
||||||
|
// Portal-User bekommen `customers:read` im JWT (für eigene Daten); ohne
|
||||||
|
// den Portal-Check vorne weg short-circuited die alte Logik auf der
|
||||||
|
// Perm und ließ Portal-User auf fremde customerId zugreifen.
|
||||||
|
// Pentest 2026-05-24 (MEDIUM 31.2 IDOR auf /api/customers/:id/
|
||||||
|
// stressfrei-emails). Auch andere Routes mit dem gleichen Middleware-
|
||||||
|
// Pattern wären betroffen gewesen.
|
||||||
const userPermissions = req.user.permissions || [];
|
const userPermissions = req.user.permissions || [];
|
||||||
|
const isPortal = !!(req.user as any).isCustomerPortal;
|
||||||
|
const customerId = parseInt(req.params.customerId || req.params.id);
|
||||||
|
|
||||||
// Admins and employees can access all customers
|
if (isPortal) {
|
||||||
|
const allowedIds = [
|
||||||
|
req.user.customerId,
|
||||||
|
...((req.user as any).representedCustomerIds || []),
|
||||||
|
].filter(Boolean);
|
||||||
|
if (allowedIds.includes(customerId)) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Kein Zugriff auf diese Kundendaten',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mitarbeiter/Admin: customers:read oder customers:update reicht
|
||||||
if (
|
if (
|
||||||
userPermissions.includes('customers:read') ||
|
userPermissions.includes('customers:read') ||
|
||||||
userPermissions.includes('customers:update')
|
userPermissions.includes('customers:update')
|
||||||
@@ -169,18 +194,6 @@ export function requireCustomerAccess(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Customers can only access their own data + represented customers
|
|
||||||
const customerId = parseInt(req.params.customerId || req.params.id);
|
|
||||||
const allowedIds = [
|
|
||||||
req.user.customerId,
|
|
||||||
...((req.user as any).representedCustomerIds || []),
|
|
||||||
].filter(Boolean);
|
|
||||||
|
|
||||||
if (allowedIds.includes(customerId)) {
|
|
||||||
next();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(403).json({
|
res.status(403).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Kein Zugriff auf diese Kundendaten',
|
error: 'Kein Zugriff auf diese Kundendaten',
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import fs from 'fs';
|
||||||
|
import { assertSafePdf } from '../utils/sanitize.js';
|
||||||
|
import { ApiError } from '../utils/apiError.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strikte Variante: Datei MUSS eine PDF sein. Sonst 415. Für Routen, die
|
||||||
|
* ausschliesslich PDFs zulassen (z.B. Vollmacht-Upload, PDF-Templates).
|
||||||
|
*
|
||||||
|
* Routen, die auch JPG/PNG akzeptieren (z.B. contract.routes
|
||||||
|
* Vertragsdokumente), nutzen `validateUploadedFile` aus
|
||||||
|
* `uploadFileTypeValidator.ts` – das macht Magic-Byte für ALLE Typen +
|
||||||
|
* PDF-Scan in einer Pipeline.
|
||||||
|
*/
|
||||||
|
export function requireSafeUploadedPdf(req: Request, res: Response, next: NextFunction): void {
|
||||||
|
const file = (req as Request & { file?: Express.Multer.File }).file;
|
||||||
|
if (!file) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const buf = fs.readFileSync(file.path);
|
||||||
|
if (buf.length < 5 || buf.subarray(0, 5).toString('latin1') !== '%PDF-') {
|
||||||
|
throw new ApiError(415, 'Datei ist keine gültige PDF.');
|
||||||
|
}
|
||||||
|
assertSafePdf(buf);
|
||||||
|
next();
|
||||||
|
} catch (e) {
|
||||||
|
try { fs.unlinkSync(file.path); } catch { /* ignore */ }
|
||||||
|
const status = e instanceof ApiError ? e.statusCode : 415;
|
||||||
|
const message = e instanceof Error ? e.message : 'PDF ungültig';
|
||||||
|
res.status(status).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,6 +82,41 @@ export const passwordResetRateLimiter = rateLimit({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Staff-Password-Set-Limiter (Pentest 48.3, 2026-06-01):
|
||||||
|
* POST /api/users/:id/password verlangt seit 47.3 die Eingabe des eigenen
|
||||||
|
* Admin-Passworts (`currentPassword`). Ohne Throttle könnte ein Angreifer
|
||||||
|
* mit gestohlenem JWT die 25-Zeichen-Passwort-Policy zwar nicht erraten,
|
||||||
|
* aber kürzere/typische Admin-Passwörter (z.B. Stagings, kompromittierte
|
||||||
|
* Setups) per Brute-Force durchprobieren – und damit den Re-Auth-Fix
|
||||||
|
* komplett aushebeln.
|
||||||
|
*
|
||||||
|
* Bucket: (IP, target-user-id). Damit walked ein Angreifer pro Opfer
|
||||||
|
* langsam und kann nicht mit einem stolen-token gegen alle Staff-User
|
||||||
|
* parallel anrennen. `skipSuccessfulRequests: true`, weil legitime
|
||||||
|
* Passwort-Resets nicht den Counter füllen sollen.
|
||||||
|
*/
|
||||||
|
export const staffPasswordReAuthLimiter = rateLimit({
|
||||||
|
windowMs: 10 * 60 * 1000, // 10 Minuten
|
||||||
|
limit: 5,
|
||||||
|
standardHeaders: 'draft-7',
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: {
|
||||||
|
success: false,
|
||||||
|
error: 'Zu viele fehlgeschlagene Passwort-Set-Versuche. Bitte in 10 Minuten erneut versuchen.',
|
||||||
|
},
|
||||||
|
skipSuccessfulRequests: true,
|
||||||
|
keyGenerator: (req): string => {
|
||||||
|
const ip = req.ip || 'unknown';
|
||||||
|
const targetUserId = (req.params?.id ?? '<missing>').toString();
|
||||||
|
return `${ip}|staff-pw|${targetUserId}`;
|
||||||
|
},
|
||||||
|
handler: (req, res, _next, options) => {
|
||||||
|
onLimitReached('staff-password-set', 'HIGH')(req, res);
|
||||||
|
res.status(options.statusCode).json(options.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public-Consent-Endpoints (/api/public/consent/:hash[/grant|/pdf]) sind
|
* Public-Consent-Endpoints (/api/public/consent/:hash[/grant|/pdf]) sind
|
||||||
* unauthenticated. Der hash ist 128-bit-UUID → kein Brute-Force-Risk,
|
* unauthenticated. Der hash ist 128-bit-UUID → kein Brute-Force-Risk,
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { assertSafePdf } from '../utils/sanitize.js';
|
||||||
|
import { ApiError } from '../utils/apiError.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Magic-Byte-Whitelist + canonical Extension Rename + PDF-Active-Content-
|
||||||
|
* Scan in einer Middleware. Greift nach `multer.single(...)`, prüft die
|
||||||
|
* geschriebene Datei auf erlaubten Typ (PDF/JPG/PNG/GIF/WebP) und benennt
|
||||||
|
* sie auf eine kanonische Endung um – damit verschwindet die
|
||||||
|
* `evil.gif.php`-Doppel-Endung und der client-gemeldete mimetype wird
|
||||||
|
* durch den ERKANNTEN ersetzt.
|
||||||
|
*
|
||||||
|
* Historie:
|
||||||
|
* - Pentest 39.3 / 39.4 (2026-05-30): Magic-Byte + canonical Rename.
|
||||||
|
* - Pentest 68.1 (2026-06-03): PDF-Body-Scan auf JS/Launch/Embed/RichMedia.
|
||||||
|
* - Pentest 69.3 (2026-06-03): Wiederverwendung in contract.routes.ts
|
||||||
|
* (Vertragsdokumente) – vorher waren JPG/PNG-Uploads dort ungeprüft,
|
||||||
|
* nur durch Download-Layer kompensiert.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PDF_MAGIC = Buffer.from('%PDF-', 'latin1');
|
||||||
|
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||||
|
const GIF87 = Buffer.from('GIF87a', 'latin1');
|
||||||
|
const GIF89 = Buffer.from('GIF89a', 'latin1');
|
||||||
|
|
||||||
|
export function detectFileType(buf: Buffer): { mime: string; ext: string } | null {
|
||||||
|
if (buf.length >= 5 && buf.subarray(0, 5).equals(PDF_MAGIC)) return { mime: 'application/pdf', ext: '.pdf' };
|
||||||
|
if (buf.length >= 8 && buf.subarray(0, 8).equals(PNG_MAGIC)) return { mime: 'image/png', ext: '.png' };
|
||||||
|
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return { mime: 'image/jpeg', ext: '.jpg' };
|
||||||
|
if (buf.length >= 6 && (buf.subarray(0, 6).equals(GIF87) || buf.subarray(0, 6).equals(GIF89))) return { mime: 'image/gif', ext: '.gif' };
|
||||||
|
if (buf.length >= 12
|
||||||
|
&& buf.subarray(0, 4).toString('latin1') === 'RIFF'
|
||||||
|
&& buf.subarray(8, 12).toString('latin1') === 'WEBP') return { mime: 'image/webp', ext: '.webp' };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateUploadedFile(req: Request, res: Response, next: NextFunction): void {
|
||||||
|
const file = (req as Request & { file?: Express.Multer.File }).file;
|
||||||
|
if (!file) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const fd = fs.openSync(file.path, 'r');
|
||||||
|
const head = Buffer.alloc(12);
|
||||||
|
fs.readSync(fd, head, 0, 12, 0);
|
||||||
|
fs.closeSync(fd);
|
||||||
|
|
||||||
|
const detected = detectFileType(head);
|
||||||
|
if (!detected) {
|
||||||
|
try { fs.unlinkSync(file.path); } catch { /* ignore */ }
|
||||||
|
res.status(415).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Datei-Inhalt entspricht keinem zulässigen Typ (PDF, JPG, PNG, GIF, WebP).',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detected.mime === 'application/pdf') {
|
||||||
|
try {
|
||||||
|
const fullBuf = fs.readFileSync(file.path);
|
||||||
|
assertSafePdf(fullBuf);
|
||||||
|
} catch (e) {
|
||||||
|
try { fs.unlinkSync(file.path); } catch { /* ignore */ }
|
||||||
|
const status = e instanceof ApiError ? e.statusCode : 415;
|
||||||
|
const msg = e instanceof Error ? e.message : 'PDF ungültig';
|
||||||
|
res.status(status).json({ success: false, error: msg });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = path.dirname(file.path);
|
||||||
|
const base = path.basename(file.path).replace(/\.[^./]+(\.[^./]+)*$/, '');
|
||||||
|
const newName = base + detected.ext;
|
||||||
|
const newPath = path.join(dir, newName);
|
||||||
|
if (newPath !== file.path) {
|
||||||
|
try {
|
||||||
|
fs.renameSync(file.path, newPath);
|
||||||
|
file.path = newPath;
|
||||||
|
file.filename = newName;
|
||||||
|
} catch (e) {
|
||||||
|
try { fs.unlinkSync(file.path); } catch { /* ignore */ }
|
||||||
|
console.error('Upload-Rename fehlgeschlagen:', e);
|
||||||
|
res.status(500).json({ success: false, error: 'Upload konnte nicht abgeschlossen werden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
file.mimetype = detected.mime;
|
||||||
|
next();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Magic-Byte-Check fehlgeschlagen:', e);
|
||||||
|
try { fs.unlinkSync(file.path); } catch { /* ignore */ }
|
||||||
|
res.status(500).json({ success: false, error: 'Upload konnte nicht geprüft werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -194,6 +194,15 @@ router.post(
|
|||||||
cachedEmailController.saveEmailAsInvoice
|
cachedEmailController.saveEmailAsInvoice
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// E-Mail als PDF exportieren und als Vertragsdokument hinterlegen
|
||||||
|
// POST /api/emails/:id/save-as-contract-document { documentType, notes?, deliveryDate? }
|
||||||
|
router.post(
|
||||||
|
'/emails/:id/save-as-contract-document',
|
||||||
|
authenticate,
|
||||||
|
requirePermission('contracts:update'),
|
||||||
|
cachedEmailController.saveEmailAsContractDocument
|
||||||
|
);
|
||||||
|
|
||||||
// Anhang als Rechnung speichern
|
// Anhang als Rechnung speichern
|
||||||
// POST /api/emails/:id/attachments/:filename/save-as-invoice { invoiceDate, invoiceType, notes? }
|
// POST /api/emails/:id/attachments/:filename/save-as-invoice { invoiceDate, invoiceType, notes? }
|
||||||
router.post(
|
router.post(
|
||||||
@@ -236,10 +245,14 @@ router.delete(
|
|||||||
|
|
||||||
// E-Mails für ein Konto synchronisieren
|
// E-Mails für ein Konto synchronisieren
|
||||||
// POST /api/stressfrei-emails/:id/sync?full=true
|
// POST /api/stressfrei-emails/:id/sync?full=true
|
||||||
|
//
|
||||||
|
// KEIN `requirePermission('customers:update')` hier: Portal-Kunden
|
||||||
|
// dürfen ihr EIGENES Postfach synchronisieren – sie haben aber nur
|
||||||
|
// `customers:read`. Der Mitarbeiter-Perm-Check und der Owner-Check
|
||||||
|
// laufen im Controller. (Pentest 2026-05-30 follow-up.)
|
||||||
router.post(
|
router.post(
|
||||||
'/stressfrei-emails/:id/sync',
|
'/stressfrei-emails/:id/sync',
|
||||||
authenticate,
|
authenticate,
|
||||||
requirePermission('customers:update'),
|
|
||||||
cachedEmailController.syncAccount
|
cachedEmailController.syncAccount
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import fs from 'fs';
|
|||||||
import * as contractController from '../controllers/contract.controller.js';
|
import * as contractController from '../controllers/contract.controller.js';
|
||||||
import * as invoiceController from '../controllers/invoice.controller.js';
|
import * as invoiceController from '../controllers/invoice.controller.js';
|
||||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||||
|
import { validateUploadedFile } from '../middleware/uploadFileTypeValidator.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -22,11 +23,15 @@ const docUpload = multer({
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
fileFilter: (_req, file, cb) => {
|
fileFilter: (_req, file, cb) => {
|
||||||
const allowed = ['application/pdf', 'image/jpeg', 'image/png', 'image/jpg'];
|
// Pentest 70.1: aligned mit validateUploadedFile-Whitelist
|
||||||
|
// (PDF/JPG/PNG/GIF/WebP). Multer-fileFilter ist nur "fast reject"
|
||||||
|
// anhand des client-gemeldeten MIME-Types; der echte Guard ist der
|
||||||
|
// Magic-Byte-Check in validateUploadedFile.
|
||||||
|
const allowed = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
|
||||||
if (allowed.includes(file.mimetype)) cb(null, true);
|
if (allowed.includes(file.mimetype)) cb(null, true);
|
||||||
else cb(new Error('Nur PDF, JPG und PNG Dateien sind erlaubt'));
|
else cb(new Error('Nur PDF, JPG, PNG, GIF oder WebP-Dateien sind erlaubt'));
|
||||||
},
|
},
|
||||||
limits: { fileSize: 10 * 1024 * 1024 },
|
limits: { fileSize: 25 * 1024 * 1024 },
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/', authenticate, requirePermission('contracts:read'), contractController.getContracts);
|
router.get('/', authenticate, requirePermission('contracts:read'), contractController.getContracts);
|
||||||
@@ -54,7 +59,7 @@ router.post('/:id/invoices', authenticate, requirePermission('contracts:update')
|
|||||||
|
|
||||||
// Vertragsdokumente
|
// Vertragsdokumente
|
||||||
router.get('/:id/documents', authenticate, requirePermission('contracts:read'), contractController.getContractDocuments);
|
router.get('/:id/documents', authenticate, requirePermission('contracts:read'), contractController.getContractDocuments);
|
||||||
router.post('/:id/documents', authenticate, requirePermission('contracts:update'), docUpload.single('file'), contractController.uploadContractDocument);
|
router.post('/:id/documents', authenticate, requirePermission('contracts:update'), docUpload.single('file'), validateUploadedFile, contractController.uploadContractDocument);
|
||||||
router.delete('/:id/documents/:documentId', authenticate, requirePermission('contracts:update'), contractController.deleteContractDocument);
|
router.delete('/:id/documents/:documentId', authenticate, requirePermission('contracts:update'), contractController.deleteContractDocument);
|
||||||
|
|
||||||
// Folgezähler
|
// Folgezähler
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import multer from 'multer';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||||
|
import { requireSafeUploadedPdf } from '../middleware/pdfUploadSafety.js';
|
||||||
import * as gdprController from '../controllers/gdpr.controller.js';
|
import * as gdprController from '../controllers/gdpr.controller.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -27,7 +28,7 @@ const authUpload = multer({
|
|||||||
cb(new Error('Nur PDF-Dateien sind erlaubt'));
|
cb(new Error('Nur PDF-Dateien sind erlaubt'));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
limits: { fileSize: 10 * 1024 * 1024 },
|
limits: { fileSize: 25 * 1024 * 1024 },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Alle Routen erfordern Authentifizierung
|
// Alle Routen erfordern Authentifizierung
|
||||||
@@ -71,6 +72,9 @@ router.put('/website-privacy-policy', requirePermission('gdpr:admin'), gdprContr
|
|||||||
// Consent-Link senden
|
// Consent-Link senden
|
||||||
router.post('/customer/:customerId/send-consent-link', requirePermission('customers:update'), gdprController.sendConsentLink);
|
router.post('/customer/:customerId/send-consent-link', requirePermission('customers:update'), gdprController.sendConsentLink);
|
||||||
|
|
||||||
|
// Unterschreibbare Datenschutzerklärung als PDF (Papierform)
|
||||||
|
router.get('/customer/:customerId/privacy-pdf', requirePermission('customers:read'), gdprController.getSignablePrivacyPdf);
|
||||||
|
|
||||||
// Portal: Eigene Datenschutzseite (nur authenticate, Check im Controller)
|
// Portal: Eigene Datenschutzseite (nur authenticate, Check im Controller)
|
||||||
router.get('/my-privacy', gdprController.getMyPrivacy);
|
router.get('/my-privacy', gdprController.getMyPrivacy);
|
||||||
router.get('/my-privacy/pdf', gdprController.getMyPrivacyPdf);
|
router.get('/my-privacy/pdf', gdprController.getMyPrivacyPdf);
|
||||||
@@ -81,7 +85,7 @@ router.get('/customer/:customerId/authorizations', requirePermission('customers:
|
|||||||
router.post('/customer/:customerId/authorizations/:representativeId/send', requirePermission('customers:update'), gdprController.sendAuthorizationRequest);
|
router.post('/customer/:customerId/authorizations/:representativeId/send', requirePermission('customers:update'), gdprController.sendAuthorizationRequest);
|
||||||
router.post('/customer/:customerId/authorizations/:representativeId/grant', requirePermission('customers:update'), gdprController.grantAuthorization);
|
router.post('/customer/:customerId/authorizations/:representativeId/grant', requirePermission('customers:update'), gdprController.grantAuthorization);
|
||||||
router.post('/customer/:customerId/authorizations/:representativeId/withdraw', requirePermission('customers:update'), gdprController.withdrawAuthorization);
|
router.post('/customer/:customerId/authorizations/:representativeId/withdraw', requirePermission('customers:update'), gdprController.withdrawAuthorization);
|
||||||
router.post('/customer/:customerId/authorizations/:representativeId/upload', requirePermission('customers:update'), authUpload.single('document'), gdprController.uploadAuthorizationDocument);
|
router.post('/customer/:customerId/authorizations/:representativeId/upload', requirePermission('customers:update'), authUpload.single('document'), requireSafeUploadedPdf, gdprController.uploadAuthorizationDocument);
|
||||||
router.delete('/customer/:customerId/authorizations/:representativeId/document', requirePermission('customers:update'), gdprController.deleteAuthorizationDocument);
|
router.delete('/customer/:customerId/authorizations/:representativeId/document', requirePermission('customers:update'), gdprController.deleteAuthorizationDocument);
|
||||||
|
|
||||||
// Portal: Vollmachten
|
// Portal: Vollmachten
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import multer from 'multer';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||||
|
import { requireSafeUploadedPdf } from '../middleware/pdfUploadSafety.js';
|
||||||
import * as pdfTemplateController from '../controllers/pdfTemplate.controller.js';
|
import * as pdfTemplateController from '../controllers/pdfTemplate.controller.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -34,7 +35,7 @@ router.use(authenticate);
|
|||||||
router.get('/', requirePermission('settings:read'), pdfTemplateController.getTemplates);
|
router.get('/', requirePermission('settings:read'), pdfTemplateController.getTemplates);
|
||||||
router.get('/crm-fields', requirePermission('settings:read'), pdfTemplateController.getCrmFields);
|
router.get('/crm-fields', requirePermission('settings:read'), pdfTemplateController.getCrmFields);
|
||||||
router.get('/:id', requirePermission('settings:read'), pdfTemplateController.getTemplate);
|
router.get('/:id', requirePermission('settings:read'), pdfTemplateController.getTemplate);
|
||||||
router.post('/', requirePermission('settings:update'), upload.single('template'), pdfTemplateController.createTemplate);
|
router.post('/', requirePermission('settings:update'), upload.single('template'), requireSafeUploadedPdf, pdfTemplateController.createTemplate);
|
||||||
router.put('/:id', requirePermission('settings:update'), pdfTemplateController.updateTemplate);
|
router.put('/:id', requirePermission('settings:update'), pdfTemplateController.updateTemplate);
|
||||||
router.delete('/:id', requirePermission('settings:update'), pdfTemplateController.deleteTemplate);
|
router.delete('/:id', requirePermission('settings:update'), pdfTemplateController.deleteTemplate);
|
||||||
|
|
||||||
|
|||||||
@@ -15,4 +15,8 @@ router.post('/:id/reset-password', authenticate, requirePermission('customers:up
|
|||||||
// Weiterleitungen neu setzen (z.B. nach Änderung der Kunden-Stamm-E-Mail)
|
// Weiterleitungen neu setzen (z.B. nach Änderung der Kunden-Stamm-E-Mail)
|
||||||
router.post('/:id/sync-forwarding', authenticate, requirePermission('customers:update'), stressfreiEmailController.syncForwarding);
|
router.post('/:id/sync-forwarding', authenticate, requirePermission('customers:update'), stressfreiEmailController.syncForwarding);
|
||||||
|
|
||||||
|
// Zusätzliche Weiterleitungs-Ziele setzen (User-pflegbare Liste, zusätzlich
|
||||||
|
// zur Stamm-E-Mail des Kunden und der globalen Default-Forward-Adresse).
|
||||||
|
router.put('/:id/additional-forwards', authenticate, requirePermission('customers:update'), stressfreiEmailController.updateAdditionalForwards);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -6,7 +6,33 @@ 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';
|
import {
|
||||||
|
canAccessContract,
|
||||||
|
canAccessCustomer,
|
||||||
|
canAccessBankCard,
|
||||||
|
canAccessIdentityDocument,
|
||||||
|
} from '../utils/accessControl.js';
|
||||||
|
import { validateOptionalIsoDate } from '../utils/sanitize.js';
|
||||||
|
import { validateUploadedFile } from '../middleware/uploadFileTypeValidator.js';
|
||||||
|
|
||||||
|
// Pentest 56.1 (HIGH, 2026-06-01): Upload-Endpoints prüften nur die
|
||||||
|
// Permission, nicht ob die Ziel-Resource zum Caller passt. Helper-Funktion
|
||||||
|
// für saubere 404-Antwort + Datei-Cleanup, wenn die Resource nicht
|
||||||
|
// existiert. Anschließend laufen die `canAccess*`-Checks (Portal-User
|
||||||
|
// werden dort auf ihre eigenen Kunden eingeschränkt; Staff bekommen
|
||||||
|
// volle Sicht – konsistent mit der bestehenden Access-Control-Logik).
|
||||||
|
async function resolveInvoiceContractId(invoiceId: number): Promise<number | null> {
|
||||||
|
const invoice = await prisma.invoice.findUnique({
|
||||||
|
where: { id: invoiceId },
|
||||||
|
select: { contractId: true, energyContractDetails: { select: { contractId: true } } },
|
||||||
|
});
|
||||||
|
return invoice?.contractId ?? invoice?.energyContractDetails?.contractId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupFile(filePath?: string) {
|
||||||
|
if (!filePath) return;
|
||||||
|
try { fs.unlinkSync(filePath); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -38,12 +64,18 @@ const fileFilter = (
|
|||||||
file: Express.Multer.File,
|
file: Express.Multer.File,
|
||||||
cb: multer.FileFilterCallback
|
cb: multer.FileFilterCallback
|
||||||
) => {
|
) => {
|
||||||
// Nur PDFs und Bilder erlauben
|
// PDFs + gängige Web-Bildformate. WebP + GIF nachgezogen 2026-05-30
|
||||||
const allowedTypes = ['application/pdf', 'image/jpeg', 'image/png', 'image/jpg'];
|
// (Pentest INFO: WebP/GIF lieferten 500 statt sauberem 4xx, weil
|
||||||
|
// erlaubter MIME-Type fehlte und der fileFilter dann throwte).
|
||||||
|
const allowedTypes = [
|
||||||
|
'application/pdf',
|
||||||
|
'image/jpeg', 'image/jpg', 'image/png',
|
||||||
|
'image/gif', 'image/webp',
|
||||||
|
];
|
||||||
if (allowedTypes.includes(file.mimetype)) {
|
if (allowedTypes.includes(file.mimetype)) {
|
||||||
cb(null, true);
|
cb(null, true);
|
||||||
} else {
|
} else {
|
||||||
cb(new Error('Nur PDF, JPG und PNG Dateien sind erlaubt'));
|
cb(new Error('Nur PDF, JPG, PNG, GIF und WebP-Dateien sind erlaubt'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -51,7 +83,10 @@ const upload = multer({
|
|||||||
storage,
|
storage,
|
||||||
fileFilter,
|
fileFilter,
|
||||||
limits: {
|
limits: {
|
||||||
fileSize: 10 * 1024 * 1024, // 10MB max
|
// 25 MB – passt für Ausweis-Scans, Handy-Photos im JpgToPdf-Flow,
|
||||||
|
// mehrseitige PDFs aus dem Modal (bis ~5-7 Seiten je nach Auflösung).
|
||||||
|
// Vorher 10 MB → Multer brach bei zwei Smartphone-Fotos ab.
|
||||||
|
fileSize: 25 * 1024 * 1024,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -63,6 +98,10 @@ function setUploadDir(subDir: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Magic-Byte-Whitelist + canonical Rename + PDF-Scan: siehe
|
||||||
|
// middleware/uploadFileTypeValidator.ts (zentralisiert, damit auch
|
||||||
|
// contract.routes.ts denselben Check fahren kann – Pentest 69.3).
|
||||||
|
|
||||||
// Upload für Bankkarten-Dokumente
|
// Upload für Bankkarten-Dokumente
|
||||||
router.post(
|
router.post(
|
||||||
'/bank-cards/:id',
|
'/bank-cards/:id',
|
||||||
@@ -70,6 +109,7 @@ router.post(
|
|||||||
requirePermission('customers:update'),
|
requirePermission('customers:update'),
|
||||||
setUploadDir('bank-cards'),
|
setUploadDir('bank-cards'),
|
||||||
upload.single('document'),
|
upload.single('document'),
|
||||||
|
validateUploadedFile,
|
||||||
async (req: AuthRequest, res: Response) => {
|
async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
@@ -78,9 +118,20 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const bankCardId = parseInt(req.params.id);
|
const bankCardId = parseInt(req.params.id);
|
||||||
|
// Pentest 56.1: Existenz- und Ownership-Check VOR DB-Update.
|
||||||
|
const card = await prisma.bankCard.findUnique({ where: { id: bankCardId } });
|
||||||
|
if (!card) {
|
||||||
|
cleanupFile(req.file.path);
|
||||||
|
res.status(404).json({ success: false, error: 'Bankkarte nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(await canAccessBankCard(req, res, bankCardId))) {
|
||||||
|
cleanupFile(req.file.path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const relativePath = `/uploads/bank-cards/${req.file.filename}`;
|
const relativePath = `/uploads/bank-cards/${req.file.filename}`;
|
||||||
|
|
||||||
// Bankkarte in der DB aktualisieren
|
|
||||||
await prisma.bankCard.update({
|
await prisma.bankCard.update({
|
||||||
where: { id: bankCardId },
|
where: { id: bankCardId },
|
||||||
data: { documentPath: relativePath },
|
data: { documentPath: relativePath },
|
||||||
@@ -97,6 +148,7 @@ router.post(
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Upload error:', error);
|
console.error('Upload error:', error);
|
||||||
|
cleanupFile(req.file?.path);
|
||||||
res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' });
|
res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,6 +161,7 @@ router.post(
|
|||||||
requirePermission('customers:update'),
|
requirePermission('customers:update'),
|
||||||
setUploadDir('documents'),
|
setUploadDir('documents'),
|
||||||
upload.single('document'),
|
upload.single('document'),
|
||||||
|
validateUploadedFile,
|
||||||
async (req: AuthRequest, res: Response) => {
|
async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
@@ -117,9 +170,20 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const documentId = parseInt(req.params.id);
|
const documentId = parseInt(req.params.id);
|
||||||
|
// Pentest 56.1: Existenz- und Ownership-Check VOR DB-Update.
|
||||||
|
const doc = await prisma.identityDocument.findUnique({ where: { id: documentId } });
|
||||||
|
if (!doc) {
|
||||||
|
cleanupFile(req.file.path);
|
||||||
|
res.status(404).json({ success: false, error: 'Ausweis nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(await canAccessIdentityDocument(req, res, documentId))) {
|
||||||
|
cleanupFile(req.file.path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const relativePath = `/uploads/documents/${req.file.filename}`;
|
const relativePath = `/uploads/documents/${req.file.filename}`;
|
||||||
|
|
||||||
// Ausweis in der DB aktualisieren
|
|
||||||
await prisma.identityDocument.update({
|
await prisma.identityDocument.update({
|
||||||
where: { id: documentId },
|
where: { id: documentId },
|
||||||
data: { documentPath: relativePath },
|
data: { documentPath: relativePath },
|
||||||
@@ -136,6 +200,7 @@ router.post(
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Upload error:', error);
|
console.error('Upload error:', error);
|
||||||
|
cleanupFile(req.file?.path);
|
||||||
res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' });
|
res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,6 +224,8 @@ router.delete(
|
|||||||
res.status(404).json({ success: false, error: 'Bankkarte nicht gefunden' });
|
res.status(404).json({ success: false, error: 'Bankkarte nicht gefunden' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Pentest 56.1: Ownership-Check.
|
||||||
|
if (!(await canAccessBankCard(req, res, bankCardId))) return;
|
||||||
|
|
||||||
if (!bankCard.documentPath) {
|
if (!bankCard.documentPath) {
|
||||||
res.status(400).json({ success: false, error: 'Kein Dokument vorhanden' });
|
res.status(400).json({ success: false, error: 'Kein Dokument vorhanden' });
|
||||||
@@ -203,6 +270,8 @@ router.delete(
|
|||||||
res.status(404).json({ success: false, error: 'Ausweis nicht gefunden' });
|
res.status(404).json({ success: false, error: 'Ausweis nicht gefunden' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Pentest 56.1: Ownership-Check.
|
||||||
|
if (!(await canAccessIdentityDocument(req, res, documentId))) return;
|
||||||
|
|
||||||
if (!document.documentPath) {
|
if (!document.documentPath) {
|
||||||
res.status(400).json({ success: false, error: 'Kein Dokument vorhanden' });
|
res.status(400).json({ success: false, error: 'Kein Dokument vorhanden' });
|
||||||
@@ -238,6 +307,7 @@ router.post(
|
|||||||
requirePermission('customers:update'),
|
requirePermission('customers:update'),
|
||||||
setUploadDir('business-registrations'),
|
setUploadDir('business-registrations'),
|
||||||
upload.single('document'),
|
upload.single('document'),
|
||||||
|
validateUploadedFile,
|
||||||
async (req: AuthRequest, res: Response) => {
|
async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
@@ -246,6 +316,11 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const customerId = parseInt(req.params.id);
|
const customerId = parseInt(req.params.id);
|
||||||
|
// Pentest 56.1: Ownership-Check.
|
||||||
|
if (!(await canAccessCustomer(req, res, customerId))) {
|
||||||
|
cleanupFile(req.file.path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const relativePath = `/uploads/business-registrations/${req.file.filename}`;
|
const relativePath = `/uploads/business-registrations/${req.file.filename}`;
|
||||||
|
|
||||||
// Alte Datei löschen falls vorhanden
|
// Alte Datei löschen falls vorhanden
|
||||||
@@ -286,6 +361,7 @@ router.post(
|
|||||||
requirePermission('customers:update'),
|
requirePermission('customers:update'),
|
||||||
setUploadDir('commercial-registers'),
|
setUploadDir('commercial-registers'),
|
||||||
upload.single('document'),
|
upload.single('document'),
|
||||||
|
validateUploadedFile,
|
||||||
async (req: AuthRequest, res: Response) => {
|
async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
@@ -294,6 +370,11 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const customerId = parseInt(req.params.id);
|
const customerId = parseInt(req.params.id);
|
||||||
|
// Pentest 56.1: Ownership-Check.
|
||||||
|
if (!(await canAccessCustomer(req, res, customerId))) {
|
||||||
|
cleanupFile(req.file.path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const relativePath = `/uploads/commercial-registers/${req.file.filename}`;
|
const relativePath = `/uploads/commercial-registers/${req.file.filename}`;
|
||||||
|
|
||||||
// Alte Datei löschen falls vorhanden
|
// Alte Datei löschen falls vorhanden
|
||||||
@@ -335,6 +416,8 @@ router.delete(
|
|||||||
async (req: AuthRequest, res: Response) => {
|
async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.id);
|
const customerId = parseInt(req.params.id);
|
||||||
|
// Pentest 56.1: Ownership-Check.
|
||||||
|
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||||
|
|
||||||
const customer = await prisma.customer.findUnique({ where: { id: customerId } });
|
const customer = await prisma.customer.findUnique({ where: { id: customerId } });
|
||||||
if (!customer) {
|
if (!customer) {
|
||||||
@@ -375,6 +458,8 @@ router.delete(
|
|||||||
async (req: AuthRequest, res: Response) => {
|
async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.id);
|
const customerId = parseInt(req.params.id);
|
||||||
|
// Pentest 56.1: Ownership-Check.
|
||||||
|
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||||
|
|
||||||
const customer = await prisma.customer.findUnique({ where: { id: customerId } });
|
const customer = await prisma.customer.findUnique({ where: { id: customerId } });
|
||||||
if (!customer) {
|
if (!customer) {
|
||||||
@@ -416,6 +501,7 @@ router.post(
|
|||||||
requirePermission('customers:update'),
|
requirePermission('customers:update'),
|
||||||
setUploadDir('privacy-policies'),
|
setUploadDir('privacy-policies'),
|
||||||
upload.single('document'),
|
upload.single('document'),
|
||||||
|
validateUploadedFile,
|
||||||
async (req: AuthRequest, res: Response) => {
|
async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
@@ -424,6 +510,14 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const customerId = parseInt(req.params.id);
|
const customerId = parseInt(req.params.id);
|
||||||
|
// Pentest 56.2 (HIGH): Ownership-Check VOR Consent-Massen-Update.
|
||||||
|
// Ohne diese Prüfung konnte jeder Caller mit customers:update für
|
||||||
|
// jede beliebige customerId ALLE Einwilligungen auf GRANTED setzen
|
||||||
|
// (DSGVO-Eskalation).
|
||||||
|
if (!(await canAccessCustomer(req, res, customerId))) {
|
||||||
|
cleanupFile(req.file.path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const relativePath = `/uploads/privacy-policies/${req.file.filename}`;
|
const relativePath = `/uploads/privacy-policies/${req.file.filename}`;
|
||||||
|
|
||||||
// Alte Datei löschen falls vorhanden
|
// Alte Datei löschen falls vorhanden
|
||||||
@@ -484,6 +578,8 @@ router.delete(
|
|||||||
async (req: AuthRequest, res: Response) => {
|
async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.id);
|
const customerId = parseInt(req.params.id);
|
||||||
|
// Pentest 56.1: Ownership-Check.
|
||||||
|
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||||
|
|
||||||
const customer = await prisma.customer.findUnique({ where: { id: customerId } });
|
const customer = await prisma.customer.findUnique({ where: { id: customerId } });
|
||||||
if (!customer) {
|
if (!customer) {
|
||||||
@@ -572,11 +668,18 @@ async function handleContractDocumentUpload(
|
|||||||
const dateField = fieldName === 'cancellationConfirmationPath'
|
const dateField = fieldName === 'cancellationConfirmationPath'
|
||||||
? 'cancellationConfirmationDate'
|
? 'cancellationConfirmationDate'
|
||||||
: 'cancellationConfirmationOptionsDate';
|
: 'cancellationConfirmationOptionsDate';
|
||||||
const provided = typeof req.body?.confirmationDate === 'string' ? req.body.confirmationDate : null;
|
// Pentest 62.7: confirmationDate gegen ISO-8601 validieren.
|
||||||
|
let provided: string | null;
|
||||||
|
try {
|
||||||
|
provided = validateOptionalIsoDate(req.body?.confirmationDate, 'confirmationDate');
|
||||||
|
} catch (err) {
|
||||||
|
cleanupFile(req.file?.path);
|
||||||
|
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültiges Bestätigungsdatum' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
let target: Date | null = null;
|
let target: Date | null = null;
|
||||||
if (provided) {
|
if (provided) {
|
||||||
const parsed = new Date(provided);
|
target = new Date(provided);
|
||||||
if (!isNaN(parsed.getTime())) target = parsed;
|
|
||||||
}
|
}
|
||||||
if (target) {
|
if (target) {
|
||||||
updateData[dateField] = target;
|
updateData[dateField] = target;
|
||||||
@@ -673,6 +776,7 @@ router.post(
|
|||||||
requirePermission('contracts:update'),
|
requirePermission('contracts:update'),
|
||||||
setUploadDir('cancellation-letters'),
|
setUploadDir('cancellation-letters'),
|
||||||
upload.single('document'),
|
upload.single('document'),
|
||||||
|
validateUploadedFile,
|
||||||
(req: AuthRequest, res: Response) => handleContractDocumentUpload(req, res, 'cancellationLetterPath', 'cancellation-letters')
|
(req: AuthRequest, res: Response) => handleContractDocumentUpload(req, res, 'cancellationLetterPath', 'cancellation-letters')
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -690,6 +794,7 @@ router.post(
|
|||||||
requirePermission('contracts:update'),
|
requirePermission('contracts:update'),
|
||||||
setUploadDir('cancellation-confirmations'),
|
setUploadDir('cancellation-confirmations'),
|
||||||
upload.single('document'),
|
upload.single('document'),
|
||||||
|
validateUploadedFile,
|
||||||
(req: AuthRequest, res: Response) => handleContractDocumentUpload(req, res, 'cancellationConfirmationPath', 'cancellation-confirmations')
|
(req: AuthRequest, res: Response) => handleContractDocumentUpload(req, res, 'cancellationConfirmationPath', 'cancellation-confirmations')
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -707,6 +812,7 @@ router.post(
|
|||||||
requirePermission('contracts:update'),
|
requirePermission('contracts:update'),
|
||||||
setUploadDir('cancellation-letters-options'),
|
setUploadDir('cancellation-letters-options'),
|
||||||
upload.single('document'),
|
upload.single('document'),
|
||||||
|
validateUploadedFile,
|
||||||
(req: AuthRequest, res: Response) => handleContractDocumentUpload(req, res, 'cancellationLetterOptionsPath', 'cancellation-letters-options')
|
(req: AuthRequest, res: Response) => handleContractDocumentUpload(req, res, 'cancellationLetterOptionsPath', 'cancellation-letters-options')
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -724,6 +830,7 @@ router.post(
|
|||||||
requirePermission('contracts:update'),
|
requirePermission('contracts:update'),
|
||||||
setUploadDir('cancellation-confirmations-options'),
|
setUploadDir('cancellation-confirmations-options'),
|
||||||
upload.single('document'),
|
upload.single('document'),
|
||||||
|
validateUploadedFile,
|
||||||
(req: AuthRequest, res: Response) => handleContractDocumentUpload(req, res, 'cancellationConfirmationOptionsPath', 'cancellation-confirmations-options')
|
(req: AuthRequest, res: Response) => handleContractDocumentUpload(req, res, 'cancellationConfirmationOptionsPath', 'cancellation-confirmations-options')
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -743,6 +850,7 @@ router.post(
|
|||||||
requirePermission('contracts:update'),
|
requirePermission('contracts:update'),
|
||||||
setUploadDir('invoices'),
|
setUploadDir('invoices'),
|
||||||
upload.single('document'),
|
upload.single('document'),
|
||||||
|
validateUploadedFile,
|
||||||
async (req: AuthRequest, res: Response) => {
|
async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
@@ -751,14 +859,20 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const invoiceId = parseInt(req.params.id);
|
const invoiceId = parseInt(req.params.id);
|
||||||
const relativePath = `/uploads/invoices/${req.file.filename}`;
|
// Pentest 56.1: Existenz- und Ownership-Check VOR DB-Update.
|
||||||
|
|
||||||
// Alte Datei löschen falls vorhanden
|
|
||||||
const invoice = await prisma.invoice.findUnique({ where: { id: invoiceId } });
|
const invoice = await prisma.invoice.findUnique({ where: { id: invoiceId } });
|
||||||
if (!invoice) {
|
if (!invoice) {
|
||||||
|
cleanupFile(req.file.path);
|
||||||
res.status(404).json({ success: false, error: 'Rechnung nicht gefunden' });
|
res.status(404).json({ success: false, error: 'Rechnung nicht gefunden' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const invoiceContractId = await resolveInvoiceContractId(invoiceId);
|
||||||
|
if (invoiceContractId == null || !(await canAccessContract(req, res, invoiceContractId))) {
|
||||||
|
cleanupFile(req.file.path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relativePath = `/uploads/invoices/${req.file.filename}`;
|
||||||
|
|
||||||
if (invoice.documentPath) {
|
if (invoice.documentPath) {
|
||||||
const oldPath = path.join(process.cwd(), invoice.documentPath);
|
const oldPath = path.join(process.cwd(), invoice.documentPath);
|
||||||
@@ -803,6 +917,9 @@ router.delete(
|
|||||||
res.status(404).json({ success: false, error: 'Rechnung nicht gefunden' });
|
res.status(404).json({ success: false, error: 'Rechnung nicht gefunden' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Pentest 56.1: Ownership-Check vor Delete.
|
||||||
|
const invoiceContractId = await resolveInvoiceContractId(invoiceId);
|
||||||
|
if (invoiceContractId == null || !(await canAccessContract(req, res, invoiceContractId))) return;
|
||||||
|
|
||||||
if (!invoice.documentPath) {
|
if (!invoice.documentPath) {
|
||||||
res.status(400).json({ success: false, error: 'Kein Dokument vorhanden' });
|
res.status(400).json({ success: false, error: 'Kein Dokument vorhanden' });
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import * as userController from '../controllers/user.controller.js';
|
import * as userController from '../controllers/user.controller.js';
|
||||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||||
|
import { staffPasswordReAuthLimiter } from '../middleware/rateLimit.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -10,8 +11,10 @@ router.post('/', authenticate, requirePermission('users:create'), userController
|
|||||||
router.get('/:id', authenticate, requirePermission('users:read'), userController.getUser);
|
router.get('/:id', authenticate, requirePermission('users:read'), userController.getUser);
|
||||||
router.put('/:id', authenticate, requirePermission('users:update'), userController.updateUser);
|
router.put('/:id', authenticate, requirePermission('users:update'), userController.updateUser);
|
||||||
router.delete('/:id', authenticate, requirePermission('users:delete'), userController.deleteUser);
|
router.delete('/:id', authenticate, requirePermission('users:delete'), userController.deleteUser);
|
||||||
// Passwort-Reset durch Admin – dedizierter Endpoint (Pentest Runde 12)
|
// Passwort-Reset durch Admin – dedizierter Endpoint (Pentest Runde 12).
|
||||||
router.post('/:id/password', authenticate, requirePermission('users:update'), userController.setUserPassword);
|
// 47.3 verlangt Re-Auth (currentPassword), 48.3 wirft einen Rate-Limit
|
||||||
|
// davor, damit ein gestohlener JWT das Admin-Passwort nicht brute-forcen kann.
|
||||||
|
router.post('/:id/password', staffPasswordReAuthLimiter, authenticate, requirePermission('users:update'), userController.setUserPassword);
|
||||||
|
|
||||||
// Roles
|
// Roles
|
||||||
router.get('/roles/list', authenticate, requirePermission('users:read'), userController.getRoles);
|
router.get('/roles/list', authenticate, requirePermission('users:read'), userController.getRoles);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import { stripHtml } from '../utils/sanitize.js';
|
import { stripHtml } from '../utils/sanitize.js';
|
||||||
|
import { validateHttpUrl } from '../utils/url.js';
|
||||||
|
|
||||||
// Default settings
|
// Default settings
|
||||||
const DEFAULT_SETTINGS: Record<string, string> = {
|
const DEFAULT_SETTINGS: Record<string, string> = {
|
||||||
@@ -28,6 +29,10 @@ export const ALLOWED_SETTING_KEYS: ReadonlySet<string> = new Set([
|
|||||||
'monitoringLastDigestAt',
|
'monitoringLastDigestAt',
|
||||||
'companyName',
|
'companyName',
|
||||||
'defaultEmailDomain',
|
'defaultEmailDomain',
|
||||||
|
// Basis-URL für an Kunden verschickte Portal-Links (Login + Passwort-Reset).
|
||||||
|
// Vorher kam aus `PUBLIC_URL`-Env, default localhost – Mails enthielten
|
||||||
|
// dann unklickbare Links. Wird in Settings → Kundenportal gepflegt.
|
||||||
|
'portalLoginUrl',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function isAllowedSettingKey(key: string): boolean {
|
export function isAllowedSettingKey(key: string): boolean {
|
||||||
@@ -60,6 +65,70 @@ export function sanitizeSettingValue(key: string, value: string): string {
|
|||||||
return typeof stripped === 'string' ? stripped : String(stripped);
|
return typeof stripped === 'string' ? stripped : String(stripped);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema-spezifische Wert-Validierung VOR dem Speichern. Wird vom
|
||||||
|
* Controller aufgerufen; liefert entweder { ok: true, value: <sanitized> }
|
||||||
|
* oder { ok: false, error: <message> } für 400.
|
||||||
|
*
|
||||||
|
* Hintergrund Pentest 2026-05-28 LOW 34.5: Schema-Whitelist und
|
||||||
|
* Slash-Trimming standen NUR im Frontend, der API-Endpoint nahm
|
||||||
|
* relative URLs (`/evil/path`), `javascript:`-Schemata und Adressen
|
||||||
|
* auf private Hosts (`http://192.168.1.1`) ungeprüft entgegen. Bei
|
||||||
|
* Cloud-Deployment war das ein SSRF-/Open-Redirect-Vektor in der
|
||||||
|
* an Kunden verschickten Mail.
|
||||||
|
*/
|
||||||
|
export function validateSettingValue(key: string, rawValue: string): { ok: true; value: string } | { ok: false; error: string } {
|
||||||
|
// Schwellenwerte: müssen positive ganze Zahlen sein, sonst läuft das
|
||||||
|
// Cockpit in NaN-Vergleichen. Bestehende Validierung war nicht
|
||||||
|
// konsequent.
|
||||||
|
const intKeys = new Set(['deadlineCriticalDays', 'deadlineWarningDays', 'deadlineOkDays', 'documentExpiryCriticalDays', 'documentExpiryWarningDays']);
|
||||||
|
if (intKeys.has(key)) {
|
||||||
|
const trimmed = rawValue.trim();
|
||||||
|
if (!/^\d+$/.test(trimmed)) {
|
||||||
|
return { ok: false, error: `${key} muss eine positive ganze Zahl sein.` };
|
||||||
|
}
|
||||||
|
return { ok: true, value: trimmed };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bool-Settings
|
||||||
|
if (key === 'customerSupportTicketsEnabled' || key === 'monitoringDigestEnabled') {
|
||||||
|
const trimmed = rawValue.trim().toLowerCase();
|
||||||
|
if (trimmed !== 'true' && trimmed !== 'false') {
|
||||||
|
return { ok: false, error: `${key} muss 'true' oder 'false' sein.` };
|
||||||
|
}
|
||||||
|
return { ok: true, value: trimmed };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email-Settings (Format-Check analog zu Customer/User – verhindert
|
||||||
|
// Header-Injection in System-Mails)
|
||||||
|
if (key === 'monitoringAlertEmail') {
|
||||||
|
const trimmed = rawValue.trim();
|
||||||
|
if (trimmed === '') return { ok: true, value: '' };
|
||||||
|
// RFC-5322-light, gleiches Pattern wie isValidEmail in utils/sanitize
|
||||||
|
if (/[\r\n\t\0\v\f]/.test(trimmed) || trimmed.length > 254) {
|
||||||
|
return { ok: false, error: 'Ungültige E-Mail-Adresse.' };
|
||||||
|
}
|
||||||
|
if (!/^[A-Za-z0-9._%+\-]{1,64}@[A-Za-z0-9.\-]{1,253}\.[A-Za-z]{2,}$/.test(trimmed)) {
|
||||||
|
return { ok: false, error: 'Ungültiges E-Mail-Format.' };
|
||||||
|
}
|
||||||
|
return { ok: true, value: trimmed };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Portal-Login-URL: nur http/https, keine privaten/loopback-Hosts.
|
||||||
|
// Strikter als `isBlockedSsrfHost`, weil der Wert in Mails an Endkunden
|
||||||
|
// landet – die können 127.0.0.1/10.x/172.16.x/192.168.x ohnehin nicht
|
||||||
|
// erreichen. (Pentest Runde 35 LOW 34.5-followup, on-prem-Override
|
||||||
|
// SSRF_BLOCK_PRIVATE_IPS gilt hier explizit NICHT.)
|
||||||
|
// Werte mit Pfad/Query sind erlaubt – Mail-Versand hängt ohnehin
|
||||||
|
// /portal/login hinten dran, eine evtl. Pfad-Komponente bleibt.
|
||||||
|
if (key === 'portalLoginUrl') {
|
||||||
|
return validateHttpUrl(rawValue, { fieldLabel: 'Portal-Login-URL' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: kein zusätzlicher Format-Check
|
||||||
|
return { ok: true, value: rawValue };
|
||||||
|
}
|
||||||
|
|
||||||
export async function getSetting(key: string): Promise<string | null> {
|
export async function getSetting(key: string): Promise<string | null> {
|
||||||
const setting = await prisma.appSetting.findUnique({
|
const setting = await prisma.appSetting.findUnique({
|
||||||
where: { key },
|
where: { key },
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { encrypt, decrypt } from '../utils/encryption.js';
|
|||||||
import { sendEmail, SmtpCredentials } from './smtpService.js';
|
import { sendEmail, SmtpCredentials } from './smtpService.js';
|
||||||
import { getSystemEmailCredentials } from './emailProvider/emailProviderService.js';
|
import { getSystemEmailCredentials } from './emailProvider/emailProviderService.js';
|
||||||
import { getAuthorizedCustomerIds } from './authorization.service.js';
|
import { getAuthorizedCustomerIds } from './authorization.service.js';
|
||||||
|
import * as appSettingService from './appSetting.service.js';
|
||||||
|
|
||||||
// Token-Lifetimes
|
// Token-Lifetimes
|
||||||
// - Access-Token: kurzlebig, nur im Browser-Memory → XSS klaut max. 15 min
|
// - Access-Token: kurzlebig, nur im Browser-Memory → XSS klaut max. 15 min
|
||||||
@@ -581,8 +582,29 @@ function generateResetToken(): string {
|
|||||||
return crypto.randomBytes(32).toString('hex');
|
return crypto.randomBytes(32).toString('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPublicUrl(): string {
|
/**
|
||||||
return process.env.PUBLIC_URL || 'http://localhost:5173';
|
* Liefert die Basis-URL für an Kunden verschickte Links (Portal-Login,
|
||||||
|
* Passwort-Reset). Reihenfolge:
|
||||||
|
* 1. `portalLoginUrl` aus AppSettings (vom Admin in Settings → Kundenportal
|
||||||
|
* gepflegt). Wenn HTTPS-Domain hier eingetragen, wird die in Mails
|
||||||
|
* verwendet, nicht der Localhost-Default.
|
||||||
|
* 2. `PUBLIC_URL`-Env (für Setups ohne Admin-UI-Konfiguration).
|
||||||
|
* 3. Fallback `http://localhost:5173` (Dev-Default).
|
||||||
|
* Hat Trailing-Slash-Bereinigung, sonst kommen Links wie
|
||||||
|
* `https://crm.de//portal/login` zustande.
|
||||||
|
*/
|
||||||
|
// Pentest 59.4 Nebenbefund (2026-06-01): Consent-URL kam mit
|
||||||
|
// `localhost:5173` raus, weil PUBLIC_URL nicht gesetzt war und
|
||||||
|
// req.headers.origin im Hintergrund-Pfad nicht greift. Helper jetzt
|
||||||
|
// EXPORT, damit auch der GDPR-Controller (sendConsentLink etc.)
|
||||||
|
// dieselbe Quelle der Wahrheit nutzt – inklusive admin-konfigurierbarer
|
||||||
|
// portalLoginUrl App-Setting.
|
||||||
|
export async function getPublicUrl(): Promise<string> {
|
||||||
|
const fromSettings = await appSettingService.getSetting('portalLoginUrl');
|
||||||
|
const raw = (fromSettings && fromSettings.trim())
|
||||||
|
|| process.env.PUBLIC_URL
|
||||||
|
|| 'http://localhost:5173';
|
||||||
|
return raw.replace(/\/+$/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -610,7 +632,7 @@ export async function sendPortalCredentialsEmail(params: {
|
|||||||
allowSelfSignedCerts: systemEmail.allowSelfSignedCerts,
|
allowSelfSignedCerts: systemEmail.allowSelfSignedCerts,
|
||||||
};
|
};
|
||||||
|
|
||||||
const loginUrl = `${getPublicUrl()}/portal/login`;
|
const loginUrl = `${await getPublicUrl()}/portal/login`;
|
||||||
const name = params.customer.companyName?.trim()
|
const name = params.customer.companyName?.trim()
|
||||||
|| `${params.customer.firstName || ''} ${params.customer.lastName || ''}`.trim()
|
|| `${params.customer.firstName || ''} ${params.customer.lastName || ''}`.trim()
|
||||||
|| 'Kunde';
|
|| 'Kunde';
|
||||||
@@ -709,7 +731,7 @@ export async function requestPasswordReset(email: string, userType: 'admin' | 'p
|
|||||||
if (!recipient) return;
|
if (!recipient) return;
|
||||||
|
|
||||||
// Reset-Link + Email senden
|
// Reset-Link + Email senden
|
||||||
const resetUrl = `${getPublicUrl()}/password-reset?token=${token}&type=${userType}`;
|
const resetUrl = `${await getPublicUrl()}/password-reset?token=${token}&type=${userType}`;
|
||||||
const systemEmail = await getSystemEmailCredentials();
|
const systemEmail = await getSystemEmailCredentials();
|
||||||
|
|
||||||
if (!systemEmail) {
|
if (!systemEmail) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { assertValidDocumentPath } from '../utils/sanitize.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vollmachten für einen Kunden abrufen (wer darf diesen Kunden einsehen?)
|
* Vollmachten für einen Kunden abrufen (wer darf diesen Kunden einsehen?)
|
||||||
@@ -53,6 +54,8 @@ export async function grantAuthorization(
|
|||||||
representativeId: number,
|
representativeId: number,
|
||||||
data: { source?: string; documentPath?: string; notes?: string }
|
data: { source?: string; documentPath?: string; notes?: string }
|
||||||
) {
|
) {
|
||||||
|
// Pentest 26.7 (Defense-in-Depth): documentPath nur als /uploads/<safe>.
|
||||||
|
assertValidDocumentPath(data.documentPath, 'documentPath');
|
||||||
return prisma.representativeAuthorization.upsert({
|
return prisma.representativeAuthorization.upsert({
|
||||||
where: {
|
where: {
|
||||||
customerId_representativeId: { customerId, representativeId },
|
customerId_representativeId: { customerId, representativeId },
|
||||||
|
|||||||
@@ -1040,20 +1040,41 @@ 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 entpacken – mit Schutz gegen Zip-Slip (../../etc/passwd Angriff).
|
||||||
// Jeder Eintragspfad muss innerhalb von finalBackupDir bleiben.
|
// Pentest 57.8 (2026-06-01): Mehrstufige Verteidigung gegen Path-
|
||||||
|
// Traversal-Varianten, die `path.resolve` allein eventuell durchlässt
|
||||||
|
// (z.B. Backslash-Mischformen auf Cross-OS, Null-Bytes, leere Namen,
|
||||||
|
// explizite `..`-Segmente). Plus Zip-Bomb-Schutz per Entry-Größenlimit.
|
||||||
const absBackupDir = path.resolve(finalBackupDir);
|
const absBackupDir = path.resolve(finalBackupDir);
|
||||||
fs.mkdirSync(absBackupDir, { recursive: true });
|
fs.mkdirSync(absBackupDir, { recursive: true });
|
||||||
|
const MAX_ENTRY_SIZE = 500 * 1024 * 1024; // 500 MB pro Entry
|
||||||
|
let totalUncompressed = 0;
|
||||||
|
const MAX_TOTAL_UNCOMPRESSED = 5 * 1024 * 1024 * 1024; // 5 GB Gesamt
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
// Pfade mit absoluten Pfaden oder Traversal ablehnen
|
|
||||||
const entryName = entry.entryName;
|
const entryName = entry.entryName;
|
||||||
if (entryName.includes('\0') || path.isAbsolute(entryName)) {
|
// Reject: leer, Null-Byte, absoluter Pfad, Backslashes (Cross-OS-
|
||||||
|
// Confusion), expliziter `..`-Segment im Original-Namen,
|
||||||
|
// Home-Dir-Expansion `~/`.
|
||||||
|
if (
|
||||||
|
!entryName
|
||||||
|
|| entryName.includes('\0')
|
||||||
|
|| entryName.includes('\\')
|
||||||
|
|| entryName.startsWith('~')
|
||||||
|
|| path.isAbsolute(entryName)
|
||||||
|
|| entryName.split('/').some((seg) => seg === '..')
|
||||||
|
) {
|
||||||
return { success: false, error: `Ungültiger Eintrag im ZIP: ${entryName}` };
|
return { success: false, error: `Ungültiger Eintrag im ZIP: ${entryName}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetPath = path.resolve(absBackupDir, entryName);
|
const targetPath = path.resolve(absBackupDir, entryName);
|
||||||
// Zip-Slip-Check: aufgelöster Pfad muss im Backup-Verzeichnis liegen
|
// Zip-Slip-Check: aufgelöster Pfad muss strikt im Backup-Verzeichnis
|
||||||
if (!targetPath.startsWith(absBackupDir + path.sep) && targetPath !== absBackupDir) {
|
// liegen. path.relative gibt "../..." zurück wenn target außerhalb
|
||||||
|
// liegt – das ist robuster als startsWith + Separator-Concat.
|
||||||
|
const rel = path.relative(absBackupDir, targetPath);
|
||||||
|
if (rel === '' && !entry.isDirectory) {
|
||||||
|
return { success: false, error: `Datei-Eintrag zeigt auf das Backup-Wurzelverzeichnis` };
|
||||||
|
}
|
||||||
|
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `Sicherheitsverletzung im ZIP: Pfad "${entryName}" zeigt außerhalb des Backup-Verzeichnisses`,
|
error: `Sicherheitsverletzung im ZIP: Pfad "${entryName}" zeigt außerhalb des Backup-Verzeichnisses`,
|
||||||
@@ -1063,6 +1084,16 @@ export async function uploadBackupZip(zipBuffer: Buffer): Promise<BackupResult>
|
|||||||
if (entry.isDirectory) {
|
if (entry.isDirectory) {
|
||||||
fs.mkdirSync(targetPath, { recursive: true });
|
fs.mkdirSync(targetPath, { recursive: true });
|
||||||
} else {
|
} else {
|
||||||
|
// Zip-Bomb-Schutz: Entry-Größe begrenzen und Gesamt-Tracking
|
||||||
|
if (typeof entry.header?.size === 'number') {
|
||||||
|
if (entry.header.size > MAX_ENTRY_SIZE) {
|
||||||
|
return { success: false, error: `Eintrag "${entryName}" überschreitet das Größenlimit von 500 MB` };
|
||||||
|
}
|
||||||
|
totalUncompressed += entry.header.size;
|
||||||
|
if (totalUncompressed > MAX_TOTAL_UNCOMPRESSED) {
|
||||||
|
return { success: false, error: `Backup-ZIP überschreitet das entpackte Gesamtlimit von 5 GB (Zip-Bomb-Schutz)` };
|
||||||
|
}
|
||||||
|
}
|
||||||
// Zielverzeichnis sicherstellen
|
// Zielverzeichnis sicherstellen
|
||||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||||
// Datei schreiben
|
// Datei schreiben
|
||||||
|
|||||||
@@ -603,33 +603,35 @@ export async function getFolderCountsForAccount(stressfreiEmailId: number): Prom
|
|||||||
return { inbox, inboxUnread, sent, sentUnread, trash, trashUnread };
|
return { inbox, inboxUnread, sent, sentUnread, trash, trashUnread };
|
||||||
}
|
}
|
||||||
|
|
||||||
// E-Mail-Anzahl pro Ordner für einen Vertrag (zugeordnete E-Mails)
|
// E-Mail-Anzahl pro Ordner für einen Vertrag (zugeordnete E-Mails).
|
||||||
export async function getFolderCountsForContract(contractId: number): Promise<{
|
// Optional auf ein bestimmtes Postfach einschränken. Bug 2026-06-21:
|
||||||
|
// vorher zählten die Badges Mails aus ALLEN Postfächern, während die
|
||||||
|
// Liste (nach Fix) nur die des ausgewählten Postfachs zeigt – Badge
|
||||||
|
// und Liste liefen auseinander. Trash mit reingenommen, weil der
|
||||||
|
// Contract-Trash-Badge sonst wieder auf account-globalen Zähler
|
||||||
|
// zurückfallen müsste.
|
||||||
|
export async function getFolderCountsForContract(
|
||||||
|
contractId: number,
|
||||||
|
stressfreiEmailId?: number,
|
||||||
|
): Promise<{
|
||||||
inbox: number;
|
inbox: number;
|
||||||
inboxUnread: number;
|
inboxUnread: number;
|
||||||
sent: number;
|
sent: number;
|
||||||
sentUnread: number;
|
sentUnread: number;
|
||||||
|
trash: number;
|
||||||
|
trashUnread: number;
|
||||||
}> {
|
}> {
|
||||||
const [inbox, inboxUnread, sent, sentUnread] = await Promise.all([
|
const baseWhere: Prisma.CachedEmailWhereInput = { contractId };
|
||||||
// INBOX total
|
if (stressfreiEmailId) baseWhere.stressfreiEmailId = stressfreiEmailId;
|
||||||
prisma.cachedEmail.count({
|
const [inbox, inboxUnread, sent, sentUnread, trash, trashUnread] = await Promise.all([
|
||||||
where: { contractId, folder: EmailFolder.INBOX, isDeleted: false },
|
prisma.cachedEmail.count({ where: { ...baseWhere, folder: EmailFolder.INBOX, isDeleted: false } }),
|
||||||
}),
|
prisma.cachedEmail.count({ where: { ...baseWhere, folder: EmailFolder.INBOX, isDeleted: false, isRead: false } }),
|
||||||
// INBOX unread
|
prisma.cachedEmail.count({ where: { ...baseWhere, folder: EmailFolder.SENT, isDeleted: false } }),
|
||||||
prisma.cachedEmail.count({
|
prisma.cachedEmail.count({ where: { ...baseWhere, folder: EmailFolder.SENT, isDeleted: false, isRead: false } }),
|
||||||
where: { contractId, folder: EmailFolder.INBOX, isDeleted: false, isRead: false },
|
prisma.cachedEmail.count({ where: { ...baseWhere, isDeleted: true } }),
|
||||||
}),
|
prisma.cachedEmail.count({ where: { ...baseWhere, isDeleted: true, isRead: false } }),
|
||||||
// SENT total
|
|
||||||
prisma.cachedEmail.count({
|
|
||||||
where: { contractId, folder: EmailFolder.SENT, isDeleted: false },
|
|
||||||
}),
|
|
||||||
// SENT unread
|
|
||||||
prisma.cachedEmail.count({
|
|
||||||
where: { contractId, folder: EmailFolder.SENT, isDeleted: false, isRead: false },
|
|
||||||
}),
|
|
||||||
]);
|
]);
|
||||||
|
return { inbox, inboxUnread, sent, sentUnread, trash, trashUnread };
|
||||||
return { inbox, inboxUnread, sent, sentUnread };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alle StressfreiEmails eines Kunden mit Mailbox
|
// Alle StressfreiEmails eines Kunden mit Mailbox
|
||||||
@@ -904,14 +906,26 @@ export async function permanentDeleteEmail(id: number): Promise<TrashOperationRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Papierkorb-E-Mails für einen Kunden abrufen
|
// Papierkorb-E-Mails für einen Kunden abrufen
|
||||||
export async function getTrashEmails(customerId: number): Promise<CachedEmailWithRelations[]> {
|
// Optional: nach Postfach (stressfreiEmailId) und/oder Vertrag (contractId)
|
||||||
|
// einschränken. Vorher zeigte der Papierkorb immer ALLE gelöschten E-Mails
|
||||||
|
// des Kunden, unabhängig von welchem Postfach man gerade angemeldet ist –
|
||||||
|
// User-Bug 2026-06-21.
|
||||||
|
export async function getTrashEmails(
|
||||||
|
customerId: number,
|
||||||
|
options?: { stressfreiEmailId?: number; contractId?: number },
|
||||||
|
): Promise<CachedEmailWithRelations[]> {
|
||||||
|
const where: Prisma.CachedEmailWhereInput = {
|
||||||
|
isDeleted: true,
|
||||||
|
stressfreiEmail: { customerId },
|
||||||
|
};
|
||||||
|
if (options?.stressfreiEmailId) {
|
||||||
|
where.stressfreiEmailId = options.stressfreiEmailId;
|
||||||
|
}
|
||||||
|
if (options?.contractId) {
|
||||||
|
where.contractId = options.contractId;
|
||||||
|
}
|
||||||
return prisma.cachedEmail.findMany({
|
return prisma.cachedEmail.findMany({
|
||||||
where: {
|
where,
|
||||||
isDeleted: true,
|
|
||||||
stressfreiEmail: {
|
|
||||||
customerId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
include: {
|
include: {
|
||||||
stressfreiEmail: {
|
stressfreiEmail: {
|
||||||
select: {
|
select: {
|
||||||
@@ -931,16 +945,22 @@ export async function getTrashEmails(customerId: number): Promise<CachedEmailWit
|
|||||||
}) as Promise<CachedEmailWithRelations[]>;
|
}) as Promise<CachedEmailWithRelations[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Papierkorb-E-Mails zählen
|
// Papierkorb-E-Mails zählen (gleiche Filter wie getTrashEmails)
|
||||||
export async function getTrashCount(customerId: number): Promise<number> {
|
export async function getTrashCount(
|
||||||
return prisma.cachedEmail.count({
|
customerId: number,
|
||||||
where: {
|
options?: { stressfreiEmailId?: number; contractId?: number },
|
||||||
isDeleted: true,
|
): Promise<number> {
|
||||||
stressfreiEmail: {
|
const where: Prisma.CachedEmailWhereInput = {
|
||||||
customerId,
|
isDeleted: true,
|
||||||
},
|
stressfreiEmail: { customerId },
|
||||||
},
|
};
|
||||||
});
|
if (options?.stressfreiEmailId) {
|
||||||
|
where.stressfreiEmailId = options.stressfreiEmailId;
|
||||||
|
}
|
||||||
|
if (options?.contractId) {
|
||||||
|
where.contractId = options.contractId;
|
||||||
|
}
|
||||||
|
return prisma.cachedEmail.count({ where });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy: E-Mail löschen (jetzt deprecated, nutze moveEmailToTrash)
|
// Legacy: E-Mail löschen (jetzt deprecated, nutze moveEmailToTrash)
|
||||||
|
|||||||
@@ -5,8 +5,19 @@ import * as consentService from './consent.service.js';
|
|||||||
import * as appSettingService from './appSetting.service.js';
|
import * as appSettingService from './appSetting.service.js';
|
||||||
import PDFDocument from 'pdfkit';
|
import PDFDocument from 'pdfkit';
|
||||||
|
|
||||||
|
// Pentest 57.7 (MEDIUM, 2026-06-01): Public-Consent-Hashes hatten keine
|
||||||
|
// Ablauffrist. Ein versehentlich weitergegebener oder geleakter Link
|
||||||
|
// hätte Jahre später noch fremde Einwilligungen erteilen können
|
||||||
|
// (DSGVO-Pflicht zur Zweckbindung). 30 Tage ist der Default-Zeitraum,
|
||||||
|
// in dem ein Kunde realistisch auf den Versandlink klickt; danach muss
|
||||||
|
// ein Mitarbeiter den Link neu generieren (ensureConsentHash() erzeugt
|
||||||
|
// einen neuen Hash + neue Frist).
|
||||||
|
const CONSENT_HASH_TTL_DAYS = 30;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kunden-Lookup per consentHash
|
* Kunden-Lookup per consentHash. Liefert null wenn der Hash unbekannt
|
||||||
|
* oder abgelaufen ist – aus Sicht des Aufrufers identisch, damit der
|
||||||
|
* Public-Endpoint keine Unterscheidung "ungültig vs. abgelaufen" leakt.
|
||||||
*/
|
*/
|
||||||
export async function getCustomerByConsentHash(hash: string) {
|
export async function getCustomerByConsentHash(hash: string) {
|
||||||
const customer = await prisma.customer.findUnique({
|
const customer = await prisma.customer.findUnique({
|
||||||
@@ -18,28 +29,40 @@ export async function getCustomerByConsentHash(hash: string) {
|
|||||||
customerNumber: true,
|
customerNumber: true,
|
||||||
salutation: true,
|
salutation: true,
|
||||||
email: true,
|
email: true,
|
||||||
|
consentHashExpiresAt: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!customer) return null;
|
if (!customer) return null;
|
||||||
|
if (customer.consentHashExpiresAt && customer.consentHashExpiresAt.getTime() < Date.now()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const consents = await consentService.getCustomerConsents(customer.id);
|
const consents = await consentService.getCustomerConsents(customer.id);
|
||||||
|
|
||||||
return { customer, consents };
|
// consentHashExpiresAt nicht an den Client durchreichen
|
||||||
|
const { consentHashExpiresAt: _expires, ...customerWithoutExpiry } = customer;
|
||||||
|
void _expires;
|
||||||
|
return { customer: customerWithoutExpiry, consents };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Alle 4 Einwilligungen über den öffentlichen Link erteilen
|
* Alle 4 Einwilligungen über den öffentlichen Link erteilen.
|
||||||
|
* Wirft bei abgelaufenem oder unbekanntem Hash mit gleicher Meldung,
|
||||||
|
* damit kein Oracle "existiert vs. abgelaufen" entsteht.
|
||||||
*/
|
*/
|
||||||
export async function grantAllConsentsPublic(hash: string, ipAddress: string) {
|
export async function grantAllConsentsPublic(hash: string, ipAddress: string) {
|
||||||
const customer = await prisma.customer.findUnique({
|
const customer = await prisma.customer.findUnique({
|
||||||
where: { consentHash: hash },
|
where: { consentHash: hash },
|
||||||
select: { id: true, firstName: true, lastName: true },
|
select: { id: true, firstName: true, lastName: true, consentHashExpiresAt: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!customer) {
|
if (!customer) {
|
||||||
throw new Error('Ungültiger Link');
|
throw new Error('Ungültiger Link');
|
||||||
}
|
}
|
||||||
|
if (customer.consentHashExpiresAt && customer.consentHashExpiresAt.getTime() < Date.now()) {
|
||||||
|
throw new Error('Link ist abgelaufen. Bitte einen neuen Link anfordern.');
|
||||||
|
}
|
||||||
|
|
||||||
const results = [];
|
const results = [];
|
||||||
for (const type of Object.values(ConsentType)) {
|
for (const type of Object.values(ConsentType)) {
|
||||||
@@ -56,26 +79,33 @@ export async function grantAllConsentsPublic(hash: string, ipAddress: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* consentHash generieren falls nicht vorhanden
|
* consentHash generieren oder erneuern. Liefert einen bestehenden Hash
|
||||||
|
* nur zurück, wenn dessen TTL noch nicht abgelaufen ist – sonst wird ein
|
||||||
|
* neuer Hash + neue Frist gesetzt (Pentest 57.7).
|
||||||
*/
|
*/
|
||||||
export async function ensureConsentHash(customerId: number): Promise<string> {
|
export async function ensureConsentHash(customerId: number): Promise<string> {
|
||||||
const customer = await prisma.customer.findUnique({
|
const customer = await prisma.customer.findUnique({
|
||||||
where: { id: customerId },
|
where: { id: customerId },
|
||||||
select: { consentHash: true },
|
select: { consentHash: true, consentHashExpiresAt: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!customer) {
|
if (!customer) {
|
||||||
throw new Error('Kunde nicht gefunden');
|
throw new Error('Kunde nicht gefunden');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (customer.consentHash) {
|
const stillValid = customer.consentHash
|
||||||
return customer.consentHash;
|
&& customer.consentHashExpiresAt
|
||||||
|
&& customer.consentHashExpiresAt.getTime() > Date.now();
|
||||||
|
|
||||||
|
if (stillValid) {
|
||||||
|
return customer.consentHash!;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hash = crypto.randomUUID();
|
const hash = crypto.randomUUID();
|
||||||
|
const expiresAt = new Date(Date.now() + CONSENT_HASH_TTL_DAYS * 24 * 60 * 60 * 1000);
|
||||||
await prisma.customer.update({
|
await prisma.customer.update({
|
||||||
where: { id: customerId },
|
where: { id: customerId },
|
||||||
data: { consentHash: hash },
|
data: { consentHash: hash, consentHashExpiresAt: expiresAt },
|
||||||
});
|
});
|
||||||
|
|
||||||
return hash;
|
return hash;
|
||||||
@@ -185,3 +215,105 @@ export async function generateConsentPdf(customerId: number): Promise<Buffer> {
|
|||||||
doc.end();
|
doc.end();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Datenschutzerklärung als unterschreibbare PDF (Papierform) generieren.
|
||||||
|
* Zusätzlich zum normalen Text wird unten eine Einwilligungs-Klausel +
|
||||||
|
* ein Unterschriften-Block angefügt (Ort/Datum + Unterschrift +
|
||||||
|
* Name in Druckbuchstaben). Das fertige PDF wird ausgedruckt, vom
|
||||||
|
* Kunden unterschrieben und im Tab "Einwilligungen / Datenschutz"
|
||||||
|
* wieder hochgeladen.
|
||||||
|
*/
|
||||||
|
export async function generateSignablePrivacyPdf(customerId: number): Promise<Buffer> {
|
||||||
|
const html = await getPrivacyPolicyHtml(customerId);
|
||||||
|
const text = htmlToText(html);
|
||||||
|
const customer = await prisma.customer.findUnique({
|
||||||
|
where: { id: customerId },
|
||||||
|
select: {
|
||||||
|
firstName: true, lastName: true, customerNumber: true, companyName: true,
|
||||||
|
salutation: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const printedName = customer
|
||||||
|
? (customer.companyName?.trim()
|
||||||
|
? customer.companyName.trim()
|
||||||
|
: `${customer.firstName ?? ''} ${customer.lastName ?? ''}`.trim())
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const doc = new PDFDocument({ size: 'A4', margin: 50 });
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
|
||||||
|
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||||||
|
doc.on('error', reject);
|
||||||
|
|
||||||
|
// Titel
|
||||||
|
doc.fontSize(18).font('Helvetica-Bold').text('Datenschutzerklärung', { align: 'center' });
|
||||||
|
doc.moveDown(0.5);
|
||||||
|
|
||||||
|
// Kundenkopf
|
||||||
|
if (printedName) {
|
||||||
|
doc.fontSize(11).font('Helvetica-Bold').text(printedName, { align: 'center' });
|
||||||
|
}
|
||||||
|
if (customer?.customerNumber) {
|
||||||
|
doc.fontSize(10).font('Helvetica').text(`Kundennummer: ${customer.customerNumber}`, { align: 'center' });
|
||||||
|
}
|
||||||
|
doc.moveDown(0.5);
|
||||||
|
doc.fontSize(10).font('Helvetica')
|
||||||
|
.text(`Erstellt am: ${new Date().toLocaleDateString('de-DE')}`, { align: 'right' });
|
||||||
|
doc.moveDown(1);
|
||||||
|
|
||||||
|
// Inhalt
|
||||||
|
doc.fontSize(11).font('Helvetica').text(text, { align: 'left', lineGap: 4 });
|
||||||
|
|
||||||
|
// Genug Platz vor dem Unterschriftenblock – wenn nicht mehr genug
|
||||||
|
// Platz auf der Seite, neue Seite anfangen.
|
||||||
|
if (doc.y > doc.page.height - doc.page.margins.bottom - 220) {
|
||||||
|
doc.addPage();
|
||||||
|
} else {
|
||||||
|
doc.moveDown(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Einwilligungsklausel
|
||||||
|
doc.fontSize(11).font('Helvetica-Bold').text('Einwilligung', { underline: false });
|
||||||
|
doc.moveDown(0.3);
|
||||||
|
doc.fontSize(10).font('Helvetica').text(
|
||||||
|
'Mit meiner Unterschrift bestätige ich, dass ich die vorstehende ' +
|
||||||
|
'Datenschutzerklärung gelesen und verstanden habe und mit der ' +
|
||||||
|
'Verarbeitung meiner personenbezogenen Daten zum Zweck der ' +
|
||||||
|
'Vertragserfüllung einverstanden bin. Diese Einwilligung kann ' +
|
||||||
|
'jederzeit für die Zukunft widerrufen werden.',
|
||||||
|
{ align: 'left', lineGap: 3 },
|
||||||
|
);
|
||||||
|
doc.moveDown(1.5);
|
||||||
|
|
||||||
|
// Unterschriftenblock: links Ort/Datum, rechts Unterschrift
|
||||||
|
const startY = doc.y;
|
||||||
|
const leftX = doc.page.margins.left;
|
||||||
|
const rightX = doc.page.width / 2 + 10;
|
||||||
|
const lineWidth = doc.page.width / 2 - doc.page.margins.left - 10;
|
||||||
|
|
||||||
|
// Linien
|
||||||
|
const lineY = startY + 35;
|
||||||
|
doc.moveTo(leftX, lineY).lineTo(leftX + lineWidth, lineY).stroke();
|
||||||
|
doc.moveTo(rightX, lineY).lineTo(rightX + lineWidth, lineY).stroke();
|
||||||
|
|
||||||
|
// Labels unter den Linien
|
||||||
|
doc.fontSize(9).font('Helvetica');
|
||||||
|
doc.text('Ort, Datum', leftX, lineY + 4, { width: lineWidth, align: 'left' });
|
||||||
|
doc.text('Unterschrift', rightX, lineY + 4, { width: lineWidth, align: 'left' });
|
||||||
|
|
||||||
|
// Zweite Zeile: Name in Druckbuchstaben (vorausgefüllt mit Kunde)
|
||||||
|
doc.moveDown(3);
|
||||||
|
const nameY = doc.y;
|
||||||
|
doc.fontSize(11).font('Helvetica');
|
||||||
|
if (printedName) {
|
||||||
|
doc.text(printedName, rightX, nameY, { width: lineWidth, align: 'left' });
|
||||||
|
}
|
||||||
|
doc.moveTo(rightX, nameY + 16).lineTo(rightX + lineWidth, nameY + 16).stroke();
|
||||||
|
doc.fontSize(9).text('Name in Druckbuchstaben', rightX, nameY + 20, { width: lineWidth, align: 'left' });
|
||||||
|
|
||||||
|
doc.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ConsentType, ConsentStatus } from '@prisma/client';
|
|||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { assertValidDocumentPath } from '../utils/sanitize.js';
|
||||||
|
|
||||||
// Whitelist legitimer Werte für CustomerConsent.source. Schema-Kommentar:
|
// Whitelist legitimer Werte für CustomerConsent.source. Schema-Kommentar:
|
||||||
// "portal", "telefon", "papier", "email". Public-Link-Flow nutzt
|
// "portal", "telefon", "papier", "email". Public-Link-Flow nutzt
|
||||||
@@ -80,6 +81,12 @@ export async function updateConsent(
|
|||||||
throw new Error('Kunde nicht gefunden');
|
throw new Error('Kunde nicht gefunden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pentest 26.7: documentPath darf nur ein gültiger /uploads/<safe>-Pfad
|
||||||
|
// sein. Aktuell hat KEIN Endpoint diesen Wert aus User-Input gemappt
|
||||||
|
// (Portal: nicht aus Body, Admin-Auth-Upload: server-generated). Diese
|
||||||
|
// Service-Side-Validation ist Defense-in-Depth gegen zukünftige Caller.
|
||||||
|
assertValidDocumentPath(data.documentPath, 'documentPath');
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const updateData = {
|
const updateData = {
|
||||||
status: data.status,
|
status: data.status,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { ContractType, ContractStatus } from '@prisma/client';
|
|||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import { generateContractNumber, paginate, buildPaginationResponse } from '../utils/helpers.js';
|
import { generateContractNumber, paginate, buildPaginationResponse } from '../utils/helpers.js';
|
||||||
import { encrypt, decrypt } from '../utils/encryption.js';
|
import { encrypt, decrypt } from '../utils/encryption.js';
|
||||||
import { sanitizeCustomerStrict } from '../utils/sanitize.js';
|
import { sanitizeCustomerStrict, sanitizePhoneField } from '../utils/sanitize.js';
|
||||||
|
|
||||||
export interface ContractFilters {
|
export interface ContractFilters {
|
||||||
customerId?: number;
|
customerId?: number;
|
||||||
@@ -177,6 +177,13 @@ export async function getContractById(id: number, decryptPassword = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Virtuelles Bool-Flag, damit das Frontend "PW gesetzt?" weiß, ohne dass
|
||||||
|
// der verschlüsselte Blob in die Response leakt (sanitizeContract strippt
|
||||||
|
// portalPasswordEncrypted bewusst). Pentest Runde 15 – sensitive Feld
|
||||||
|
// raus aus /contracts/:id; UI nutzt jetzt `hasPortalPassword`.
|
||||||
|
(contract as Record<string, unknown>).hasPortalPassword =
|
||||||
|
!!contract.portalPasswordEncrypted;
|
||||||
|
|
||||||
return contract;
|
return contract;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +203,9 @@ interface ContractCreateData {
|
|||||||
providerName?: string;
|
providerName?: string;
|
||||||
tariffName?: string;
|
tariffName?: string;
|
||||||
customerNumberAtProvider?: string;
|
customerNumberAtProvider?: string;
|
||||||
|
orderNumberAtSalesPlatform?: string;
|
||||||
|
customerNumberAtSalesPlatform?: string;
|
||||||
|
contractNumberAtSalesPlatform?: string;
|
||||||
priceFirst12Months?: string;
|
priceFirst12Months?: string;
|
||||||
priceFrom13Months?: string;
|
priceFrom13Months?: string;
|
||||||
priceAfter24Months?: string;
|
priceAfter24Months?: string;
|
||||||
@@ -218,7 +228,8 @@ interface ContractCreateData {
|
|||||||
annualConsumption?: number;
|
annualConsumption?: number;
|
||||||
basePrice?: number;
|
basePrice?: number;
|
||||||
unitPrice?: number;
|
unitPrice?: number;
|
||||||
bonus?: number;
|
instantBonus?: number;
|
||||||
|
newCustomerBonus?: number;
|
||||||
previousProviderName?: string;
|
previousProviderName?: string;
|
||||||
previousCustomerNumber?: string;
|
previousCustomerNumber?: string;
|
||||||
};
|
};
|
||||||
@@ -242,6 +253,7 @@ interface ContractCreateData {
|
|||||||
phoneNumbers?: {
|
phoneNumbers?: {
|
||||||
id?: number;
|
id?: number;
|
||||||
phoneNumber: string;
|
phoneNumber: string;
|
||||||
|
areaCode?: string;
|
||||||
isMain?: boolean;
|
isMain?: boolean;
|
||||||
sipUsername?: string;
|
sipUsername?: string;
|
||||||
sipPassword?: string;
|
sipPassword?: string;
|
||||||
@@ -267,6 +279,8 @@ interface ContractCreateData {
|
|||||||
puk?: string;
|
puk?: string;
|
||||||
isMultisim?: boolean;
|
isMultisim?: boolean;
|
||||||
isMain?: boolean;
|
isMain?: boolean;
|
||||||
|
isEsim?: boolean;
|
||||||
|
cardUser?: string;
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
tvDetails?: {
|
tvDetails?: {
|
||||||
@@ -335,7 +349,8 @@ export async function createContract(data: ContractCreateData) {
|
|||||||
phoneNumbers: internetDetails.phoneNumbers && internetDetails.phoneNumbers.length > 0
|
phoneNumbers: internetDetails.phoneNumbers && internetDetails.phoneNumbers.length > 0
|
||||||
? {
|
? {
|
||||||
create: internetDetails.phoneNumbers.map((pn) => ({
|
create: internetDetails.phoneNumbers.map((pn) => ({
|
||||||
phoneNumber: pn.phoneNumber,
|
phoneNumber: sanitizePhoneField(pn.phoneNumber, 'Rufnummer') ?? '',
|
||||||
|
areaCode: sanitizePhoneField(pn.areaCode, 'Vorwahl'),
|
||||||
isMain: pn.isMain ?? false,
|
isMain: pn.isMain ?? false,
|
||||||
sipUsername: pn.sipUsername,
|
sipUsername: pn.sipUsername,
|
||||||
sipPasswordEncrypted: pn.sipPassword
|
sipPasswordEncrypted: pn.sipPassword
|
||||||
@@ -370,6 +385,8 @@ export async function createContract(data: ContractCreateData) {
|
|||||||
puk: sc.puk ? encrypt(sc.puk) : undefined,
|
puk: sc.puk ? encrypt(sc.puk) : undefined,
|
||||||
isMultisim: sc.isMultisim ?? false,
|
isMultisim: sc.isMultisim ?? false,
|
||||||
isMain: sc.isMain ?? false,
|
isMain: sc.isMain ?? false,
|
||||||
|
isEsim: sc.isEsim ?? false,
|
||||||
|
cardUser: sc.cardUser,
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -531,7 +548,8 @@ export async function updateContract(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
internetContractDetailsId: existing.id,
|
internetContractDetailsId: existing.id,
|
||||||
phoneNumber: pn.phoneNumber,
|
phoneNumber: sanitizePhoneField(pn.phoneNumber, 'Rufnummer') ?? '',
|
||||||
|
areaCode: sanitizePhoneField(pn.areaCode, 'Vorwahl'),
|
||||||
isMain: pn.isMain ?? false,
|
isMain: pn.isMain ?? false,
|
||||||
sipUsername: pn.sipUsername,
|
sipUsername: pn.sipUsername,
|
||||||
// Preserve existing sipPassword if no new value provided
|
// Preserve existing sipPassword if no new value provided
|
||||||
@@ -554,7 +572,8 @@ export async function updateContract(
|
|||||||
phoneNumbers: phoneNumbers
|
phoneNumbers: phoneNumbers
|
||||||
? {
|
? {
|
||||||
create: phoneNumbers.map((pn) => ({
|
create: phoneNumbers.map((pn) => ({
|
||||||
phoneNumber: pn.phoneNumber,
|
phoneNumber: sanitizePhoneField(pn.phoneNumber, 'Rufnummer') ?? '',
|
||||||
|
areaCode: sanitizePhoneField(pn.areaCode, 'Vorwahl'),
|
||||||
isMain: pn.isMain ?? false,
|
isMain: pn.isMain ?? false,
|
||||||
sipUsername: pn.sipUsername,
|
sipUsername: pn.sipUsername,
|
||||||
sipPasswordEncrypted: pn.sipPassword
|
sipPasswordEncrypted: pn.sipPassword
|
||||||
@@ -606,6 +625,8 @@ export async function updateContract(
|
|||||||
puk: sc.puk ? encrypt(sc.puk) : (existingSc?.puk ?? undefined),
|
puk: sc.puk ? encrypt(sc.puk) : (existingSc?.puk ?? undefined),
|
||||||
isMultisim: sc.isMultisim ?? false,
|
isMultisim: sc.isMultisim ?? false,
|
||||||
isMain: sc.isMain ?? false,
|
isMain: sc.isMain ?? false,
|
||||||
|
isEsim: sc.isEsim ?? false,
|
||||||
|
cardUser: sc.cardUser,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -624,6 +645,8 @@ export async function updateContract(
|
|||||||
puk: sc.puk ? encrypt(sc.puk) : undefined,
|
puk: sc.puk ? encrypt(sc.puk) : undefined,
|
||||||
isMultisim: sc.isMultisim ?? false,
|
isMultisim: sc.isMultisim ?? false,
|
||||||
isMain: sc.isMain ?? false,
|
isMain: sc.isMain ?? false,
|
||||||
|
isEsim: sc.isEsim ?? false,
|
||||||
|
cardUser: sc.cardUser,
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -710,7 +733,8 @@ export async function createFollowUpContract(previousContractId: number) {
|
|||||||
previousContract.energyDetails.annualConsumption ?? undefined,
|
previousContract.energyDetails.annualConsumption ?? undefined,
|
||||||
basePrice: previousContract.energyDetails.basePrice ?? undefined,
|
basePrice: previousContract.energyDetails.basePrice ?? undefined,
|
||||||
unitPrice: previousContract.energyDetails.unitPrice ?? undefined,
|
unitPrice: previousContract.energyDetails.unitPrice ?? undefined,
|
||||||
bonus: previousContract.energyDetails.bonus ?? undefined,
|
instantBonus: previousContract.energyDetails.instantBonus ?? undefined,
|
||||||
|
newCustomerBonus: previousContract.energyDetails.newCustomerBonus ?? undefined,
|
||||||
previousProviderName: previousContract.providerName ?? undefined,
|
previousProviderName: previousContract.providerName ?? undefined,
|
||||||
previousCustomerNumber:
|
previousCustomerNumber:
|
||||||
previousContract.customerNumberAtProvider ?? undefined,
|
previousContract.customerNumberAtProvider ?? undefined,
|
||||||
@@ -749,6 +773,7 @@ export async function createFollowUpContract(previousContractId: number) {
|
|||||||
simCardNumber: sc.simCardNumber ?? undefined,
|
simCardNumber: sc.simCardNumber ?? undefined,
|
||||||
isMultisim: sc.isMultisim,
|
isMultisim: sc.isMultisim,
|
||||||
isMain: sc.isMain,
|
isMain: sc.isMain,
|
||||||
|
isEsim: sc.isEsim,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -874,6 +899,10 @@ export async function createRenewalContract(previousContractId: number) {
|
|||||||
providerName: previousContract.providerName,
|
providerName: previousContract.providerName,
|
||||||
tariffName: previousContract.tariffName,
|
tariffName: previousContract.tariffName,
|
||||||
customerNumberAtProvider: previousContract.customerNumberAtProvider,
|
customerNumberAtProvider: previousContract.customerNumberAtProvider,
|
||||||
|
contractNumberAtProvider: previousContract.contractNumberAtProvider,
|
||||||
|
orderNumberAtSalesPlatform: previousContract.orderNumberAtSalesPlatform,
|
||||||
|
customerNumberAtSalesPlatform: previousContract.customerNumberAtSalesPlatform,
|
||||||
|
contractNumberAtSalesPlatform: previousContract.contractNumberAtSalesPlatform,
|
||||||
portalUsername: previousContract.portalUsername,
|
portalUsername: previousContract.portalUsername,
|
||||||
portalPasswordEncrypted: previousContract.portalPasswordEncrypted,
|
portalPasswordEncrypted: previousContract.portalPasswordEncrypted,
|
||||||
commission: previousContract.commission,
|
commission: previousContract.commission,
|
||||||
@@ -898,7 +927,8 @@ export async function createRenewalContract(previousContractId: number) {
|
|||||||
basePrice: ed.basePrice,
|
basePrice: ed.basePrice,
|
||||||
unitPrice: ed.unitPrice,
|
unitPrice: ed.unitPrice,
|
||||||
unitPriceNt: ed.unitPriceNt,
|
unitPriceNt: ed.unitPriceNt,
|
||||||
bonus: ed.bonus,
|
instantBonus: ed.instantBonus,
|
||||||
|
newCustomerBonus: ed.newCustomerBonus,
|
||||||
previousProviderName: ed.previousProviderName,
|
previousProviderName: ed.previousProviderName,
|
||||||
previousCustomerNumber: ed.previousCustomerNumber,
|
previousCustomerNumber: ed.previousCustomerNumber,
|
||||||
},
|
},
|
||||||
@@ -972,6 +1002,7 @@ export async function createRenewalContract(previousContractId: number) {
|
|||||||
simCardNumber: sc.simCardNumber,
|
simCardNumber: sc.simCardNumber,
|
||||||
isMultisim: sc.isMultisim,
|
isMultisim: sc.isMultisim,
|
||||||
isMain: sc.isMain,
|
isMain: sc.isMain,
|
||||||
|
isEsim: sc.isEsim,
|
||||||
pin: sc.pin,
|
pin: sc.pin,
|
||||||
puk: sc.puk,
|
puk: sc.puk,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -494,8 +494,11 @@ export async function getCockpitData(opts?: { customerIds?: number[] }): Promise
|
|||||||
summary.byCategory.missingData++;
|
summary.byCategory.missingData++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7b. KEIN AUSWEIS (nur für Telekommunikationsprodukte relevant)
|
// 7b. KEIN AUSWEIS – nur Mobilfunk. Bei Festnetz/Internet (DSL, FIBER,
|
||||||
const requiresIdentityDocument = ['DSL', 'FIBER', 'CABLE', 'MOBILE'].includes(contract.type);
|
// CABLE) verlangt der Anbieter beim Auftrag keinen Ausweis, die
|
||||||
|
// Warnung ist da nur Rauschen. Mobile bleibt drin, weil dort echte
|
||||||
|
// Identitätsfeststellung Pflicht ist.
|
||||||
|
const requiresIdentityDocument = contract.type === 'MOBILE';
|
||||||
if (requiresIdentityDocument && !contract.identityDocumentId) {
|
if (requiresIdentityDocument && !contract.identityDocumentId) {
|
||||||
issues.push({
|
issues.push({
|
||||||
type: 'missing_identity_document',
|
type: 'missing_identity_document',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
|
import { stripHtml } from '../utils/sanitize.js';
|
||||||
|
|
||||||
export interface CreateHistoryEntryData {
|
export interface CreateHistoryEntryData {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -7,23 +8,36 @@ export interface CreateHistoryEntryData {
|
|||||||
createdBy: string;
|
createdBy: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read-Time-Defensive: title + description durch stripHtml schicken, damit
|
||||||
|
// Alt-Einträge (vor Pentest 43.6) mit rohen HTML-Payloads nicht roh
|
||||||
|
// rausgehen. Schützt zusätzlich gegen einen umgangenen Write-Filter.
|
||||||
|
function sanitizeEntry<T extends { title: string; description: string | null }>(entry: T): T {
|
||||||
|
return {
|
||||||
|
...entry,
|
||||||
|
title: stripHtml(entry.title) as string,
|
||||||
|
description: entry.description != null ? (stripHtml(entry.description) as string) : entry.description,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Alle Historie-Einträge für einen Vertrag abrufen
|
* Alle Historie-Einträge für einen Vertrag abrufen
|
||||||
*/
|
*/
|
||||||
export async function getHistoryEntries(contractId: number) {
|
export async function getHistoryEntries(contractId: number) {
|
||||||
return prisma.contractHistoryEntry.findMany({
|
const entries = await prisma.contractHistoryEntry.findMany({
|
||||||
where: { contractId },
|
where: { contractId },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
});
|
});
|
||||||
|
return entries.map(sanitizeEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Einzelnen Historie-Eintrag abrufen
|
* Einzelnen Historie-Eintrag abrufen
|
||||||
*/
|
*/
|
||||||
export async function getHistoryEntry(contractId: number, entryId: number) {
|
export async function getHistoryEntry(contractId: number, entryId: number) {
|
||||||
return prisma.contractHistoryEntry.findFirst({
|
const entry = await prisma.contractHistoryEntry.findFirst({
|
||||||
where: { id: entryId, contractId },
|
where: { id: entryId, contractId },
|
||||||
});
|
});
|
||||||
|
return entry ? sanitizeEntry(entry) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,11 +53,14 @@ export async function createHistoryEntry(contractId: number, data: CreateHistory
|
|||||||
throw new Error('Vertrag nicht gefunden');
|
throw new Error('Vertrag nicht gefunden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pentest 2026-05-30 (MEDIUM, 43.6): Admin konnte HTML-Tags in title +
|
||||||
|
// description schreiben, Portal-User las sie roh zurück. stripHtml räumt
|
||||||
|
// Tags + gefährliche URI-Schemata vor dem Persistieren weg.
|
||||||
return prisma.contractHistoryEntry.create({
|
return prisma.contractHistoryEntry.create({
|
||||||
data: {
|
data: {
|
||||||
contractId,
|
contractId,
|
||||||
title: data.title,
|
title: stripHtml(data.title) as string,
|
||||||
description: data.description,
|
description: data.description != null ? (stripHtml(data.description) as string) : data.description,
|
||||||
isAutomatic: data.isAutomatic ?? false,
|
isAutomatic: data.isAutomatic ?? false,
|
||||||
createdBy: data.createdBy,
|
createdBy: data.createdBy,
|
||||||
},
|
},
|
||||||
@@ -73,8 +90,8 @@ export async function updateHistoryEntry(
|
|||||||
return prisma.contractHistoryEntry.update({
|
return prisma.contractHistoryEntry.update({
|
||||||
where: { id: entryId },
|
where: { id: entryId },
|
||||||
data: {
|
data: {
|
||||||
title: data.title,
|
title: data.title != null ? (stripHtml(data.title) as string) : undefined,
|
||||||
description: data.description,
|
description: data.description != null ? (stripHtml(data.description) as string) : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import cron from 'node-cron';
|
import cron from 'node-cron';
|
||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import { createAuditLog, logChange } from './audit.service.js';
|
import { createAuditLog, logChange } from './audit.service.js';
|
||||||
|
import { ApiError } from '../utils/apiError.js';
|
||||||
|
|
||||||
async function runExpireCheck(): Promise<void> {
|
async function runExpireCheck(): Promise<void> {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
@@ -84,6 +85,61 @@ export function startContractStatusScheduler(): void {
|
|||||||
|
|
||||||
export { runExpireCheck };
|
export { runExpireCheck };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pentest 55.4 (LOW, 2026-06-01): 5 parallele Lieferbestätigung-Requests
|
||||||
|
* erzeugten 5 ContractDocuments. Application-Lock per (contractId,
|
||||||
|
* documentType) verhindert das in der Praxis (single-instance) und bietet
|
||||||
|
* für Cluster wenigstens eine deutliche Verzögerung gegen Spam-Sprays.
|
||||||
|
*
|
||||||
|
* Plus DB-Check „kürzlich angelegt": rejected, falls innerhalb der
|
||||||
|
* letzten 10 s schon ein Eintrag mit gleichem Typ existiert. Schließt
|
||||||
|
* den größten Teil des Race-Windows und unterscheidet Spam-Attacks von
|
||||||
|
* legitimen Sekunden-später-Updates.
|
||||||
|
*/
|
||||||
|
const docCreateLocks = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
|
export async function assertNoRecentDuplicateDocument(
|
||||||
|
contractId: number,
|
||||||
|
documentType: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const recent = await prisma.contractDocument.findFirst({
|
||||||
|
where: {
|
||||||
|
contractId,
|
||||||
|
documentType,
|
||||||
|
createdAt: { gte: new Date(Date.now() - 10_000) },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (recent) {
|
||||||
|
// Pentest 64.1: ApiError(400) statt generischem Error – Caller
|
||||||
|
// mappt das auf 400 Bad Request statt pauschal 500.
|
||||||
|
throw new ApiError(400, 'Ein Dokument dieses Typs wurde vor wenigen Sekunden bereits angelegt – bitte kurz warten und Seite neu laden.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withContractDocumentLock<T>(
|
||||||
|
contractId: number,
|
||||||
|
documentType: string,
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const key = `${contractId}|${documentType.trim().toLowerCase()}`;
|
||||||
|
const previous = docCreateLocks.get(key);
|
||||||
|
let release: () => void = () => {};
|
||||||
|
const slot = new Promise<void>((resolve) => { release = resolve; });
|
||||||
|
docCreateLocks.set(key, (previous ?? Promise.resolve()).then(() => slot));
|
||||||
|
if (previous) await previous;
|
||||||
|
try {
|
||||||
|
await assertNoRecentDuplicateDocument(contractId, documentType);
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
// Map-Aufräumen: wenn niemand mehr in der Kette wartet
|
||||||
|
if (docCreateLocks.get(key) === (previous ?? Promise.resolve()).then(() => slot)) {
|
||||||
|
docCreateLocks.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wird nach einem ContractDocument-Upload aufgerufen. Wenn der Typ eine
|
* Wird nach einem ContractDocument-Upload aufgerufen. Wenn der Typ eine
|
||||||
* Lieferbestätigung ist:
|
* Lieferbestätigung ist:
|
||||||
|
|||||||
@@ -83,9 +83,28 @@ export async function getCustomerById(id: number) {
|
|||||||
meters: {
|
meters: {
|
||||||
orderBy: { isActive: 'desc' },
|
orderBy: { isActive: 'desc' },
|
||||||
include: {
|
include: {
|
||||||
|
address: true,
|
||||||
readings: {
|
readings: {
|
||||||
orderBy: { readingDate: 'desc' },
|
orderBy: { readingDate: 'desc' },
|
||||||
},
|
},
|
||||||
|
// Verträge, die diesen Zähler aktuell als Hauptzähler nutzen
|
||||||
|
// (energyDetails.meterId === meter.id)
|
||||||
|
energyDetails: {
|
||||||
|
include: {
|
||||||
|
contract: { select: { id: true, contractNumber: true, status: true, type: true, providerName: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Verträge, in denen der Zähler in der ContractMeter-Kette steht
|
||||||
|
// (Vorgänger oder Nachfolger über Zählerwechsel)
|
||||||
|
contractMeters: {
|
||||||
|
include: {
|
||||||
|
energyContractDetails: {
|
||||||
|
include: {
|
||||||
|
contract: { select: { id: true, contractNumber: true, status: true, type: true, providerName: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
stressfreiEmails: { orderBy: { isActive: 'desc' } },
|
stressfreiEmails: { orderBy: { isActive: 'desc' } },
|
||||||
@@ -410,6 +429,7 @@ export async function getCustomerMeters(
|
|||||||
return prisma.meter.findMany({
|
return prisma.meter.findMany({
|
||||||
where,
|
where,
|
||||||
include: {
|
include: {
|
||||||
|
address: true,
|
||||||
readings: {
|
readings: {
|
||||||
orderBy: { readingDate: 'desc' },
|
orderBy: { readingDate: 'desc' },
|
||||||
take: 5,
|
take: 5,
|
||||||
@@ -419,21 +439,224 @@ export async function getCustomerMeters(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Schreibt den Endstand des Vorgänger-Zählers beim Zählerwechsel als
|
||||||
|
// MeterReading. Wird beim Folgezähler-Anlegen aufgerufen (sowohl aus der
|
||||||
|
// Kundenakte als auch aus der Vertragsansicht). Idempotent: existiert am
|
||||||
|
// Wechseltag schon ein Reading, wird nichts angelegt. Validierung
|
||||||
|
// monoton-steigend wird durchgereicht – wirft bei Konflikt.
|
||||||
|
export async function recordPredecessorFinalReading(
|
||||||
|
predecessorMeterId: number,
|
||||||
|
switchAt: Date,
|
||||||
|
value: number,
|
||||||
|
) {
|
||||||
|
const meter = await prisma.meter.findUnique({
|
||||||
|
where: { id: predecessorMeterId },
|
||||||
|
select: { type: true },
|
||||||
|
});
|
||||||
|
if (!meter) return;
|
||||||
|
|
||||||
|
const dayStart = new Date(switchAt);
|
||||||
|
dayStart.setHours(0, 0, 0, 0);
|
||||||
|
const dayEnd = new Date(dayStart);
|
||||||
|
dayEnd.setDate(dayEnd.getDate() + 1);
|
||||||
|
|
||||||
|
const existingSameDay = await prisma.meterReading.findFirst({
|
||||||
|
where: { meterId: predecessorMeterId, readingDate: { gte: dayStart, lt: dayEnd } },
|
||||||
|
});
|
||||||
|
if (existingSameDay) return;
|
||||||
|
|
||||||
|
await validateReadingValue(predecessorMeterId, switchAt, value, undefined, 'HT');
|
||||||
|
|
||||||
|
await prisma.meterReading.create({
|
||||||
|
data: {
|
||||||
|
meterId: predecessorMeterId,
|
||||||
|
readingDate: switchAt,
|
||||||
|
value,
|
||||||
|
unit: meter.type === 'GAS' ? 'm³' : 'kWh',
|
||||||
|
notes: 'Endstand bei Zählerwechsel (automatisch beim Folgezähler-Anlegen erfasst)',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lieferadresse muss zum Kunden gehören und vom Typ DELIVERY_RESIDENCE sein.
|
||||||
|
// Wirft eine sprechende Fehlermeldung, die der Controller dem User durchreicht.
|
||||||
|
async function assertDeliveryAddressBelongsToCustomer(addressId: number, customerId: number) {
|
||||||
|
const addr = await prisma.address.findUnique({ where: { id: addressId } });
|
||||||
|
if (!addr || addr.customerId !== customerId) {
|
||||||
|
throw new Error('Ungültige Lieferadresse');
|
||||||
|
}
|
||||||
|
if (addr.type !== 'DELIVERY_RESIDENCE') {
|
||||||
|
throw new Error('Nur Lieferadressen können einem Zähler zugeordnet werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function createMeter(
|
export async function createMeter(
|
||||||
customerId: number,
|
customerId: number,
|
||||||
data: {
|
data: {
|
||||||
meterNumber: string;
|
meterNumber: string;
|
||||||
type: 'ELECTRICITY' | 'GAS';
|
type: 'ELECTRICITY' | 'GAS';
|
||||||
|
tariffModel?: 'SINGLE' | 'DUAL';
|
||||||
location?: string;
|
location?: string;
|
||||||
|
addressId?: number | null;
|
||||||
|
// Optional: dieser Zähler ersetzt einen bestehenden (Folgezähler).
|
||||||
|
// Beim Create werden alle Verträge, die den Vorgänger als aktuellen
|
||||||
|
// Zähler nutzen, automatisch auf den neuen Zähler umgestellt
|
||||||
|
// (ContractMeter-Eintrag analog zu Vertragsansicht).
|
||||||
|
successorOf?: {
|
||||||
|
predecessorMeterId: number;
|
||||||
|
installedAt?: string;
|
||||||
|
finalReadingPrevious?: number;
|
||||||
|
// Default true im UI: alter Zähler wird nach dem Wechsel auf
|
||||||
|
// isActive=false gesetzt. Kann ausgeschaltet werden, wenn der alte
|
||||||
|
// Zähler aus irgendeinem Grund noch aktiv bleiben soll.
|
||||||
|
deactivatePredecessor?: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
return prisma.meter.create({
|
if (data.addressId == null) {
|
||||||
|
throw new Error('Lieferadresse ist erforderlich');
|
||||||
|
}
|
||||||
|
await assertDeliveryAddressBelongsToCustomer(data.addressId, customerId);
|
||||||
|
|
||||||
|
// Vorgänger validieren (wenn Folgezähler)
|
||||||
|
let predecessor: { id: number; customerId: number; type: 'ELECTRICITY' | 'GAS' } | null = null;
|
||||||
|
if (data.successorOf) {
|
||||||
|
const pred = await prisma.meter.findUnique({
|
||||||
|
where: { id: data.successorOf.predecessorMeterId },
|
||||||
|
select: { id: true, customerId: true, type: true },
|
||||||
|
});
|
||||||
|
if (!pred || pred.customerId !== customerId) {
|
||||||
|
throw new Error('Vorgänger-Zähler nicht gefunden');
|
||||||
|
}
|
||||||
|
if (pred.type !== data.type) {
|
||||||
|
throw new Error('Vorgänger-Zähler muss denselben Typ haben (Strom/Gas)');
|
||||||
|
}
|
||||||
|
predecessor = pred;
|
||||||
|
|
||||||
|
// Endstand bereits hier validieren, damit kein verwaister Meter entsteht
|
||||||
|
// wenn der Wert mit bestehenden Zählerständen kollidiert.
|
||||||
|
if (data.successorOf.finalReadingPrevious != null) {
|
||||||
|
const switchAt = data.successorOf.installedAt
|
||||||
|
? new Date(data.successorOf.installedAt)
|
||||||
|
: new Date();
|
||||||
|
await validateReadingValue(
|
||||||
|
pred.id,
|
||||||
|
switchAt,
|
||||||
|
data.successorOf.finalReadingPrevious,
|
||||||
|
undefined,
|
||||||
|
'HT',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await prisma.meter.create({
|
||||||
data: {
|
data: {
|
||||||
customerId,
|
customerId,
|
||||||
...data,
|
meterNumber: data.meterNumber,
|
||||||
|
type: data.type,
|
||||||
|
tariffModel: data.tariffModel,
|
||||||
|
location: data.location,
|
||||||
|
addressId: data.addressId,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
predecessorMeterId: predecessor?.id,
|
||||||
},
|
},
|
||||||
|
include: { address: true, predecessor: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Folgezähler-Propagation: alle Verträge, die den Vorgänger als aktuellen
|
||||||
|
// Zähler nutzen, bekommen den neuen Zähler als Nachfolger angehängt
|
||||||
|
// (analog zu addSuccessorMeter im contract.controller).
|
||||||
|
if (predecessor && data.successorOf) {
|
||||||
|
const installedAt = data.successorOf.installedAt
|
||||||
|
? new Date(data.successorOf.installedAt)
|
||||||
|
: new Date();
|
||||||
|
const finalReading = data.successorOf.finalReadingPrevious;
|
||||||
|
|
||||||
|
const affectedContracts = await prisma.energyContractDetails.findMany({
|
||||||
|
where: { meterId: predecessor.id },
|
||||||
|
include: { contractMeters: { orderBy: { position: 'asc' } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const ecd of affectedContracts) {
|
||||||
|
// Vorhandenen ContractMeter für den Vorgänger als gewechselt markieren.
|
||||||
|
// Falls noch kein ContractMeter für den Vorgänger existiert (Single-Meter-
|
||||||
|
// Vertrag vor Multi-Meter-Refactor), legen wir ihn als position 0 an,
|
||||||
|
// damit die Kette lückenlos ist.
|
||||||
|
let predCM = ecd.contractMeters.find((cm) => cm.meterId === predecessor!.id);
|
||||||
|
if (!predCM) {
|
||||||
|
predCM = await prisma.contractMeter.create({
|
||||||
|
data: {
|
||||||
|
energyContractDetailsId: ecd.id,
|
||||||
|
meterId: predecessor.id,
|
||||||
|
position: 0,
|
||||||
|
installedAt: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ecd.contractMeters.push(predCM);
|
||||||
|
}
|
||||||
|
await prisma.contractMeter.update({
|
||||||
|
where: { id: predCM.id },
|
||||||
|
data: {
|
||||||
|
removedAt: installedAt,
|
||||||
|
finalReading: finalReading != null ? finalReading : predCM.finalReading,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextPosition = ecd.contractMeters.length > 0
|
||||||
|
? Math.max(...ecd.contractMeters.map((cm) => cm.position)) + 1
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Idempotenz: falls (durch Doppel-Klick o.ä.) schon ein ContractMeter
|
||||||
|
// mit dem neuen Zähler existiert, nicht doppelt anlegen.
|
||||||
|
const existsForNew = await prisma.contractMeter.findUnique({
|
||||||
|
where: {
|
||||||
|
energyContractDetailsId_meterId: {
|
||||||
|
energyContractDetailsId: ecd.id,
|
||||||
|
meterId: created.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!existsForNew) {
|
||||||
|
await prisma.contractMeter.create({
|
||||||
|
data: {
|
||||||
|
energyContractDetailsId: ecd.id,
|
||||||
|
meterId: created.id,
|
||||||
|
position: nextPosition,
|
||||||
|
installedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aktuellen Zähler am Vertrag aktualisieren
|
||||||
|
await prisma.energyContractDetails.update({
|
||||||
|
where: { id: ecd.id },
|
||||||
|
data: { meterId: created.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Endstand des Vorgängers als regulären Zählerstand erfassen, damit er
|
||||||
|
// in die Verbrauchsberechnung einfließt und in der Zählerstände-Liste
|
||||||
|
// sichtbar ist. Idempotent gegen Doppel-Submit.
|
||||||
|
if (data.successorOf.finalReadingPrevious != null) {
|
||||||
|
await recordPredecessorFinalReading(
|
||||||
|
predecessor.id,
|
||||||
|
installedAt,
|
||||||
|
data.successorOf.finalReadingPrevious,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alten Zähler deaktivieren (Default), sofern der Aufrufer das nicht
|
||||||
|
// explizit auf false setzt. Macht den typischen Zählerwechsel-Workflow
|
||||||
|
// ein-klick-fähig.
|
||||||
|
if (data.successorOf.deactivatePredecessor !== false) {
|
||||||
|
await prisma.meter.update({
|
||||||
|
where: { id: predecessor.id },
|
||||||
|
data: { isActive: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateMeter(
|
export async function updateMeter(
|
||||||
@@ -441,13 +664,21 @@ export async function updateMeter(
|
|||||||
data: {
|
data: {
|
||||||
meterNumber?: string;
|
meterNumber?: string;
|
||||||
type?: 'ELECTRICITY' | 'GAS';
|
type?: 'ELECTRICITY' | 'GAS';
|
||||||
|
tariffModel?: 'SINGLE' | 'DUAL';
|
||||||
location?: string;
|
location?: string;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
|
addressId?: number | null;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
if (data.addressId !== undefined && data.addressId !== null) {
|
||||||
|
const meter = await prisma.meter.findUnique({ where: { id }, select: { customerId: true } });
|
||||||
|
if (!meter) throw new Error('Zähler nicht gefunden');
|
||||||
|
await assertDeliveryAddressBelongsToCustomer(data.addressId, meter.customerId);
|
||||||
|
}
|
||||||
return prisma.meter.update({
|
return prisma.meter.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data,
|
data,
|
||||||
|
include: { address: true },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,22 @@
|
|||||||
|
|
||||||
import prisma from '../../lib/prisma.js';
|
import prisma from '../../lib/prisma.js';
|
||||||
import { decrypt } from '../../utils/encryption.js';
|
import { decrypt } from '../../utils/encryption.js';
|
||||||
|
import { stripHtml } from '../../utils/sanitize.js';
|
||||||
|
|
||||||
|
// Pentest 48.1 (MEDIUM, 2026-06-01): customerEmailLabel landete roh in der
|
||||||
|
// DB und kam über /api/email-providers/public-settings 1:1 raus. React
|
||||||
|
// escapt zwar als Textnode, aber Defense-in-Depth verlangt Stripping schon
|
||||||
|
// beim Schreiben (PDF/Mail-Templates wären sofort betroffen). Zudem war
|
||||||
|
// das Längenlimit nur frontendseitig gesetzt – hier 60 Zeichen enforced.
|
||||||
|
const CUSTOMER_EMAIL_LABEL_MAX = 60;
|
||||||
|
function sanitizeCustomerEmailLabel(raw: unknown): string | null {
|
||||||
|
if (raw == null) return null;
|
||||||
|
if (typeof raw !== 'string') return null;
|
||||||
|
const stripped = stripHtml(raw) as string;
|
||||||
|
const trimmed = stripped.trim();
|
||||||
|
if (trimmed === '') return null;
|
||||||
|
return trimmed.slice(0, CUSTOMER_EMAIL_LABEL_MAX);
|
||||||
|
}
|
||||||
import {
|
import {
|
||||||
IEmailProvider,
|
IEmailProvider,
|
||||||
EmailProviderConfig,
|
EmailProviderConfig,
|
||||||
@@ -126,7 +142,7 @@ export async function createProviderConfig(data: CreateProviderConfigData) {
|
|||||||
allowSelfSignedCerts: data.allowSelfSignedCerts ?? false,
|
allowSelfSignedCerts: data.allowSelfSignedCerts ?? false,
|
||||||
systemEmailAddress: data.systemEmailAddress || null,
|
systemEmailAddress: data.systemEmailAddress || null,
|
||||||
systemEmailPasswordEncrypted,
|
systemEmailPasswordEncrypted,
|
||||||
customerEmailLabel: data.customerEmailLabel || null,
|
customerEmailLabel: sanitizeCustomerEmailLabel(data.customerEmailLabel),
|
||||||
isActive: data.isActive ?? true,
|
isActive: data.isActive ?? true,
|
||||||
isDefault: data.isDefault ?? false,
|
isDefault: data.isDefault ?? false,
|
||||||
},
|
},
|
||||||
@@ -159,7 +175,7 @@ export async function updateProviderConfig(
|
|||||||
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.customerEmailLabel !== undefined) updateData.customerEmailLabel = sanitizeCustomerEmailLabel(data.customerEmailLabel);
|
||||||
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;
|
||||||
|
|
||||||
@@ -532,7 +548,10 @@ export async function getProviderPublicSettings(): Promise<{
|
|||||||
}> {
|
}> {
|
||||||
const config = await getActiveProviderConfig();
|
const config = await getActiveProviderConfig();
|
||||||
const domain = config?.domain ?? null;
|
const domain = config?.domain ?? null;
|
||||||
const customLabel = config?.customerEmailLabel?.trim();
|
// Read-Time-Defensive (Pentest 48.1): falls je rohe Alt-Daten in der DB
|
||||||
|
// landeten, hier nochmal durch den Sanitizer schicken. Stellt sicher,
|
||||||
|
// dass /public-settings nicht ungewollt XSS-Payloads rausreicht.
|
||||||
|
const customLabel = sanitizeCustomerEmailLabel(config?.customerEmailLabel) ?? null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
domain,
|
domain,
|
||||||
|
|||||||
@@ -180,17 +180,56 @@ export class PleskEmailProvider implements IEmailProvider {
|
|||||||
|
|
||||||
// Mailbox-Status aus stdout parsen (Format: "Mailbox: true" oder "Mailbox: false")
|
// Mailbox-Status aus stdout parsen (Format: "Mailbox: true" oder "Mailbox: false")
|
||||||
let hasMailbox: boolean | undefined;
|
let hasMailbox: boolean | undefined;
|
||||||
|
let mailgroupActive: boolean | undefined;
|
||||||
|
let mailgroupMembers: string[] | undefined;
|
||||||
|
let forwardingActive: boolean | undefined;
|
||||||
|
let forwardingTargets: string[] | undefined;
|
||||||
if (exists && result.stdout) {
|
if (exists && result.stdout) {
|
||||||
const mailboxMatch = result.stdout.match(/Mailbox:\s*(true|false)/i);
|
const mailboxMatch = result.stdout.match(/Mailbox:\s*(true|false)/i);
|
||||||
if (mailboxMatch) {
|
if (mailboxMatch) {
|
||||||
hasMailbox = mailboxMatch[1].toLowerCase() === 'true';
|
hasMailbox = mailboxMatch[1].toLowerCase() === 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mailgroup-Status + Mitglieder. Plesk listet sie auf einer
|
||||||
|
// Zeile, Adressen sind durch Whitespace getrennt.
|
||||||
|
const mailgroupMatch = result.stdout.match(/Mailgroup:\s*(true|false)/i);
|
||||||
|
if (mailgroupMatch) {
|
||||||
|
mailgroupActive = mailgroupMatch[1].toLowerCase() === 'true';
|
||||||
|
}
|
||||||
|
const groupMembersMatch = result.stdout.match(/Group member\(s\):\s*([^\n]*)/i);
|
||||||
|
if (groupMembersMatch) {
|
||||||
|
mailgroupMembers = groupMembersMatch[1]
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter((m) => m.includes('@'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forwarding-Status + Ziele. Plesk druckt "Forward request: <addrs>".
|
||||||
|
// Auf manchen Plesk-Versionen heißt das Feld auch "Forwarding".
|
||||||
|
const forwardActiveMatch = result.stdout.match(/Forwarding:\s*(true|false)/i);
|
||||||
|
if (forwardActiveMatch) {
|
||||||
|
forwardingActive = forwardActiveMatch[1].toLowerCase() === 'true';
|
||||||
|
}
|
||||||
|
const forwardTargetsMatch = result.stdout.match(/Forward(?:ing)?(?: request)?:\s*([^\n]*)/i);
|
||||||
|
if (forwardTargetsMatch) {
|
||||||
|
forwardingTargets = forwardTargetsMatch[1]
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter((m) => m.includes('@'));
|
||||||
|
if (forwardingActive === undefined) {
|
||||||
|
forwardingActive = (forwardingTargets?.length ?? 0) > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
exists,
|
exists,
|
||||||
email: exists ? email : undefined,
|
email: exists ? email : undefined,
|
||||||
hasMailbox,
|
hasMailbox,
|
||||||
|
mailgroupActive,
|
||||||
|
mailgroupMembers,
|
||||||
|
forwardingActive,
|
||||||
|
forwardingTargets,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// HTTP-Fehler oder Netzwerkfehler
|
// HTTP-Fehler oder Netzwerkfehler
|
||||||
@@ -458,15 +497,63 @@ export class PleskEmailProvider implements IEmailProvider {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plesk CLI API: Weiterleitungsziele aktualisieren
|
// Plesk-CLI-Eigenheit: `-forwarding-addresses` akzeptiert NUR
|
||||||
// Format für -forwarding-addresses: "set:email1,email2" ersetzt alle Adressen
|
// `add:` und `del:`, KEIN `set:`. Und `-forwarding` ist der
|
||||||
await this.request('POST', '/api/v2/cli/mail/call', {
|
// Mailgroup-Schalter (Plesk nennt das im `--info` "Mailgroup",
|
||||||
params: [
|
// im CLI "forwarding" – derselbe Mechanismus, doppelt benannt).
|
||||||
|
// Es gibt KEINE separaten Mailgroup-Optionen wie `-mailgroup`.
|
||||||
|
//
|
||||||
|
// Wir bauen daher den Diff: alte Member abrufen, dann
|
||||||
|
// del:<entfernt> + add:<neu> in zwei separaten Calls. Idempotent,
|
||||||
|
// weil add: Duplikate ignoriert und del: nicht-vorhandene auch.
|
||||||
|
const currentMembers = exists.mailgroupMembers ?? [];
|
||||||
|
const targetsLower = new Set(targets.map((t) => t.toLowerCase()));
|
||||||
|
const currentLower = new Set(currentMembers.map((m) => m.toLowerCase()));
|
||||||
|
const toRemove = currentMembers.filter((m) => !targetsLower.has(m.toLowerCase()));
|
||||||
|
const toAdd = targets.filter((t) => !currentLower.has(t.toLowerCase()));
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[Plesk updateForwardTargets] ${email} – aktuell: [${currentMembers.join(', ')}], ` +
|
||||||
|
`soll: [${targets.join(', ')}], entfernen: [${toRemove.join(', ')}], hinzufügen: [${toAdd.join(', ')}]`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Entfernen-Schritt
|
||||||
|
if (toRemove.length > 0) {
|
||||||
|
const delParams = [
|
||||||
|
'--update', email,
|
||||||
|
'-forwarding-addresses', `del:${toRemove.join(',')}`,
|
||||||
|
];
|
||||||
|
const delResult = await this.request<{ code: number; stdout: string; stderr: string }>(
|
||||||
|
'POST', '/api/v2/cli/mail/call', { params: delParams },
|
||||||
|
);
|
||||||
|
console.log('[Plesk updateForwardTargets] del response:', JSON.stringify(delResult, null, 2));
|
||||||
|
if (delResult.code !== 0 || /error|failed/i.test(delResult.stderr || '')) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: delResult.stderr?.trim() || delResult.stdout?.trim() || `Plesk del returned code ${delResult.code}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hinzufügen-Schritt (impliziert -forwarding true, damit Mailgroup
|
||||||
|
// aktiviert bleibt bzw. wird).
|
||||||
|
if (toAdd.length > 0) {
|
||||||
|
const addParams = [
|
||||||
'--update', email,
|
'--update', email,
|
||||||
'-forwarding', 'true',
|
'-forwarding', 'true',
|
||||||
'-forwarding-addresses', `set:${targets.join(',')}`,
|
'-forwarding-addresses', `add:${toAdd.join(',')}`,
|
||||||
],
|
];
|
||||||
});
|
const addResult = await this.request<{ code: number; stdout: string; stderr: string }>(
|
||||||
|
'POST', '/api/v2/cli/mail/call', { params: addParams },
|
||||||
|
);
|
||||||
|
console.log('[Plesk updateForwardTargets] add response:', JSON.stringify(addResult, null, 2));
|
||||||
|
if (addResult.code !== 0 || /error|failed/i.test(addResult.stderr || '')) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: addResult.stderr?.trim() || addResult.stdout?.trim() || `Plesk add returned code ${addResult.code}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -42,6 +42,14 @@ export interface EmailExistsResult {
|
|||||||
exists: boolean;
|
exists: boolean;
|
||||||
email?: string;
|
email?: string;
|
||||||
hasMailbox?: boolean; // true wenn echte Mailbox vorhanden
|
hasMailbox?: boolean; // true wenn echte Mailbox vorhanden
|
||||||
|
// Plesk hat zwei unabhängige Verteil-Mechanismen, beide können parallel
|
||||||
|
// aktiv sein. Manuelle/Legacy-Anlagen nutzen oft "Mailgroup" statt
|
||||||
|
// "Forwarding" – unser Sync muss alte Mitglieder dort einsammeln,
|
||||||
|
// sonst gehen sie beim Umschalten auf Forwarding verloren.
|
||||||
|
mailgroupActive?: boolean;
|
||||||
|
mailgroupMembers?: string[];
|
||||||
|
forwardingActive?: boolean;
|
||||||
|
forwardingTargets?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EmailOperationResult {
|
export interface EmailOperationResult {
|
||||||
|
|||||||
@@ -46,6 +46,13 @@ export interface ProviderExport {
|
|||||||
portalUrl: string | null;
|
portalUrl: string | null;
|
||||||
usernameFieldName: string | null;
|
usernameFieldName: string | null;
|
||||||
passwordFieldName: string | null;
|
passwordFieldName: string | null;
|
||||||
|
contactEmail: string | null;
|
||||||
|
contactPhone: string | null;
|
||||||
|
contactFax: string | null;
|
||||||
|
contactAddress: string | null;
|
||||||
|
cancellationEmail: string | null;
|
||||||
|
cancellationFax: string | null;
|
||||||
|
cancellationAddress: string | null;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
tariffs: { name: string; isActive: boolean }[];
|
tariffs: { name: string; isActive: boolean }[];
|
||||||
}
|
}
|
||||||
@@ -90,6 +97,13 @@ export async function collectFactoryDefaults() {
|
|||||||
portalUrl: p.portalUrl,
|
portalUrl: p.portalUrl,
|
||||||
usernameFieldName: p.usernameFieldName,
|
usernameFieldName: p.usernameFieldName,
|
||||||
passwordFieldName: p.passwordFieldName,
|
passwordFieldName: p.passwordFieldName,
|
||||||
|
contactEmail: p.contactEmail,
|
||||||
|
contactPhone: p.contactPhone,
|
||||||
|
contactFax: p.contactFax,
|
||||||
|
contactAddress: p.contactAddress,
|
||||||
|
cancellationEmail: p.cancellationEmail,
|
||||||
|
cancellationFax: p.cancellationFax,
|
||||||
|
cancellationAddress: p.cancellationAddress,
|
||||||
isActive: p.isActive,
|
isActive: p.isActive,
|
||||||
tariffs: p.tariffs.map((t) => ({ name: t.name, isActive: t.isActive })),
|
tariffs: p.tariffs.map((t) => ({ name: t.name, isActive: t.isActive })),
|
||||||
})),
|
})),
|
||||||
@@ -284,6 +298,13 @@ export async function importFactoryDefaults(
|
|||||||
portalUrl: p.portalUrl ?? null,
|
portalUrl: p.portalUrl ?? null,
|
||||||
usernameFieldName: p.usernameFieldName ?? null,
|
usernameFieldName: p.usernameFieldName ?? null,
|
||||||
passwordFieldName: p.passwordFieldName ?? null,
|
passwordFieldName: p.passwordFieldName ?? null,
|
||||||
|
contactEmail: p.contactEmail ?? null,
|
||||||
|
contactPhone: p.contactPhone ?? null,
|
||||||
|
contactFax: p.contactFax ?? null,
|
||||||
|
contactAddress: p.contactAddress ?? null,
|
||||||
|
cancellationEmail: p.cancellationEmail ?? null,
|
||||||
|
cancellationFax: p.cancellationFax ?? null,
|
||||||
|
cancellationAddress: p.cancellationAddress ?? null,
|
||||||
isActive: p.isActive ?? true,
|
isActive: p.isActive ?? true,
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
@@ -291,6 +312,13 @@ export async function importFactoryDefaults(
|
|||||||
portalUrl: p.portalUrl ?? null,
|
portalUrl: p.portalUrl ?? null,
|
||||||
usernameFieldName: p.usernameFieldName ?? null,
|
usernameFieldName: p.usernameFieldName ?? null,
|
||||||
passwordFieldName: p.passwordFieldName ?? null,
|
passwordFieldName: p.passwordFieldName ?? null,
|
||||||
|
contactEmail: p.contactEmail ?? null,
|
||||||
|
contactPhone: p.contactPhone ?? null,
|
||||||
|
contactFax: p.contactFax ?? null,
|
||||||
|
contactAddress: p.contactAddress ?? null,
|
||||||
|
cancellationEmail: p.cancellationEmail ?? null,
|
||||||
|
cancellationFax: p.cancellationFax ?? null,
|
||||||
|
cancellationAddress: p.cancellationAddress ?? null,
|
||||||
isActive: p.isActive ?? true,
|
isActive: p.isActive ?? true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,13 +45,16 @@ export const CRM_FIELDS = [
|
|||||||
{ path: 'owner.phone', label: 'Telefon', group: 'Eigentümer' },
|
{ path: 'owner.phone', label: 'Telefon', group: 'Eigentümer' },
|
||||||
{ path: 'owner.mobile', label: 'Mobil', group: 'Eigentümer' },
|
{ path: 'owner.mobile', label: 'Mobil', group: 'Eigentümer' },
|
||||||
{ path: 'owner.email', label: 'E-Mail', group: 'Eigentümer' },
|
{ path: 'owner.email', label: 'E-Mail', group: 'Eigentümer' },
|
||||||
// Rechnungsadresse
|
// Rechnungsadresse (Fallback auf Lieferadresse, wenn keine separate
|
||||||
|
// Rechnungsadresse gepflegt ist – siehe billingAddress-Resolve im Code)
|
||||||
{ path: 'billingAddress.street', label: 'Straße (Rechnung)', group: 'Rechnungsadresse' },
|
{ path: 'billingAddress.street', label: 'Straße (Rechnung)', group: 'Rechnungsadresse' },
|
||||||
{ path: 'billingAddress.houseNumber', label: 'Hausnummer (Rechnung)', group: 'Rechnungsadresse' },
|
{ path: 'billingAddress.houseNumber', label: 'Hausnummer (Rechnung)', group: 'Rechnungsadresse' },
|
||||||
{ path: 'billingAddress.streetFull', label: 'Straße + Nr. (Rechnung)', group: 'Rechnungsadresse' },
|
{ path: 'billingAddress.streetFull', label: 'Straße + Nr. (Rechnung)', group: 'Rechnungsadresse' },
|
||||||
{ path: 'billingAddress.postalCode', label: 'PLZ (Rechnung)', group: 'Rechnungsadresse' },
|
{ path: 'billingAddress.postalCode', label: 'PLZ (Rechnung)', group: 'Rechnungsadresse' },
|
||||||
{ path: 'billingAddress.city', label: 'Stadt (Rechnung)', group: 'Rechnungsadresse' },
|
{ path: 'billingAddress.city', label: 'Stadt (Rechnung)', group: 'Rechnungsadresse' },
|
||||||
{ path: 'billingAddress.postalCodeCity', label: 'PLZ + Stadt (Rechnung)', group: 'Rechnungsadresse' },
|
{ path: 'billingAddress.postalCodeCity', label: 'PLZ + Stadt (Rechnung)', group: 'Rechnungsadresse' },
|
||||||
|
{ path: 'billingAddress.country', label: 'Land (Rechnung)', group: 'Rechnungsadresse' },
|
||||||
|
{ path: 'billingAddress.full', label: 'Vollständige Adresse (Rechnung)', group: 'Rechnungsadresse' },
|
||||||
// Bankverbindung
|
// Bankverbindung
|
||||||
{ path: 'bankCard.iban', label: 'IBAN', group: 'Bank' },
|
{ path: 'bankCard.iban', label: 'IBAN', group: 'Bank' },
|
||||||
{ path: 'bankCard.bic', label: 'BIC', group: 'Bank' },
|
{ path: 'bankCard.bic', label: 'BIC', group: 'Bank' },
|
||||||
@@ -87,7 +90,9 @@ export const CRM_FIELDS = [
|
|||||||
{ path: 'energyDetails.basePrice', label: 'Grundpreis (€/Monat)', group: 'Energie' },
|
{ path: 'energyDetails.basePrice', label: 'Grundpreis (€/Monat)', group: 'Energie' },
|
||||||
{ path: 'energyDetails.unitPrice', label: 'Arbeitspreis (€/kWh)', group: 'Energie' },
|
{ path: 'energyDetails.unitPrice', label: 'Arbeitspreis (€/kWh)', group: 'Energie' },
|
||||||
{ path: 'energyDetails.unitPriceNt', label: 'NT-Arbeitspreis (€/kWh)', group: 'Energie' },
|
{ path: 'energyDetails.unitPriceNt', label: 'NT-Arbeitspreis (€/kWh)', group: 'Energie' },
|
||||||
{ path: 'energyDetails.bonus', label: 'Bonus (€)', group: 'Energie' },
|
{ path: 'energyDetails.instantBonus', label: 'Sofort-Bonus (€)', group: 'Energie' },
|
||||||
|
{ path: 'energyDetails.newCustomerBonus', label: 'Neukunden-Bonus (€)', group: 'Energie' },
|
||||||
|
{ path: 'energyDetails.totalBonus', label: 'Gesamtbonus (€)', group: 'Energie' },
|
||||||
// Internet/DSL/Glasfaser/Kabel
|
// Internet/DSL/Glasfaser/Kabel
|
||||||
{ path: 'internetDetails.downloadSpeed', label: 'Download-Speed (Mbit/s)', group: 'Internet' },
|
{ path: 'internetDetails.downloadSpeed', label: 'Download-Speed (Mbit/s)', group: 'Internet' },
|
||||||
{ path: 'internetDetails.uploadSpeed', label: 'Upload-Speed (Mbit/s)', group: 'Internet' },
|
{ path: 'internetDetails.uploadSpeed', label: 'Upload-Speed (Mbit/s)', group: 'Internet' },
|
||||||
@@ -384,7 +389,12 @@ export async function generateFilledPdf(
|
|||||||
const docTypeLabels: Record<string, string> = { ID_CARD: 'Personalausweis', PASSPORT: 'Reisepass', DRIVERS_LICENSE: 'Führerschein', OTHER: 'Sonstiges' };
|
const docTypeLabels: Record<string, string> = { ID_CARD: 'Personalausweis', PASSPORT: 'Reisepass', DRIVERS_LICENSE: 'Führerschein', OTHER: 'Sonstiges' };
|
||||||
|
|
||||||
const addr = contract.address;
|
const addr = contract.address;
|
||||||
const bAddr = contract.billingAddress;
|
// Wenn keine separate Rechnungsadresse hinterlegt ist, fällt der Wert auf
|
||||||
|
// die Lieferadresse zurück – konsistent mit der Kundenakte-Logik
|
||||||
|
// (Contract.billingAddressId NULL = "Wie Lieferadresse"). Damit füllen
|
||||||
|
// Auftragsformulare die Rechnungs-Felder nicht mehr leer aus, wenn der
|
||||||
|
// Anbieter eine identische Adresse erwartet.
|
||||||
|
const bAddr = contract.billingAddress ?? contract.address;
|
||||||
|
|
||||||
const dataContext: Record<string, string> = {
|
const dataContext: Record<string, string> = {
|
||||||
// Kunde
|
// Kunde
|
||||||
@@ -435,6 +445,8 @@ export async function generateFilledPdf(
|
|||||||
'billingAddress.postalCode': bAddr?.postalCode || '',
|
'billingAddress.postalCode': bAddr?.postalCode || '',
|
||||||
'billingAddress.city': bAddr?.city || '',
|
'billingAddress.city': bAddr?.city || '',
|
||||||
'billingAddress.postalCodeCity': bAddr ? `${bAddr.postalCode} ${bAddr.city}` : '',
|
'billingAddress.postalCodeCity': bAddr ? `${bAddr.postalCode} ${bAddr.city}` : '',
|
||||||
|
'billingAddress.country': bAddr?.country || '',
|
||||||
|
'billingAddress.full': bAddr ? `${bAddr.street} ${bAddr.houseNumber}, ${bAddr.postalCode} ${bAddr.city}` : '',
|
||||||
// Bank
|
// Bank
|
||||||
'bankCard.iban': bankCard?.iban || '',
|
'bankCard.iban': bankCard?.iban || '',
|
||||||
'bankCard.bic': bankCard?.bic || '',
|
'bankCard.bic': bankCard?.bic || '',
|
||||||
@@ -469,7 +481,11 @@ export async function generateFilledPdf(
|
|||||||
'energyDetails.basePrice': contract.energyDetails?.basePrice?.toString() || '',
|
'energyDetails.basePrice': contract.energyDetails?.basePrice?.toString() || '',
|
||||||
'energyDetails.unitPrice': contract.energyDetails?.unitPrice?.toString() || '',
|
'energyDetails.unitPrice': contract.energyDetails?.unitPrice?.toString() || '',
|
||||||
'energyDetails.unitPriceNt': contract.energyDetails?.unitPriceNt?.toString() || '',
|
'energyDetails.unitPriceNt': contract.energyDetails?.unitPriceNt?.toString() || '',
|
||||||
'energyDetails.bonus': contract.energyDetails?.bonus?.toString() || '',
|
'energyDetails.instantBonus': contract.energyDetails?.instantBonus?.toString() || '',
|
||||||
|
'energyDetails.newCustomerBonus': contract.energyDetails?.newCustomerBonus?.toString() || '',
|
||||||
|
'energyDetails.totalBonus': (
|
||||||
|
((contract.energyDetails?.instantBonus ?? 0) + (contract.energyDetails?.newCustomerBonus ?? 0)) || ''
|
||||||
|
).toString(),
|
||||||
// Internet
|
// Internet
|
||||||
'internetDetails.downloadSpeed': contract.internetDetails?.downloadSpeed?.toString() || '',
|
'internetDetails.downloadSpeed': contract.internetDetails?.downloadSpeed?.toString() || '',
|
||||||
'internetDetails.uploadSpeed': contract.internetDetails?.uploadSpeed?.toString() || '',
|
'internetDetails.uploadSpeed': contract.internetDetails?.uploadSpeed?.toString() || '',
|
||||||
@@ -555,8 +571,31 @@ export async function generateFilledPdf(
|
|||||||
|
|
||||||
const maxFields = template.maxPhoneFields || 8;
|
const maxFields = template.maxPhoneFields || 8;
|
||||||
for (let i = 0; i < Math.max(maxFields, phoneNumbers.length); i++) {
|
for (let i = 0; i < Math.max(maxFields, phoneNumbers.length); i++) {
|
||||||
const fullNumber = phoneNumbers[i]?.phoneNumber || '';
|
const entry = phoneNumbers[i];
|
||||||
const { areaCode, local } = splitPhoneNumber(fullNumber);
|
const fullNumber = entry?.phoneNumber || '';
|
||||||
|
// Bevorzugt den explizit gepflegten areaCode aus der DB (verlässlich),
|
||||||
|
// fällt sonst auf die Heuristik zurück (Altbestand ohne separates
|
||||||
|
// Vorwahl-Feld). `phoneLocal` analog: aus phoneNumber abgeleitet,
|
||||||
|
// wenn areaCode da → den Vorwahl-Prefix abschneiden, sonst Heuristik.
|
||||||
|
let areaCode = '';
|
||||||
|
let local = '';
|
||||||
|
if (entry?.areaCode) {
|
||||||
|
areaCode = entry.areaCode;
|
||||||
|
const split = splitPhoneNumber(fullNumber);
|
||||||
|
// Wenn der heuristische areaCode mit dem DB-Wert übereinstimmt,
|
||||||
|
// ist der heuristische local-Anteil korrekt – sonst pragmatisch:
|
||||||
|
// alles nach dem areaCode-Prefix bis zum Ende
|
||||||
|
if (split.areaCode === entry.areaCode) {
|
||||||
|
local = split.local;
|
||||||
|
} else {
|
||||||
|
const stripped = fullNumber.replace(new RegExp('^' + entry.areaCode.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '[\\s\\-/]*'), '').trim();
|
||||||
|
local = stripped || split.local;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const split = splitPhoneNumber(fullNumber);
|
||||||
|
areaCode = split.areaCode;
|
||||||
|
local = split.local;
|
||||||
|
}
|
||||||
dataContext[`phoneNumbers[${i}]`] = fullNumber;
|
dataContext[`phoneNumbers[${i}]`] = fullNumber;
|
||||||
dataContext[`phoneAreaCode[${i}]`] = areaCode;
|
dataContext[`phoneAreaCode[${i}]`] = areaCode;
|
||||||
dataContext[`phoneLocal[${i}]`] = local;
|
dataContext[`phoneLocal[${i}]`] = local;
|
||||||
|
|||||||
@@ -1,4 +1,20 @@
|
|||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
|
import { stripHtml, isValidEmail, sanitizePhoneField, validateProviderAddress } from '../utils/sanitize.js';
|
||||||
|
import { validateHttpUrl } from '../utils/url.js';
|
||||||
|
|
||||||
|
// Pentest 46.1 (HIGH, 2026-06-01): Stored XSS via provider.portalUrl.
|
||||||
|
// PUT akzeptierte `javascript:alert(...)` als URL, das Portal rendert
|
||||||
|
// sie als <a href={portalUrl}> → ein Klick im Kunden-Browser löst die
|
||||||
|
// XSS aus. Fix: vor Schreiben durch validateHttpUrl, das auch andere
|
||||||
|
// gefährliche Schemata (data:, vbscript:, file:) sperrt und private
|
||||||
|
// IPs verbietet (die URL wird Kunden gezeigt, denen interne Hosts
|
||||||
|
// nichts bringen).
|
||||||
|
function assertValidPortalUrl(portalUrl: string | undefined | null): string | undefined {
|
||||||
|
if (portalUrl == null || portalUrl === '') return undefined;
|
||||||
|
const check = validateHttpUrl(portalUrl, { fieldLabel: 'Portal-URL' });
|
||||||
|
if (!check.ok) throw new Error(check.error);
|
||||||
|
return check.value === '' ? undefined : check.value;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getAllProviders(includeInactive = false) {
|
export async function getAllProviders(includeInactive = false) {
|
||||||
const where = includeInactive ? {} : { isActive: true };
|
const where = includeInactive ? {} : { isActive: true };
|
||||||
@@ -31,33 +47,97 @@ export async function getProviderById(id: number) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createProvider(data: {
|
// Pentest 47.2 (INFO, 2026-06-01): provider.name landete roh in der DB.
|
||||||
name: string;
|
// Aktuell escapt React das Textnode, also kein direkter XSS – aber neue
|
||||||
|
// Renderpfade (PDF, Mail-Templates, Embedded-Strings in URLs) wären
|
||||||
|
// sofort betroffen. Defense-in-depth: schon beim Schreiben strippen.
|
||||||
|
//
|
||||||
|
// 2026-06-21: contactEmail/cancellationEmail laufen zusätzlich durch
|
||||||
|
// isValidEmail (Header-Injection-Schutz für künftige Mail-Templates),
|
||||||
|
// contactPhone/contactFax/cancellationFax durch sanitizePhoneField
|
||||||
|
// (kein CRLF/Control-Char), Postadressen durch sanitizeNotes mit
|
||||||
|
// 500-Cap (mehrzeilig, normalisierte Newlines).
|
||||||
|
function stripProviderStrings<T extends object>(data: T): T {
|
||||||
|
const out: any = { ...data };
|
||||||
|
for (const k of ['name', 'usernameFieldName', 'passwordFieldName'] as const) {
|
||||||
|
if (typeof out[k] === 'string') out[k] = stripHtml(out[k]);
|
||||||
|
}
|
||||||
|
for (const k of ['contactEmail', 'cancellationEmail'] as const) {
|
||||||
|
if (out[k] === '' || out[k] === null) { out[k] = null; continue; }
|
||||||
|
if (out[k] === undefined) continue;
|
||||||
|
const stripped = typeof out[k] === 'string' ? stripHtml(out[k]) : out[k];
|
||||||
|
const value = typeof stripped === 'string' ? stripped.trim() : stripped;
|
||||||
|
if (value === '' ) { out[k] = null; continue; }
|
||||||
|
if (!isValidEmail(value)) {
|
||||||
|
throw new Error(`${k === 'contactEmail' ? 'Kontakt-Emailadresse' : 'Kündigungs-Emailadresse'} ist ungültig.`);
|
||||||
|
}
|
||||||
|
out[k] = value;
|
||||||
|
}
|
||||||
|
const phoneLabels: Record<string, string> = {
|
||||||
|
contactPhone: 'Kontakt-Telefonnummer',
|
||||||
|
contactFax: 'Kontakt-Faxnummer',
|
||||||
|
cancellationFax: 'Kündigungs-Faxnummer',
|
||||||
|
};
|
||||||
|
for (const k of ['contactPhone', 'contactFax', 'cancellationFax'] as const) {
|
||||||
|
if (out[k] === undefined) continue;
|
||||||
|
if (out[k] === '' || out[k] === null) { out[k] = null; continue; }
|
||||||
|
const v = sanitizePhoneField(out[k], phoneLabels[k]);
|
||||||
|
out[k] = v === undefined ? null : v;
|
||||||
|
}
|
||||||
|
const addressLabels: Record<string, string> = {
|
||||||
|
contactAddress: 'Kontakt-Postadresse',
|
||||||
|
cancellationAddress: 'Kündigungs-Postadresse',
|
||||||
|
};
|
||||||
|
for (const k of ['contactAddress', 'cancellationAddress'] as const) {
|
||||||
|
if (out[k] === undefined) continue;
|
||||||
|
// R89.1/R89.2: validateProviderAddress wirft 400 bei Längen-
|
||||||
|
// Verstoß, HTML, Tabs oder Steuerzeichen. Kein silent truncate,
|
||||||
|
// kein silent null-overwrite mehr.
|
||||||
|
out[k] = validateProviderAddress(out[k], addressLabels[k]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProviderWritable {
|
||||||
|
name?: string;
|
||||||
portalUrl?: string;
|
portalUrl?: string;
|
||||||
usernameFieldName?: string;
|
usernameFieldName?: string;
|
||||||
passwordFieldName?: string;
|
passwordFieldName?: string;
|
||||||
}) {
|
contactEmail?: string | null;
|
||||||
|
contactPhone?: string | null;
|
||||||
|
contactFax?: string | null;
|
||||||
|
contactAddress?: string | null;
|
||||||
|
cancellationEmail?: string | null;
|
||||||
|
cancellationFax?: string | null;
|
||||||
|
cancellationAddress?: string | null;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createProvider(data: ProviderWritable & { name: string }) {
|
||||||
|
const clean = stripProviderStrings(data);
|
||||||
|
const portalUrl = assertValidPortalUrl(clean.portalUrl);
|
||||||
return prisma.provider.create({
|
return prisma.provider.create({
|
||||||
data: {
|
data: {
|
||||||
...data,
|
...clean,
|
||||||
|
name: clean.name,
|
||||||
|
portalUrl,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateProvider(
|
export async function updateProvider(id: number, data: ProviderWritable) {
|
||||||
id: number,
|
// portalUrl nur validieren wenn explizit mitgesendet (undefined = unverändert).
|
||||||
data: {
|
// Leerstring = "auf null setzen" - hier setzen wir explizit auf null,
|
||||||
name?: string;
|
// damit Prisma nicht den alten Wert hält.
|
||||||
portalUrl?: string;
|
const updateData: any = stripProviderStrings(data);
|
||||||
usernameFieldName?: string;
|
if (data.portalUrl !== undefined) {
|
||||||
passwordFieldName?: string;
|
const validated = assertValidPortalUrl(data.portalUrl);
|
||||||
isActive?: boolean;
|
updateData.portalUrl = validated ?? null;
|
||||||
}
|
}
|
||||||
) {
|
|
||||||
return prisma.provider.update({
|
return prisma.provider.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data,
|
data: updateData,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,75 @@ import {
|
|||||||
getActiveProviderConfig,
|
getActiveProviderConfig,
|
||||||
} from './emailProvider/emailProviderService.js';
|
} from './emailProvider/emailProviderService.js';
|
||||||
import { generateSecurePassword } from '../utils/passwordGenerator.js';
|
import { generateSecurePassword } from '../utils/passwordGenerator.js';
|
||||||
|
import { ApiError } from '../utils/apiError.js';
|
||||||
|
|
||||||
|
// Locker, aber strikt genug gegen offensichtlichen Müll (CRLF, Spaces,
|
||||||
|
// Komma). Wirklich validiert wird vom Provider beim Sync.
|
||||||
|
const FORWARD_EMAIL_REGEX = /^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$/;
|
||||||
|
|
||||||
|
// Pentest 71.1 (MEDIUM, 2026-06-08): RFC-reservierte und private/
|
||||||
|
// On-Prem-TLDs. Eine Weiterleitung an `attacker@plesk.internal` würde
|
||||||
|
// am Provider DNS-Lookups gegen internes Netz auslösen oder bei mDNS-
|
||||||
|
// Setup an einen lokalen Mailserver gehen. Wir blocken sie hart.
|
||||||
|
const BLOCKED_TLDS = new Set([
|
||||||
|
'local', 'internal', 'corp', 'lan', 'home', 'private',
|
||||||
|
'invalid', 'test', 'localhost', 'example',
|
||||||
|
'intranet', 'localdomain', 'arpa',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function parseAdditionalForwards(raw: string | null | undefined): string[] {
|
||||||
|
if (!raw) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
return parsed.filter((x): x is string => typeof x === 'string' && x.trim() !== '');
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeAdditionalForwards(list: string[]): string | null {
|
||||||
|
const cleaned = list.map((s) => s.trim()).filter((s) => s !== '');
|
||||||
|
return cleaned.length === 0 ? null : JSON.stringify(cleaned);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert eine normalisierte Form der Adresse für den Dedup-Vergleich:
|
||||||
|
* lowercase + Plus-Tag aus dem Local-Part rausgestrippt.
|
||||||
|
* `billing+pentest@test.de` und `billing@test.de` haben so denselben
|
||||||
|
* Schlüssel und treffen sich beim Dedup. Pentest 71.2.
|
||||||
|
*/
|
||||||
|
export function canonicalEmailKey(email: string): string {
|
||||||
|
const trimmed = email.trim().toLowerCase();
|
||||||
|
const at = trimmed.lastIndexOf('@');
|
||||||
|
if (at < 1) return trimmed;
|
||||||
|
const localPart = trimmed.slice(0, at);
|
||||||
|
const domain = trimmed.slice(at + 1);
|
||||||
|
const plus = localPart.indexOf('+');
|
||||||
|
const cleanedLocal = plus === -1 ? localPart : localPart.slice(0, plus);
|
||||||
|
return `${cleanedLocal}@${domain}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertValidForwardingEmail(value: unknown): string {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
throw new ApiError(400, 'Ungültige Weiterleitungs-E-Mail-Adresse');
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed.length === 0 || trimmed.length > 254) {
|
||||||
|
throw new ApiError(400, 'Weiterleitungs-E-Mail-Adresse ist leer oder zu lang');
|
||||||
|
}
|
||||||
|
if (!FORWARD_EMAIL_REGEX.test(trimmed)) {
|
||||||
|
throw new ApiError(400, `Ungültiges E-Mail-Format: ${trimmed}`);
|
||||||
|
}
|
||||||
|
// 71.1: TLD aus dem Domain-Part rausziehen und gegen die Blocklist
|
||||||
|
// halten. Domain liegt nach dem letzten @, TLD nach dem letzten Punkt.
|
||||||
|
const domain = trimmed.slice(trimmed.lastIndexOf('@') + 1).toLowerCase();
|
||||||
|
const tld = domain.slice(domain.lastIndexOf('.') + 1);
|
||||||
|
if (BLOCKED_TLDS.has(tld)) {
|
||||||
|
throw new ApiError(400, `Top-Level-Domain "${tld}" ist nicht erlaubt (reservierte/private TLD).`);
|
||||||
|
}
|
||||||
|
return trimmed.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
export async function getEmailsByCustomerId(customerId: number, includeInactive = false) {
|
export async function getEmailsByCustomerId(customerId: number, includeInactive = false) {
|
||||||
const where: Record<string, unknown> = { customerId };
|
const where: Record<string, unknown> = { customerId };
|
||||||
@@ -83,6 +152,27 @@ export interface CreateEmailData {
|
|||||||
export async function createEmail(data: CreateEmailData) {
|
export async function createEmail(data: CreateEmailData) {
|
||||||
const { provisionAtProvider, createMailbox, ...emailData } = data;
|
const { provisionAtProvider, createMailbox, ...emailData } = data;
|
||||||
|
|
||||||
|
// Duplikatscheck: pro Kunde darf eine Stressfrei-Adresse nur einmal
|
||||||
|
// existieren. Case-insensitive, weil RFC-localpart-Großschreibung in
|
||||||
|
// der Praxis nie semantischen Unterschied macht und der Provider eh
|
||||||
|
// einheitlich lowercased.
|
||||||
|
const normalized = data.email.trim().toLowerCase();
|
||||||
|
const conflict = await prisma.stressfreiEmail.findFirst({
|
||||||
|
where: {
|
||||||
|
customerId: data.customerId,
|
||||||
|
email: { equals: normalized },
|
||||||
|
},
|
||||||
|
select: { id: true, isActive: true },
|
||||||
|
});
|
||||||
|
if (conflict) {
|
||||||
|
const hint = conflict.isActive
|
||||||
|
? 'Diese E-Mail-Adresse ist bei diesem Kunden bereits angelegt.'
|
||||||
|
: 'Diese E-Mail-Adresse existiert bei diesem Kunden bereits (deaktiviert). Bitte reaktiviere den vorhandenen Eintrag, statt einen neuen anzulegen.';
|
||||||
|
throw new ApiError(409, hint);
|
||||||
|
}
|
||||||
|
// Wert in DB ist eh schon lowercase – wir setzen es einheitlich.
|
||||||
|
emailData.email = normalized;
|
||||||
|
|
||||||
// Falls beim Provider anlegen gewünscht
|
// Falls beim Provider anlegen gewünscht
|
||||||
if (provisionAtProvider) {
|
if (provisionAtProvider) {
|
||||||
// Kunde laden für Weiterleitung
|
// Kunde laden für Weiterleitung
|
||||||
@@ -153,6 +243,34 @@ export async function updateEmail(
|
|||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
// Beim Umbenennen der E-Mail-Adresse erneut Kollisionscheck. Sonst
|
||||||
|
// kann man eine zweite Adresse mit identischer Mail beim selben Kunden
|
||||||
|
// anlegen (Umweg um den Create-Check).
|
||||||
|
if (typeof data.email === 'string' && data.email.trim() !== '') {
|
||||||
|
const normalized = data.email.trim().toLowerCase();
|
||||||
|
const current = await prisma.stressfreiEmail.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { customerId: true, email: true },
|
||||||
|
});
|
||||||
|
if (!current) {
|
||||||
|
throw new ApiError(404, 'StressfreiEmail nicht gefunden');
|
||||||
|
}
|
||||||
|
if (normalized !== current.email.toLowerCase()) {
|
||||||
|
const conflict = await prisma.stressfreiEmail.findFirst({
|
||||||
|
where: {
|
||||||
|
customerId: current.customerId,
|
||||||
|
email: { equals: normalized },
|
||||||
|
NOT: { id },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (conflict) {
|
||||||
|
throw new ApiError(409, 'Diese E-Mail-Adresse ist bei diesem Kunden bereits angelegt.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data.email = normalized;
|
||||||
|
}
|
||||||
|
|
||||||
return prisma.stressfreiEmail.update({
|
return prisma.stressfreiEmail.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data,
|
data,
|
||||||
@@ -163,6 +281,98 @@ export async function deleteEmail(id: number) {
|
|||||||
return prisma.stressfreiEmail.delete({ where: { id } });
|
return prisma.stressfreiEmail.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Komplette Liste zusätzlicher Weiterleitungs-E-Mails ersetzen und
|
||||||
|
* direkt mit dem Provider synchronisieren. Aufrufer hat eine canonical
|
||||||
|
* Liste – das Sub-Modal arbeitet auf Snapshot-Basis.
|
||||||
|
*
|
||||||
|
* Pentest 71.2: Dedup über `canonicalEmailKey` (Plus-Tags strippen),
|
||||||
|
* damit `billing+tag@x.de` und `billing@x.de` als gleiches Ziel
|
||||||
|
* erkannt werden – auch im Vergleich zur Stamm-E-Mail des Kunden.
|
||||||
|
*
|
||||||
|
* Pentest 71.4: DB-Update wird bei Provider-Sync-Fehler zurückgerollt,
|
||||||
|
* damit Plesk und DB nicht auseinanderlaufen.
|
||||||
|
*
|
||||||
|
* Pentest 81.1: Self-Forward wird hart abgelehnt – würde sonst am
|
||||||
|
* Provider einen Mail-Loop erzeugen (Stressfrei-Adresse leitet auf
|
||||||
|
* sich selbst um → unendliche Weiterleitung).
|
||||||
|
*/
|
||||||
|
export async function setAdditionalForwards(
|
||||||
|
id: number,
|
||||||
|
emails: string[],
|
||||||
|
): Promise<{ success: boolean; forwardTargets?: string[]; error?: string }> {
|
||||||
|
// Kunden-Stamm-Mail + eigene Email holen für Dedup gegen die fest
|
||||||
|
// gesetzten Ziele bzw. die Stressfrei-Adresse selbst.
|
||||||
|
const meta = await prisma.stressfreiEmail.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: {
|
||||||
|
email: true,
|
||||||
|
additionalForwardingEmails: true,
|
||||||
|
customer: { select: { email: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!meta) {
|
||||||
|
throw new ApiError(404, 'StressfreiEmail nicht gefunden');
|
||||||
|
}
|
||||||
|
const previousRaw = meta.additionalForwardingEmails;
|
||||||
|
const customerEmailKey = meta.customer?.email
|
||||||
|
? canonicalEmailKey(meta.customer.email)
|
||||||
|
: null;
|
||||||
|
const selfKey = canonicalEmailKey(meta.email);
|
||||||
|
|
||||||
|
// Input normalisieren + Duplikate raus.
|
||||||
|
const seen = new Set<string>();
|
||||||
|
if (customerEmailKey) seen.add(customerEmailKey);
|
||||||
|
const cleaned: string[] = [];
|
||||||
|
for (const raw of emails) {
|
||||||
|
const ok = assertValidForwardingEmail(raw);
|
||||||
|
const key = canonicalEmailKey(ok);
|
||||||
|
// 81.1: Eintrag, der auf die Adresse selbst zeigt, würde einen
|
||||||
|
// Mail-Loop am Provider erzeugen. Hart ablehnen mit klarer
|
||||||
|
// Fehlermeldung, statt silent zu droppen – der User soll merken,
|
||||||
|
// dass sein Eintrag bewusst nicht akzeptiert wurde.
|
||||||
|
if (key === selfKey) {
|
||||||
|
throw new ApiError(
|
||||||
|
400,
|
||||||
|
`"${ok}" zeigt auf die Adresse selbst – Mail-Loop. Bitte eine andere Weiterleitungsadresse wählen.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
|
cleaned.push(ok);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextRaw = serializeAdditionalForwards(cleaned);
|
||||||
|
await prisma.stressfreiEmail.update({
|
||||||
|
where: { id },
|
||||||
|
data: { additionalForwardingEmails: nextRaw },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Provider unmittelbar nachziehen, sonst läuft das Plesk-Mail-Konto
|
||||||
|
// mit der alten Liste weiter. autoImport=false, weil unsere DB-Liste
|
||||||
|
// hier die explizite User-Intent ist – kein Plesk-Member-Auto-Pull,
|
||||||
|
// sonst landen gerade entfernte Adressen zurück in der Liste.
|
||||||
|
const syncResult = await syncForwardingForEmail(id, { autoImportPleskMembers: false });
|
||||||
|
|
||||||
|
// 71.4: Rollback wenn Plesk den Sync abgelehnt hat. DB darf nicht
|
||||||
|
// den optimistischen Stand zeigen, wenn der Provider noch auf dem
|
||||||
|
// alten Stand ist.
|
||||||
|
if (!syncResult.success && previousRaw !== nextRaw) {
|
||||||
|
await prisma.stressfreiEmail.update({
|
||||||
|
where: { id },
|
||||||
|
data: { additionalForwardingEmails: previousRaw },
|
||||||
|
}).catch((rollbackErr) => {
|
||||||
|
console.error(
|
||||||
|
'[setAdditionalForwards] Rollback nach Provider-Fail fehlgeschlagen:',
|
||||||
|
rollbackErr,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return syncResult;
|
||||||
|
}
|
||||||
|
|
||||||
// Mailbox nachträglich aktivieren (für existierende E-Mail-Weiterleitung)
|
// Mailbox nachträglich aktivieren (für existierende E-Mail-Weiterleitung)
|
||||||
export async function enableMailbox(id: number): Promise<{ success: boolean; error?: string }> {
|
export async function enableMailbox(id: number): Promise<{ success: boolean; error?: string }> {
|
||||||
const stressfreiEmail = await prisma.stressfreiEmail.findUnique({
|
const stressfreiEmail = await prisma.stressfreiEmail.findUnique({
|
||||||
@@ -298,6 +508,7 @@ export async function getDecryptedPassword(id: number): Promise<string | null> {
|
|||||||
// Einträge, bei denen das Flag nie gesetzt wurde, werden so geheilt).
|
// Einträge, bei denen das Flag nie gesetzt wurde, werden so geheilt).
|
||||||
export async function syncForwardingForEmail(
|
export async function syncForwardingForEmail(
|
||||||
id: number,
|
id: number,
|
||||||
|
options: { autoImportPleskMembers?: boolean } = {},
|
||||||
): Promise<{
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
forwardTargets?: string[];
|
forwardTargets?: string[];
|
||||||
@@ -305,6 +516,14 @@ export async function syncForwardingForEmail(
|
|||||||
passwordReset?: boolean;
|
passwordReset?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
|
// Auto-Import übernimmt unbekannte Plesk-Members in unsere DB. Macht
|
||||||
|
// beim Sync-Button Sinn (Bestands-Migration), aber NICHT beim
|
||||||
|
// User-getriggerten Add/Remove – dort ist die DB-Liste die Wahrheit.
|
||||||
|
// Sonst kreisen entfernte Adressen zurück in die Liste:
|
||||||
|
// 1. User entfernt c → DB=[a,b], Plesk=[a,b,c]
|
||||||
|
// 2. Auto-Import: "c ist in Plesk aber nicht in DB → in DB schreiben"
|
||||||
|
// 3. → DB=[a,b,c], Diff sagt nichts zu löschen, Plesk bleibt [a,b,c].
|
||||||
|
const autoImport = options.autoImportPleskMembers ?? true;
|
||||||
const stressfreiEmail = await prisma.stressfreiEmail.findUnique({
|
const stressfreiEmail = await prisma.stressfreiEmail.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
select: {
|
select: {
|
||||||
@@ -313,6 +532,7 @@ export async function syncForwardingForEmail(
|
|||||||
isProvisioned: true,
|
isProvisioned: true,
|
||||||
hasMailbox: true,
|
hasMailbox: true,
|
||||||
emailPasswordEncrypted: true,
|
emailPasswordEncrypted: true,
|
||||||
|
additionalForwardingEmails: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -333,10 +553,90 @@ export async function syncForwardingForEmail(
|
|||||||
if (config?.defaultForwardEmail) {
|
if (config?.defaultForwardEmail) {
|
||||||
forwardTargets.push(config.defaultForwardEmail);
|
forwardTargets.push(config.defaultForwardEmail);
|
||||||
}
|
}
|
||||||
|
// Zusätzliche Weiterleitungsziele (vom User im Modal gepflegt). Duplikate
|
||||||
|
// gegen die Stamm-Mail oder den Default werden hier weggefiltert, damit
|
||||||
|
// Plesk nicht mit Wiederholungen die Liste aufbläht. Pentest 71.2:
|
||||||
|
// Vergleich über `canonicalEmailKey`, damit Plus-Tags nicht doppelt
|
||||||
|
// zustellen.
|
||||||
|
const seenKeys = new Set(forwardTargets.map(canonicalEmailKey));
|
||||||
|
for (const extra of parseAdditionalForwards(stressfreiEmail.additionalForwardingEmails)) {
|
||||||
|
const key = canonicalEmailKey(extra);
|
||||||
|
if (!seenKeys.has(key)) {
|
||||||
|
seenKeys.add(key);
|
||||||
|
forwardTargets.push(extra);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const localPart = stressfreiEmail.email.split('@')[0];
|
const localPart = stressfreiEmail.email.split('@')[0];
|
||||||
|
|
||||||
// 1) Forwards neu setzen.
|
// 0) Auto-Migration: Plesk hat zwei Verteil-Mechanismen (Mailgroup +
|
||||||
|
// Forwarding). Alt-Anlagen liefen oft via Mailgroup – unser Sync
|
||||||
|
// schreibt aber nur in die Forwarding-Liste, daher landeten neue
|
||||||
|
// Adressen nirgendwo. Hier holen wir die aktuellen Mailgroup-Members
|
||||||
|
// ab und ziehen alle, die wir nicht schon kennen, in unsere
|
||||||
|
// additionalForwardingEmails-Liste rein. Der nachfolgende Plesk-Call
|
||||||
|
// deaktiviert dann die Mailgroup und schreibt die volle Liste als
|
||||||
|
// Forwarding. Verlustfrei – kein Empfänger fällt raus.
|
||||||
|
// Pentest 83.2: Self-Forward auch beim Import blocken. Die
|
||||||
|
// Stressfrei-Adresse selbst darf nicht aus Plesk in unsere DB
|
||||||
|
// landen – sonst läuft sie nach dem Mailgroup→Forwarding-Umschalten
|
||||||
|
// als Forwarding-Target auf sich selbst (Mail-Loop).
|
||||||
|
seenKeys.add(canonicalEmailKey(stressfreiEmail.email));
|
||||||
|
|
||||||
|
if (autoImport) {
|
||||||
|
try {
|
||||||
|
const pleskState = await checkEmailExists(localPart);
|
||||||
|
const existingMembers = [
|
||||||
|
...(pleskState.mailgroupMembers ?? []),
|
||||||
|
...(pleskState.forwardingTargets ?? []),
|
||||||
|
];
|
||||||
|
const newImports: string[] = [];
|
||||||
|
for (const member of existingMembers) {
|
||||||
|
// Pentest 83.1: importierte Adressen aus Plesk müssen denselben
|
||||||
|
// Filter passieren wie User-Eingaben (TLD-Blocklist, Format).
|
||||||
|
// Sonst rutschen reservierte TLDs wie `.internal` ohne Check
|
||||||
|
// in unsere DB, falls ein Plesk-Admin sie dort manuell gepflegt
|
||||||
|
// hat. Ungültige werden silent gedroppt – Log informiert.
|
||||||
|
let validated: string;
|
||||||
|
try {
|
||||||
|
validated = assertValidForwardingEmail(member);
|
||||||
|
} catch (validationErr) {
|
||||||
|
const reason = validationErr instanceof Error ? validationErr.message : 'unbekannt';
|
||||||
|
console.debug(
|
||||||
|
`[syncForwardingForEmail] Plesk-Member "${member}" verworfen: ${reason}`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = canonicalEmailKey(validated);
|
||||||
|
if (!seenKeys.has(key)) {
|
||||||
|
seenKeys.add(key);
|
||||||
|
forwardTargets.push(validated);
|
||||||
|
newImports.push(validated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (newImports.length > 0) {
|
||||||
|
const mergedAdditional = [
|
||||||
|
...parseAdditionalForwards(stressfreiEmail.additionalForwardingEmails),
|
||||||
|
...newImports,
|
||||||
|
];
|
||||||
|
await prisma.stressfreiEmail.update({
|
||||||
|
where: { id },
|
||||||
|
data: { additionalForwardingEmails: serializeAdditionalForwards(mergedAdditional) },
|
||||||
|
});
|
||||||
|
// Pentest 83.3: PII-Logs auf debug-Level statt log-Level.
|
||||||
|
console.debug(
|
||||||
|
`[syncForwardingForEmail] Importiert aus Plesk-Mailgroup für ${stressfreiEmail.email}:`,
|
||||||
|
newImports,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (importErr) {
|
||||||
|
// Nicht hart fehlschlagen – im schlimmsten Fall fehlen ein paar
|
||||||
|
// alte Empfänger, aber der eigentliche Sync soll trotzdem laufen.
|
||||||
|
console.error('[syncForwardingForEmail] Mailgroup-Import fehlgeschlagen:', importErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Forwards neu setzen (deaktiviert intern Mailgroup).
|
||||||
const forwardResult = await setEmailForwardTargets(localPart, forwardTargets);
|
const forwardResult = await setEmailForwardTargets(localPart, forwardTargets);
|
||||||
if (!forwardResult.success) {
|
if (!forwardResult.success) {
|
||||||
// Wenn Plesk meldet „nicht gefunden", liefern wir eine sprechende Meldung
|
// Wenn Plesk meldet „nicht gefunden", liefern wir eine sprechende Meldung
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Erlaubt Service-/Helper-Funktionen, einen Fehler mit explizitem HTTP-
|
||||||
|
* Status nach oben zu reichen. Controller können in ihrem `catch` per
|
||||||
|
* `instanceof ApiError` den Status auslesen statt pauschal 500 zu liefern.
|
||||||
|
*
|
||||||
|
* Pentest 64.1 (LOW, 2026-06-02): Race-Lock (assertNoRecentDuplicate-
|
||||||
|
* Document) warf einen generischen Error → catch hat 500 zurückgegeben,
|
||||||
|
* obwohl die Fehlermeldung "Dokument vor wenigen Sekunden bereits
|
||||||
|
* angelegt" eindeutig eine 400-Class-Situation ist.
|
||||||
|
*/
|
||||||
|
export class ApiError extends Error {
|
||||||
|
readonly statusCode: number;
|
||||||
|
constructor(statusCode: number, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@
|
|||||||
* Verschlüsselungen oder Reset-Tokens versehentlich durch die API leaken.
|
* Verschlüsselungen oder Reset-Tokens versehentlich durch die API leaken.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { ApiError } from './apiError.js';
|
||||||
|
|
||||||
// Felder die NIE in einer API-Response an den Client gehen dürfen
|
// Felder die NIE in einer API-Response an den Client gehen dürfen
|
||||||
const SENSITIVE_CUSTOMER_FIELDS = [
|
const SENSITIVE_CUSTOMER_FIELDS = [
|
||||||
'portalPasswordHash',
|
'portalPasswordHash',
|
||||||
@@ -15,6 +17,10 @@ const SENSITIVE_CUSTOMER_FIELDS = [
|
|||||||
// braucht, holt ihn über GET /gdpr/customer/:id/consent-status (eigener
|
// braucht, holt ihn über GET /gdpr/customer/:id/consent-status (eigener
|
||||||
// Endpoint mit canAccessCustomer-Check). Pentest Runde 5 (2026-05-16).
|
// Endpoint mit canAccessCustomer-Check). Pentest Runde 5 (2026-05-16).
|
||||||
'consentHash',
|
'consentHash',
|
||||||
|
// Pentest 57.7 (2026-06-01): TTL des Public-Consent-Links – kein Leak
|
||||||
|
// an die Standard-Customer-Response, da Existenz/Ablaufzeit Info über
|
||||||
|
// den Workflow gibt.
|
||||||
|
'consentHashExpiresAt',
|
||||||
// Session-/OTP-State – Pentest Runde 15 (2026-05-18, 20.4 HOCH): zeigt
|
// Session-/OTP-State – Pentest Runde 15 (2026-05-18, 20.4 HOCH): zeigt
|
||||||
// einem externen Beobachter, ob ein Kunde gerade im OTP-Flow ist und
|
// einem externen Beobachter, ob ein Kunde gerade im OTP-Flow ist und
|
||||||
// wann zuletzt seine Tokens invalidiert wurden. Reiner Info-Leak ohne
|
// wann zuletzt seine Tokens invalidiert wurden. Reiner Info-Leak ohne
|
||||||
@@ -59,6 +65,50 @@ const PORTAL_HIDDEN_CONTRACT_FIELDS = [
|
|||||||
'nextReviewDate', // Snooze-Workflow ist internes Cockpit-Feature
|
'nextReviewDate', // Snooze-Workflow ist internes Cockpit-Feature
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
// User-eingabe String-Felder am Contract, die in der UI dargestellt werden.
|
||||||
|
// Werden beim Read über stripHtml geschickt, damit Alt-Daten mit rohen
|
||||||
|
// XSS-Payloads (vor Einführung von sanitizeContractBody) nicht mehr als
|
||||||
|
// `<script>alert(...)</script>` in der Liste auftauchen. Neue Daten sind
|
||||||
|
// schon beim Write gestrippt, aber doppelt hält besser.
|
||||||
|
const CONTRACT_DISPLAY_STRING_FIELDS = [
|
||||||
|
'providerName',
|
||||||
|
'tariffName',
|
||||||
|
'customerNumberAtProvider',
|
||||||
|
'contractNumberAtProvider',
|
||||||
|
'orderNumberAtSalesPlatform',
|
||||||
|
'customerNumberAtSalesPlatform',
|
||||||
|
'contractNumberAtSalesPlatform',
|
||||||
|
'portalUsername',
|
||||||
|
'previousProviderName',
|
||||||
|
'previousCustomerNumber',
|
||||||
|
'previousContractNumber',
|
||||||
|
'notes',
|
||||||
|
// Preisfelder sind im Schema `String?` (freitextlich, nicht numerisch),
|
||||||
|
// damit Tarifangaben wie "0,28 €/kWh" oder "27,90 € + 10 € Bonus"
|
||||||
|
// möglich sind. Pentest 2026-05-30 (MEDIUM, 42.5): rohe HTML-Payloads
|
||||||
|
// in den drei Feldern überlebten den Write-Strip nicht und kommen
|
||||||
|
// beim Read 1:1 wieder raus.
|
||||||
|
'priceFirst12Months',
|
||||||
|
'priceFrom13Months',
|
||||||
|
'priceAfter24Months',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// User-eingabe String-Felder am Customer für dieselbe Read-Time-Defensive.
|
||||||
|
const CUSTOMER_DISPLAY_STRING_FIELDS = [
|
||||||
|
'firstName',
|
||||||
|
'lastName',
|
||||||
|
'companyName',
|
||||||
|
'salutation',
|
||||||
|
'email',
|
||||||
|
'phone',
|
||||||
|
'mobile',
|
||||||
|
'portalEmail',
|
||||||
|
'portalUsername',
|
||||||
|
'taxNumber',
|
||||||
|
'commercialRegisterNumber',
|
||||||
|
'notes',
|
||||||
|
] as const;
|
||||||
|
|
||||||
const SENSITIVE_USER_FIELDS = [
|
const SENSITIVE_USER_FIELDS = [
|
||||||
'password',
|
'password',
|
||||||
'passwordResetToken',
|
'passwordResetToken',
|
||||||
@@ -79,6 +129,11 @@ export function sanitizeCustomer<T extends Record<string, unknown>>(customer: T
|
|||||||
for (const field of SENSITIVE_CUSTOMER_FIELDS) {
|
for (const field of SENSITIVE_CUSTOMER_FIELDS) {
|
||||||
delete copy[field];
|
delete copy[field];
|
||||||
}
|
}
|
||||||
|
for (const field of CUSTOMER_DISPLAY_STRING_FIELDS) {
|
||||||
|
if (typeof copy[field] === 'string') {
|
||||||
|
copy[field] = stripForDisplay(copy[field]);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (Array.isArray(copy.contracts)) {
|
if (Array.isArray(copy.contracts)) {
|
||||||
copy.contracts = (copy.contracts as Record<string, unknown>[]).map((c) => sanitizeContract(c));
|
copy.contracts = (copy.contracts as Record<string, unknown>[]).map((c) => sanitizeContract(c));
|
||||||
}
|
}
|
||||||
@@ -120,12 +175,380 @@ export function sanitizeCustomers<T extends Record<string, unknown>>(customers:
|
|||||||
* Provider-Passwort (nur über den dedizierten /password-Endpoint mit
|
* Provider-Passwort (nur über den dedizierten /password-Endpoint mit
|
||||||
* Audit-Log abrufbar) und sanitisiert das embedded customer.
|
* Audit-Log abrufbar) und sanitisiert das embedded customer.
|
||||||
*/
|
*/
|
||||||
|
// Sanitisierung für freitextliche User-Notizen (ContractDocument.notes,
|
||||||
|
// Invoice.notes, MeterReading.notes etc.). Pentest 55.2 (MEDIUM,
|
||||||
|
// 2026-06-01): 50 000-Zeichen-Inputs mit XSS-Payload und CRLF gingen
|
||||||
|
// roh in die DB. Selbst wenn React escapt, sind sie ein Header-Injection-
|
||||||
|
// und Speicher-Risiko, wenn die Notiz mal in Mail/PDF/CSV-Export fließt.
|
||||||
|
// - Tags + gefährliche Schemata via stripHtml
|
||||||
|
// - CRLF auf Newline normalisieren, keine Carriage-Returns persistieren
|
||||||
|
// - Length-Cap default 2000 Zeichen (genug für sinnvolle Anmerkungen)
|
||||||
|
// Pentest 58.1 (MEDIUM, 2026-06-01): documentType wurde nur durch
|
||||||
|
// stripHtml geschickt, aber NICHT gegen eine Whitelist geprüft. Damit
|
||||||
|
// landeten beliebige Strings (`NICHT_ERLAUBT`, `DROP TABLE …`,
|
||||||
|
// Tippfehler-Werte aus alten UI-Versionen) als documentType in der
|
||||||
|
// ContractDocument-Tabelle und brachen Frontend-Filter, Auto-Activation
|
||||||
|
// (Lieferbestätigung-Trigger) und Reports.
|
||||||
|
//
|
||||||
|
// Whitelist spiegelt die Konstante CONTRACT_DOCUMENT_TYPES aus
|
||||||
|
// SaveAttachmentModal / SaveEmailAsPdfModal im Frontend. Beide
|
||||||
|
// Listen MÜSSEN synchron gehalten werden – idealerweise später
|
||||||
|
// in eine geteilte Konfiguration gehoben.
|
||||||
|
export const ALLOWED_CONTRACT_DOCUMENT_TYPES = [
|
||||||
|
'Auftragsformular',
|
||||||
|
'Auftragsbestätigung',
|
||||||
|
'Lieferbestätigung',
|
||||||
|
'Vertragsunterlagen',
|
||||||
|
'Vollmacht',
|
||||||
|
'Widerrufsbelehrung',
|
||||||
|
'Preisblatt',
|
||||||
|
'Sonstiges',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const CONTRACT_DOCUMENT_TYPE_SET: Set<string> = new Set(ALLOWED_CONTRACT_DOCUMENT_TYPES);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validiert + normalisiert einen documentType-Wert. Wirft einen Fehler
|
||||||
|
* mit klarer Liste, wenn der Wert nicht in der Whitelist steht (der
|
||||||
|
* aufrufende Controller mappt das auf 400). Trimmt Whitespace und macht
|
||||||
|
* den Vergleich case-insensitive – damit `"lieferbestätigung"` aus
|
||||||
|
* Drittsystemen sauber matched, aber `"Lieferbestätigung_DROP"` rausfliegt.
|
||||||
|
*/
|
||||||
|
export function validateContractDocumentType(raw: unknown): string {
|
||||||
|
if (typeof raw !== 'string') {
|
||||||
|
throw new Error(`documentType ist erforderlich. Erlaubt: ${ALLOWED_CONTRACT_DOCUMENT_TYPES.join(', ')}`);
|
||||||
|
}
|
||||||
|
const cleaned = stripHtml(raw) as string;
|
||||||
|
const trimmed = cleaned.trim();
|
||||||
|
if (trimmed === '') {
|
||||||
|
throw new Error(`documentType ist erforderlich. Erlaubt: ${ALLOWED_CONTRACT_DOCUMENT_TYPES.join(', ')}`);
|
||||||
|
}
|
||||||
|
const canonical = ALLOWED_CONTRACT_DOCUMENT_TYPES.find((t) => t.toLowerCase() === trimmed.toLowerCase());
|
||||||
|
if (!canonical) {
|
||||||
|
throw new Error(`Ungültiger documentType '${trimmed}'. Erlaubt: ${ALLOWED_CONTRACT_DOCUMENT_TYPES.join(', ')}`);
|
||||||
|
}
|
||||||
|
return canonical;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pentest 26.7 LOW (defense-in-depth, 2026-06-02): documentPath wird
|
||||||
|
// (außer beim Upload-Endpoint) NIE direkt aus User-Input übernommen.
|
||||||
|
// Falls doch jemand auf die Idee kommt, das Feld irgendwo zu mappen,
|
||||||
|
// fangen wir hier Path-Traversal / Javascript-URIs / HTML ab.
|
||||||
|
// Spiegelt isValidDocumentPath aus prisma/cleanup-xss-and-mass-assignment.ts
|
||||||
|
// 1:1 – Single Source of Truth für Lese- UND Schreibpfad.
|
||||||
|
export function isValidDocumentPath(v: string | null | undefined): boolean {
|
||||||
|
if (!v) return true; // null/leer ist OK – Feld bleibt einfach unbesetzt
|
||||||
|
if (typeof v !== 'string') return false;
|
||||||
|
if (v.includes('..')) return false;
|
||||||
|
if (/(?:javascript|data|vbscript)\s*:/i.test(v)) return false;
|
||||||
|
if (/<[a-z!/]/i.test(v)) return false;
|
||||||
|
return /^\/?uploads\/[A-Za-z0-9._\-/]+$/.test(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertValidDocumentPath(v: string | null | undefined, fieldLabel = 'documentPath'): void {
|
||||||
|
if (!isValidDocumentPath(v)) {
|
||||||
|
throw new Error(`${fieldLabel} ist kein gültiger Upload-Pfad (erlaubt: /uploads/<safe>).`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pentest 68.1 (LOW, 2026-06-03): PDFs mit JavaScript, /Launch (externes
|
||||||
|
// Programm), /EmbeddedFile (eingebettete Executables) oder /RichMedia
|
||||||
|
// (Flash) kamen durch den reinen Magic-Byte-Check (%PDF-) und wurden
|
||||||
|
// inline ausgeliefert. Browser-PDF-Viewer (PDFium/PDF.js) führen kein JS
|
||||||
|
// aus, aber sobald jemand die PDF in Adobe Acrobat öffnet, läuft sie.
|
||||||
|
// → Wir blocken das schon beim Upload.
|
||||||
|
//
|
||||||
|
// PDF-Name-Objekte sind laut PDF 32000-1:2008 §7.3.5 case-sensitive, also
|
||||||
|
// kein /i auf den Patterns. Whitespace nach `/` ist im Standard zwar
|
||||||
|
// erlaubt, in real-world Exploits aber praktisch nie zu sehen – wir
|
||||||
|
// bleiben hier pragmatisch.
|
||||||
|
//
|
||||||
|
// Hinweis: erkannt wird nur, was im Klartext im PDF-Body steht.
|
||||||
|
// Komprimierte oder verschlüsselte Streams entgehen dem String-Scan.
|
||||||
|
// Für unser Bedrohungsmodell (kompromittierter Staff-Account, LOW) reicht
|
||||||
|
// das – ein vollständiger PDF-Parser wäre Overkill.
|
||||||
|
const PDF_DANGER_PATTERNS: { pattern: RegExp; label: string }[] = [
|
||||||
|
{ pattern: /\/JavaScript\b/, label: 'JavaScript-Action' },
|
||||||
|
{ pattern: /\/JS\b/, label: 'JavaScript-Action' },
|
||||||
|
{ pattern: /\/Launch\b/, label: 'Launch-Action (externes Programm)' },
|
||||||
|
{ pattern: /\/EmbeddedFile\b/, label: 'eingebettete Datei' },
|
||||||
|
{ pattern: /\/RichMedia\b/, label: 'RichMedia-Inhalt (Flash)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function assertSafePdf(buf: Buffer): void {
|
||||||
|
if (buf.length < 5 || buf.subarray(0, 5).toString('latin1') !== '%PDF-') {
|
||||||
|
return; // keine PDF → andere Validatoren zuständig
|
||||||
|
}
|
||||||
|
// Stream-Inhalte (Bilder/Fonts/Komprimiertes) aus dem Scan rausnehmen.
|
||||||
|
// Jpeg-Bytes können zufällig "/JavaScript" enthalten → false-positive
|
||||||
|
// bei jsPDF-generierten PDFs mit eingebetteten Fotos (stage-Bug
|
||||||
|
// 2026-06-03). Echte aktive PDF-Inhalte stehen IMMER im PDF-
|
||||||
|
// Object-Stream (außerhalb von `stream..endstream`-Blöcken).
|
||||||
|
const scanTarget = buf.toString('latin1').replace(/stream\s[\s\S]*?endstream/g, '');
|
||||||
|
for (const { pattern, label } of PDF_DANGER_PATTERNS) {
|
||||||
|
if (pattern.test(scanTarget)) {
|
||||||
|
throw new ApiError(
|
||||||
|
415,
|
||||||
|
`PDF enthält nicht erlaubte aktive Inhalte (${label}). Bitte ohne JavaScript / Auto-Actions / eingebettete Dateien hochladen.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pentest 51.3 + 60.3 (MEDIUM, 2026-06-01): Telefon-/Vorwahl-Felder
|
||||||
|
// dürfen NIE CRLF oder andere Control-Chars enthalten – sonst sind sie
|
||||||
|
// ein Header-Injection-Vektor (Mail, HTTP), wenn der Wert mal in einen
|
||||||
|
// Header fließt (PDF/Mail-Templates, CSV-Export). Whitespace bewusst auf
|
||||||
|
// literales Space beschränkt, NICHT `\s` – das matched sonst `\r\n\t`.
|
||||||
|
// Allowed: Ziffern, Plus, Minus, Slash, Klammern, Punkt, Space. Bis 40 Zeichen.
|
||||||
|
//
|
||||||
|
// 51.3 deckte nur Contract-Phone-Felder ab; 60.3: `Customer.phone` /
|
||||||
|
// `Customer.mobile` waren immer noch offen, weil pickCustomerUpdate nur
|
||||||
|
// stripHtml laufen ließ – das filtert keine Control-Chars.
|
||||||
|
const PHONE_FIELD_ALLOWED = /^[0-9+\-/(). ]{0,40}$/;
|
||||||
|
export function sanitizePhoneField(raw: unknown, fieldLabel: string): string | undefined {
|
||||||
|
if (raw == null) return undefined;
|
||||||
|
const trimmed = String(raw).trim();
|
||||||
|
if (trimmed === '') return undefined;
|
||||||
|
if (!PHONE_FIELD_ALLOWED.test(trimmed)) {
|
||||||
|
throw new Error(`${fieldLabel} enthält unzulässige Zeichen (erlaubt sind Ziffern, +, Leerzeichen, -, /, Klammern).`);
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pentest 62.7 (LOW, 2026-06-02): deliveryDate/confirmationDate-Felder
|
||||||
|
// liefen ungeprüft in maybeActivateOnDeliveryConfirmation. XSS-Payloads
|
||||||
|
// gingen mit 200 durch, weil das ungültige Datum nur silent als null
|
||||||
|
// behandelt wurde. Impact gering, aber API-Hygiene: ungültige Eingabe
|
||||||
|
// soll 400 zurückgeben, nicht 200.
|
||||||
|
//
|
||||||
|
// Akzeptiert: ISO-8601-Datum (YYYY-MM-DD) und Datum+Zeit (mit oder ohne
|
||||||
|
// Zeitzone). Whitespace wird getrimmt. null / leerer String / undefined
|
||||||
|
// sind OK – der Aufrufer behandelt das als "Datum nicht gesetzt".
|
||||||
|
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/;
|
||||||
|
export function validateOptionalIsoDate(raw: unknown, fieldLabel: string): string | null {
|
||||||
|
if (raw == null) return null;
|
||||||
|
if (typeof raw !== 'string') {
|
||||||
|
throw new Error(`${fieldLabel} muss ein Datums-String (YYYY-MM-DD) sein.`);
|
||||||
|
}
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (trimmed === '') return null;
|
||||||
|
if (!ISO_DATE_REGEX.test(trimmed)) {
|
||||||
|
throw new Error(`${fieldLabel} muss ISO-8601-Format haben (YYYY-MM-DD oder YYYY-MM-DDTHH:MM:SS).`);
|
||||||
|
}
|
||||||
|
const parsed = new Date(trimmed);
|
||||||
|
if (isNaN(parsed.getTime())) {
|
||||||
|
throw new Error(`${fieldLabel} ist kein gültiges Datum.`);
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pentest 86.1 + 86.2 (LOW, 2026-06-19): Kunden-/Vertrags-/Auftrags-
|
||||||
|
// Nummern bei Anbieter und Vertriebsplattform hatten keine Längen- oder
|
||||||
|
// Zeichen-Validierung. >1000 Zeichen-Strings warfen einen generischen
|
||||||
|
// 500er (DB-Overflow VARCHAR(191)) statt eines 400ers. Außerdem
|
||||||
|
// überlebten Attribut-Injection-Payloads wie `foo" onerror="alert(1)`
|
||||||
|
// die stripHtml-Defense (kein umschließender Tag → kein Match), die
|
||||||
|
// in PDF-/Mail-Export potentiell aktiv werden könnten.
|
||||||
|
//
|
||||||
|
// Whitelist orientiert sich am Vorschlag des Pentesters
|
||||||
|
// `^[\w\-\/\s\.]*$` – Whitespace ist hier bewusst NUR ein literales
|
||||||
|
// Space, NICHT `\s` (kein CRLF/Tab → kein Header-Injection-Vektor
|
||||||
|
// in CSV-/Mail-Exporten). Max 100 Zeichen reicht für jede reale
|
||||||
|
// Kunden-/Vertrags-Nummer und bleibt deutlich unter dem VARCHAR(191)-
|
||||||
|
// Limit der DB-Spalte.
|
||||||
|
const CONTRACT_IDENTIFIER_FIELDS: ReadonlySet<string> = new Set([
|
||||||
|
'customerNumberAtProvider',
|
||||||
|
'contractNumberAtProvider',
|
||||||
|
'orderNumberAtSalesPlatform',
|
||||||
|
'customerNumberAtSalesPlatform',
|
||||||
|
'contractNumberAtSalesPlatform',
|
||||||
|
]);
|
||||||
|
const CONTRACT_IDENTIFIER_ALLOWED = /^[A-Za-z0-9_\-/. ]{0,100}$/;
|
||||||
|
const CONTRACT_IDENTIFIER_MAX_LEN = 100;
|
||||||
|
|
||||||
|
export function isContractIdentifierField(key: string): boolean {
|
||||||
|
return CONTRACT_IDENTIFIER_FIELDS.has(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateContractIdentifier(
|
||||||
|
raw: unknown,
|
||||||
|
fieldLabel: string,
|
||||||
|
): string | null {
|
||||||
|
if (raw == null) return null;
|
||||||
|
if (typeof raw !== 'string') {
|
||||||
|
throw new ApiError(400, `${fieldLabel} muss ein Text sein.`);
|
||||||
|
}
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (trimmed === '') return null;
|
||||||
|
if (trimmed.length > CONTRACT_IDENTIFIER_MAX_LEN) {
|
||||||
|
throw new ApiError(
|
||||||
|
400,
|
||||||
|
`${fieldLabel} darf maximal ${CONTRACT_IDENTIFIER_MAX_LEN} Zeichen lang sein.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!CONTRACT_IDENTIFIER_ALLOWED.test(trimmed)) {
|
||||||
|
throw new ApiError(
|
||||||
|
400,
|
||||||
|
`${fieldLabel} enthält unzulässige Zeichen (erlaubt: Buchstaben, Ziffern, Punkt, Bindestrich, Schrägstrich, Unterstrich, Leerzeichen).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pentest 95.1/95.3/95.4 (MEDIUM/LOW, 2026-06-21): Manuelles
|
||||||
|
// `portalUsername` am Vertrag hatte gar keine Validierung. Drei
|
||||||
|
// nachweisbare Effekte:
|
||||||
|
// - `foo\r\nBcc:evil@x.de` (CRLF) verbatim gespeichert →
|
||||||
|
// Header-Injection-Vektor sobald der Wert in Mail-Templates
|
||||||
|
// oder PDF-Footers landet.
|
||||||
|
// - `<script>alert(1)</script>@x.de` lief durch stripHtml →
|
||||||
|
// stille Mutation (R87.1/R89.2-Pattern auf neuem Feld).
|
||||||
|
// - >190 Zeichen → VARCHAR-Overflow → generischer 500 statt 400.
|
||||||
|
//
|
||||||
|
// Bewusst NICHT übernommen wurde R95.2 (Email-Format-Pflicht):
|
||||||
|
// `portalUsername` ist im Manual-Modus nicht zwingend eine
|
||||||
|
// E-Mail. Vodafone, 1&1, EWE und etliche Stadtwerke nutzen
|
||||||
|
// Kundennummern, Pseudonyme oder Customer-IDs als Portal-Login.
|
||||||
|
// Eine `email().regex()`-Pflicht würde legitime Logins ablehnen.
|
||||||
|
// Der Stressfrei-Modus hängt eh an einer schon validierten
|
||||||
|
// Email-Stammdate (assertValidForwardingEmail).
|
||||||
|
//
|
||||||
|
// Allowed: Alphanumerisch + `_`, `-`, `.`, `/`, `@`, `+`, Space.
|
||||||
|
// Damit sind Vodafone-Kunden-IDs (`12345678`), Pseudonyme
|
||||||
|
// (`max.mustermann`), Plus-Tag-Emails (`m+tag@example.com`)
|
||||||
|
// und gemischte Formen abgedeckt. Strukturell sind CRLF, Tab,
|
||||||
|
// alle Control-Chars, Tags und Quotes raus → R95.1+R95.3 ohne
|
||||||
|
// extra Check. Max 100 Zeichen << VARCHAR(191) → R95.4.
|
||||||
|
const PORTAL_USERNAME_ALLOWED = /^[A-Za-z0-9_\-/.@+ ]{0,100}$/;
|
||||||
|
const PORTAL_USERNAME_MAX_LEN = 100;
|
||||||
|
|
||||||
|
export function validatePortalUsername(
|
||||||
|
raw: unknown,
|
||||||
|
fieldLabel = 'portalUsername',
|
||||||
|
): string | null {
|
||||||
|
if (raw == null) return null;
|
||||||
|
if (typeof raw !== 'string') {
|
||||||
|
throw new ApiError(400, `${fieldLabel} muss ein Text sein.`);
|
||||||
|
}
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (trimmed === '') return null;
|
||||||
|
if (trimmed.length > PORTAL_USERNAME_MAX_LEN) {
|
||||||
|
throw new ApiError(
|
||||||
|
400,
|
||||||
|
`${fieldLabel} darf maximal ${PORTAL_USERNAME_MAX_LEN} Zeichen lang sein.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!PORTAL_USERNAME_ALLOWED.test(trimmed)) {
|
||||||
|
throw new ApiError(
|
||||||
|
400,
|
||||||
|
`${fieldLabel} enthält unzulässige Zeichen (erlaubt: Buchstaben, Ziffern, Punkt, Bindestrich, Schrägstrich, Unterstrich, @, +, Leerzeichen).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pentest 89.1 + 89.2 (MEDIUM/LOW, 2026-06-21): Postadressen am
|
||||||
|
// Provider (`contactAddress`, `cancellationAddress`). sanitizeNotes
|
||||||
|
// hat das Length-Cap silent durchgeschoben (slice statt Error) und
|
||||||
|
// stripHtml lief vor dem Length-Check – derselbe Fehler wie R87:
|
||||||
|
// `<script>…</script>` reduziert auf leeren String → null in der
|
||||||
|
// DB → vorheriger Wert ohne Fehlermeldung überschrieben.
|
||||||
|
//
|
||||||
|
// Lösung wie R87: Raw-Input validieren, harte 400 statt silent-Mutation.
|
||||||
|
// - max 500 Zeichen (mehrzeilige Postadresse ≈ 4 Zeilen × 80)
|
||||||
|
// - `<` oder `>` direkt 400 (Postadressen brauchen kein HTML)
|
||||||
|
// - Steuerzeichen außer `\n` direkt 400 (kein CR/Tab/Null/etc)
|
||||||
|
// - leerer/getrimmt-leerer Input → null (Feld zurücksetzen)
|
||||||
|
// - CRLF → LF normalisieren
|
||||||
|
const PROVIDER_ADDRESS_MAX_LEN = 500;
|
||||||
|
// Erlaubt: nur LF (`\x0A`) als Newline. Alles andere – inkl. Tab (`\x09`) –
|
||||||
|
// fliegt raus. Tab in Postadressen ist Header-Injection-Vektor für CSV/Mail
|
||||||
|
// und nichts, was ein Mensch je tippt.
|
||||||
|
const PROVIDER_ADDRESS_BAD_CHARS = /[\x00-\x09\x0B\x0C\x0E-\x1F\x7F<>]/;
|
||||||
|
|
||||||
|
export function validateProviderAddress(
|
||||||
|
raw: unknown,
|
||||||
|
fieldLabel: string,
|
||||||
|
): string | null {
|
||||||
|
if (raw == null) return null;
|
||||||
|
if (typeof raw !== 'string') {
|
||||||
|
throw new ApiError(400, `${fieldLabel} muss ein Text sein.`);
|
||||||
|
}
|
||||||
|
// CRLF → LF NORMALISIEREN bevor wir auf Länge prüfen – ein Editor der
|
||||||
|
// immer `\r\n` schickt würde sonst bei jedem Zeilenumbruch zwei
|
||||||
|
// Zeichen gegen das 500er-Cap zählen.
|
||||||
|
const normalized = raw.replace(/\r\n?/g, '\n');
|
||||||
|
if (PROVIDER_ADDRESS_BAD_CHARS.test(normalized)) {
|
||||||
|
throw new ApiError(
|
||||||
|
400,
|
||||||
|
`${fieldLabel} enthält unzulässige Zeichen (HTML, Tabs oder Steuerzeichen sind in Postadressen nicht erlaubt).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (normalized.length > PROVIDER_ADDRESS_MAX_LEN) {
|
||||||
|
throw new ApiError(
|
||||||
|
400,
|
||||||
|
`${fieldLabel} darf maximal ${PROVIDER_ADDRESS_MAX_LEN} Zeichen lang sein.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const trimmed = normalized.trim();
|
||||||
|
return trimmed === '' ? null : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOTES_DEFAULT_MAX = 2000;
|
||||||
|
export function sanitizeNotes(raw: unknown, maxLength: number = NOTES_DEFAULT_MAX): string | null {
|
||||||
|
if (raw == null) return null;
|
||||||
|
if (typeof raw !== 'string') return null;
|
||||||
|
const stripped = stripHtml(raw) as string;
|
||||||
|
// CR allein → entfernen (CRLF → LF); restliche Steuerzeichen außer \n
|
||||||
|
// herausfiltern. Null/Form-Feed/Tabs raus.
|
||||||
|
const normalized = stripped
|
||||||
|
.replace(/\r\n?/g, '\n')
|
||||||
|
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
|
||||||
|
const trimmed = normalized.trim();
|
||||||
|
if (trimmed === '') return null;
|
||||||
|
return trimmed.slice(0, maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hilfs-Wrapper: stripHtml + Cleanup des `blocked:`-Markers in reinen
|
||||||
|
// Display-Strings. Der Marker ist sinnvoll bei URL-Feldern (man sieht,
|
||||||
|
// dass ein gefährliches Scheme abgewehrt wurde), in einem Tarif-Namen
|
||||||
|
// oder Preisfeld ist er nur kosmetischer Müll.
|
||||||
|
// Pentest 2026-05-30 (INFO, 43.5): `javascript:alert(1)` in
|
||||||
|
// priceFirst12Months wurde als "blocked:alert(1)" angezeigt.
|
||||||
|
function stripForDisplay(value: unknown): unknown {
|
||||||
|
const stripped = stripHtml(value);
|
||||||
|
if (typeof stripped === 'string' && stripped.includes('blocked:')) {
|
||||||
|
return stripped.replace(/blocked:/g, '').trim();
|
||||||
|
}
|
||||||
|
return stripped;
|
||||||
|
}
|
||||||
|
|
||||||
export function sanitizeContract<T extends Record<string, unknown>>(contract: T | null): T | null {
|
export function sanitizeContract<T extends Record<string, unknown>>(contract: T | null): T | null {
|
||||||
if (!contract) return contract;
|
if (!contract) return contract;
|
||||||
const copy: Record<string, unknown> = { ...contract };
|
const copy: Record<string, unknown> = { ...contract };
|
||||||
for (const field of SENSITIVE_CONTRACT_FIELDS) {
|
for (const field of SENSITIVE_CONTRACT_FIELDS) {
|
||||||
delete copy[field];
|
delete copy[field];
|
||||||
}
|
}
|
||||||
|
for (const field of CONTRACT_DISPLAY_STRING_FIELDS) {
|
||||||
|
if (typeof copy[field] === 'string') {
|
||||||
|
copy[field] = stripForDisplay(copy[field]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Nested: previousProviderName liegt im energyDetails-Sub-Objekt
|
||||||
|
if (copy.energyDetails && typeof copy.energyDetails === 'object') {
|
||||||
|
const ed = copy.energyDetails as Record<string, unknown>;
|
||||||
|
if (typeof ed.previousProviderName === 'string') {
|
||||||
|
ed.previousProviderName = stripHtml(ed.previousProviderName);
|
||||||
|
}
|
||||||
|
if (typeof ed.previousCustomerNumber === 'string') {
|
||||||
|
ed.previousCustomerNumber = stripHtml(ed.previousCustomerNumber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Nested: previousContract wird rekursiv auch sanitisiert
|
||||||
|
if (copy.previousContract && typeof copy.previousContract === 'object') {
|
||||||
|
copy.previousContract = sanitizeContract(copy.previousContract as Record<string, unknown>);
|
||||||
|
}
|
||||||
if (copy.customer && typeof copy.customer === 'object') {
|
if (copy.customer && typeof copy.customer === 'object') {
|
||||||
copy.customer = sanitizeCustomer(copy.customer as Record<string, unknown>);
|
copy.customer = sanitizeCustomer(copy.customer as Record<string, unknown>);
|
||||||
}
|
}
|
||||||
|
|||||||
+140
-10
@@ -23,8 +23,14 @@ const BLOCKED_PATTERNS: RegExp[] = [
|
|||||||
/^23[0-9]\./, // 230-239 Multicast
|
/^23[0-9]\./, // 230-239 Multicast
|
||||||
/^24[0-9]\./, // 240-249 reserved
|
/^24[0-9]\./, // 240-249 reserved
|
||||||
/^25[0-5]\./, // 250-255 reserved
|
/^25[0-5]\./, // 250-255 reserved
|
||||||
|
// Pentest 51.2 (LOW, 2026-06-01): 100.64.0.0/10 CGNAT (RFC 6598)
|
||||||
|
// wird teils von Cloud-Providern für interne Pfade genutzt; 100.100.x.x
|
||||||
|
// ist konkret Alibaba Cloud Metadata.
|
||||||
|
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./,
|
||||||
/^fd00:ec2::/i, // AWS IPv6 Metadata
|
/^fd00:ec2::/i, // AWS IPv6 Metadata
|
||||||
/^fe80:/i, // IPv6 Link-Local
|
/^fe80:/i, // IPv6 Link-Local
|
||||||
|
// Pentest 51.1: lang ausgeschriebene fe80-Form abdecken
|
||||||
|
/^fe80:0*:/i,
|
||||||
/^ff/i, // IPv6 Multicast
|
/^ff/i, // IPv6 Multicast
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -40,12 +46,23 @@ const PRIVATE_IP_PATTERNS: RegExp[] = [
|
|||||||
/^10\./, // 10.0.0.0/8
|
/^10\./, // 10.0.0.0/8
|
||||||
/^192\.168\./, // 192.168.0.0/16
|
/^192\.168\./, // 192.168.0.0/16
|
||||||
/^172\.(1[6-9]|2\d|3[01])\./, // 172.16.0.0/12
|
/^172\.(1[6-9]|2\d|3[01])\./, // 172.16.0.0/12
|
||||||
/^::1$/, // IPv6 Loopback
|
// IPv6 Loopback in allen Schreibweisen, die DNS/URL-Parser liefern können
|
||||||
/^::ffff:127\./i, // IPv4-mapped Loopback
|
/^::1$/, // kompakte Form
|
||||||
|
/^0:0:0:0:0:0:0:1$/, // voll ausgeschrieben
|
||||||
|
/^::ffff:127\./i, // IPv4-mapped Loopback (kompakt)
|
||||||
|
/^0:0:0:0:0:ffff:127\./i, // IPv4-mapped Loopback (ausgeschrieben)
|
||||||
/^::ffff:10\./i, // IPv4-mapped 10/8
|
/^::ffff:10\./i, // IPv4-mapped 10/8
|
||||||
|
/^0:0:0:0:0:ffff:10\./i,
|
||||||
/^::ffff:192\.168\./i, // IPv4-mapped 192.168/16
|
/^::ffff:192\.168\./i, // IPv4-mapped 192.168/16
|
||||||
|
/^0:0:0:0:0:ffff:192\.168\./i,
|
||||||
/^::ffff:172\.(1[6-9]|2\d|3[01])\./i,
|
/^::ffff:172\.(1[6-9]|2\d|3[01])\./i,
|
||||||
/^f[cd]/i, // fc00::/7 Unique-Local
|
/^0:0:0:0:0:ffff:172\.(1[6-9]|2\d|3[01])\./i,
|
||||||
|
// Pentest 51.1 (MEDIUM, 2026-06-01): fc00::/7 deckt fc00..fdff ab
|
||||||
|
// (Unique-Local + Site-Local). Das alte `/^f[cd]/i` greift nur am
|
||||||
|
// Anfang einer einzelnen Hex-Stelle; lang ausgeschriebene Formen
|
||||||
|
// fingen wir nicht zuverlässig. Jetzt explizit auf das erste Group-
|
||||||
|
// Hex-Block-Prefix `fc` oder `fd` (gefolgt von 2 Hex + ':').
|
||||||
|
/^f[cd][0-9a-f]{2}:/i,
|
||||||
];
|
];
|
||||||
|
|
||||||
const PRIVATE_HOSTNAMES = new Set([
|
const PRIVATE_HOSTNAMES = new Set([
|
||||||
@@ -59,8 +76,65 @@ const BLOCKED_HOSTNAMES = new Set([
|
|||||||
'metadata.goog',
|
'metadata.goog',
|
||||||
'metadata',
|
'metadata',
|
||||||
'169.254.169.254',
|
'169.254.169.254',
|
||||||
|
// Pentest 51.2 (LOW, 2026-06-01): Alibaba Cloud Metadata
|
||||||
|
'100.100.100.200',
|
||||||
|
// Vollständig ausgeschriebene IPv6-Loopback und gängige Cloud-Provider-
|
||||||
|
// Hostnamen, die DNS-Auflösung in geblockten Ranges liefern würden.
|
||||||
|
'0:0:0:0:0:0:0:1',
|
||||||
|
'[::1]',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pentest 59.4 (HIGH, 2026-06-01): Node's URL-Parser normalisiert
|
||||||
|
* IPv4-mapped IPv6 zur **Hex-Form**:
|
||||||
|
* `::ffff:127.0.0.1` → `::ffff:7f00:1`
|
||||||
|
* `::ffff:169.254.169.254` → `::ffff:a9fe:a9fe` (GCP/AWS Metadata!)
|
||||||
|
* `::ffff:10.0.0.1` → `::ffff:a00:1`
|
||||||
|
*
|
||||||
|
* Die alten Patterns (`::ffff:127\.`, `::ffff:10\.` etc.) griffen nur
|
||||||
|
* auf die Dotted-Form – Angreifer konnten via URL-Brackets die Hex-Form
|
||||||
|
* an der Blocklist vorbeischleusen, weil `new URL()` umnormalisiert.
|
||||||
|
*
|
||||||
|
* Lösung: aus IPv4-mapped IPv6 extrahieren wir den IPv4-Anteil und
|
||||||
|
* lassen ihn durch die IPv4-Patterns laufen. Das deckt beide Schreib-
|
||||||
|
* weisen + ausgeschriebene Long-Form ab.
|
||||||
|
*/
|
||||||
|
function extractMappedIPv4(addr: string): string | null {
|
||||||
|
// Compact dotted: ::ffff:127.0.0.1
|
||||||
|
let m = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(addr);
|
||||||
|
if (m) return m[1];
|
||||||
|
// Compact hex: ::ffff:7f00:1
|
||||||
|
m = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(addr);
|
||||||
|
if (m) {
|
||||||
|
const h1 = parseInt(m[1], 16);
|
||||||
|
const h2 = parseInt(m[2], 16);
|
||||||
|
if (!Number.isFinite(h1) || !Number.isFinite(h2) || h1 > 0xffff || h2 > 0xffff) return null;
|
||||||
|
return `${(h1 >> 8) & 0xff}.${h1 & 0xff}.${(h2 >> 8) & 0xff}.${h2 & 0xff}`;
|
||||||
|
}
|
||||||
|
// Expanded dotted: 0:0:0:0:0:ffff:127.0.0.1
|
||||||
|
m = /^0:0:0:0:0:ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(addr);
|
||||||
|
if (m) return m[1];
|
||||||
|
// Expanded hex: 0:0:0:0:0:ffff:7f00:1
|
||||||
|
m = /^0:0:0:0:0:ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(addr);
|
||||||
|
if (m) {
|
||||||
|
const h1 = parseInt(m[1], 16);
|
||||||
|
const h2 = parseInt(m[2], 16);
|
||||||
|
if (!Number.isFinite(h1) || !Number.isFinite(h2) || h1 > 0xffff || h2 > 0xffff) return null;
|
||||||
|
return `${(h1 >> 8) & 0xff}.${h1 & 0xff}.${(h2 >> 8) & 0xff}.${h2 & 0xff}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkIPv4(ipv4: string, includePrivate: boolean): boolean {
|
||||||
|
if (BLOCKED_HOSTNAMES.has(ipv4)) return true;
|
||||||
|
for (const pattern of BLOCKED_PATTERNS) if (pattern.test(ipv4)) return true;
|
||||||
|
if (includePrivate) {
|
||||||
|
if (PRIVATE_HOSTNAMES.has(ipv4)) return true;
|
||||||
|
for (const pattern of PRIVATE_IP_PATTERNS) if (pattern.test(ipv4)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export function isBlockedSsrfHost(host: string | null | undefined): boolean {
|
export function isBlockedSsrfHost(host: string | null | undefined): boolean {
|
||||||
if (!host) return false;
|
if (!host) return false;
|
||||||
const h = host.trim().toLowerCase();
|
const h = host.trim().toLowerCase();
|
||||||
@@ -69,6 +143,10 @@ export function isBlockedSsrfHost(host: string | null | undefined): boolean {
|
|||||||
for (const pattern of BLOCKED_PATTERNS) {
|
for (const pattern of BLOCKED_PATTERNS) {
|
||||||
if (pattern.test(h)) return true;
|
if (pattern.test(h)) return true;
|
||||||
}
|
}
|
||||||
|
// 59.4: IPv4-mapped IPv6 entpacken und die IPv4 separat prüfen –
|
||||||
|
// egal ob Hex- oder Dotted-Form, egal ob compact oder expanded.
|
||||||
|
const mappedV4 = extractMappedIPv4(h);
|
||||||
|
if (mappedV4 && checkIPv4(mappedV4, BLOCK_PRIVATE_IPS)) return true;
|
||||||
if (BLOCK_PRIVATE_IPS) {
|
if (BLOCK_PRIVATE_IPS) {
|
||||||
if (PRIVATE_HOSTNAMES.has(h)) return true;
|
if (PRIVATE_HOSTNAMES.has(h)) return true;
|
||||||
for (const pattern of PRIVATE_IP_PATTERNS) {
|
for (const pattern of PRIVATE_IP_PATTERNS) {
|
||||||
@@ -78,13 +156,50 @@ export function isBlockedSsrfHost(host: string | null | undefined): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strikter Check: blockt private/loopback IP-Ranges UNABHÄNGIG von
|
||||||
|
* `SSRF_BLOCK_PRIVATE_IPS`. Für Use Cases, in denen ein privater Host
|
||||||
|
* NIE legitim sein kann – z.B. eine URL, die an Endkunden per Mail
|
||||||
|
* geht (der Kunde kann eh nicht auf 192.168.x.x routen). Pentest
|
||||||
|
* 2026-05-28 Runde 35.
|
||||||
|
*
|
||||||
|
* Liefert true auch für die regulären Block-Patterns (Cloud-Metadata
|
||||||
|
* etc.), sodass Caller nur eine Funktion aufrufen müssen.
|
||||||
|
*/
|
||||||
|
export function isPrivateOrBlockedHost(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;
|
||||||
|
if (PRIVATE_HOSTNAMES.has(h)) return true;
|
||||||
|
for (const pattern of BLOCKED_PATTERNS) {
|
||||||
|
if (pattern.test(h)) return true;
|
||||||
|
}
|
||||||
|
for (const pattern of PRIVATE_IP_PATTERNS) {
|
||||||
|
if (pattern.test(h)) return true;
|
||||||
|
}
|
||||||
|
// 59.4: IPv4-mapped IPv6 strikt prüfen (Hex- + Dotted-Form,
|
||||||
|
// compact + expanded). Pentester konnte ::ffff:7f00:1 statt
|
||||||
|
// ::ffff:127.0.0.1 nutzen, weil URL-Parser umnormalisiert.
|
||||||
|
const mappedV4 = extractMappedIPv4(h);
|
||||||
|
if (mappedV4 && checkIPv4(mappedV4, true)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wirft einen Fehler, wenn der Host für ausgehende Verbindungen blockiert ist.
|
* Wirft einen Fehler, wenn der Host für ausgehende Verbindungen blockiert ist.
|
||||||
* Caller sollte den Fehler in 400er Response umsetzen.
|
* Caller sollte den Fehler in 400er Response umsetzen.
|
||||||
|
*
|
||||||
|
* `strict=true` (Pentest 50.1, 2026-06-01): private/Loopback-Ranges werden
|
||||||
|
* UNABHÄNGIG von `SSRF_BLOCK_PRIVATE_IPS` immer geblockt. Für Endpunkte mit
|
||||||
|
* besonders kritischer Angriffsfläche (test-connection, test-mail-access),
|
||||||
|
* die im Cloud-Deployment sonst Metadata-/Internal-Service-Probes erlauben
|
||||||
|
* würden.
|
||||||
*/
|
*/
|
||||||
export function assertAllowedHost(host: string | null | undefined, label = 'Host'): void {
|
export function assertAllowedHost(host: string | null | undefined, label = 'Host', opts: { strict?: boolean } = {}): void {
|
||||||
if (isBlockedSsrfHost(host)) {
|
const blocked = opts.strict ? isPrivateOrBlockedHost(host) : isBlockedSsrfHost(host);
|
||||||
throw new Error(`${label} verweist auf eine geblockte Adresse (Cloud-Metadata / Link-Local / Reserved).`);
|
if (blocked) {
|
||||||
|
throw new Error(`${label} verweist auf eine geblockte Adresse (Cloud-Metadata / Link-Local / Reserved / privater Host).`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,18 +217,33 @@ import net from 'net';
|
|||||||
*
|
*
|
||||||
* Wenn der Host bereits eine IP-Literal ist, wird er direkt geprüft.
|
* 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 }> {
|
export async function safeResolveHost(
|
||||||
|
host: string | null | undefined,
|
||||||
|
label = 'Host',
|
||||||
|
opts: { strict?: boolean } = {},
|
||||||
|
): Promise<{ ip: string; servername: string }> {
|
||||||
if (!host || !host.trim()) {
|
if (!host || !host.trim()) {
|
||||||
throw new Error(`${label} fehlt`);
|
throw new Error(`${label} fehlt`);
|
||||||
}
|
}
|
||||||
const trimmed = host.trim();
|
// URL.hostname liefert IPv6-Hosts mit eckigen Klammern (`[::1]`).
|
||||||
|
// Damit `net.isIP` und die Regex-Pattern korrekt matchen, hier strippen.
|
||||||
|
// Pentest 51.1 (2026-06-01): ohne dieses Stripping fiel `::1` durch
|
||||||
|
// ins DNS-Branch und die Block-Patterns liefen ins Leere.
|
||||||
|
const trimmed = host.trim().replace(/^\[|\]$/g, '');
|
||||||
|
const check = opts.strict ? isPrivateOrBlockedHost : isBlockedSsrfHost;
|
||||||
|
|
||||||
// IP-Literal? Direkt prüfen, kein DNS nötig.
|
// IP-Literal? Direkt prüfen, kein DNS nötig.
|
||||||
if (net.isIP(trimmed)) {
|
if (net.isIP(trimmed)) {
|
||||||
assertAllowedHost(trimmed, label);
|
assertAllowedHost(trimmed, label, opts);
|
||||||
return { ip: trimmed, servername: trimmed };
|
return { ip: trimmed, servername: trimmed };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pentest 50.1 Defense-in-Depth: bereits vor DNS prüfen, ob der
|
||||||
|
// Hostname selbst auf der Blocklist steht (z.B. "metadata",
|
||||||
|
// "metadata.google.internal", "localhost"). DNS könnte sonst je nach
|
||||||
|
// Resolver legitime IPs liefern und so die Hostname-Blocklist umgehen.
|
||||||
|
assertAllowedHost(trimmed, label, opts);
|
||||||
|
|
||||||
// Hostname → resolve to IPv4 + IPv6
|
// Hostname → resolve to IPv4 + IPv6
|
||||||
let ips: string[] = [];
|
let ips: string[] = [];
|
||||||
try {
|
try {
|
||||||
@@ -130,7 +260,7 @@ export async function safeResolveHost(host: string | null | undefined, label = '
|
|||||||
|
|
||||||
// Alle aufgelösten IPs prüfen – schon eine geblockte reicht für Ablehnung.
|
// Alle aufgelösten IPs prüfen – schon eine geblockte reicht für Ablehnung.
|
||||||
for (const ip of ips) {
|
for (const ip of ips) {
|
||||||
if (isBlockedSsrfHost(ip)) {
|
if (check(ip)) {
|
||||||
throw new Error(`${label} ${trimmed} löst auf geblockte Adresse ${ip} auf`);
|
throw new Error(`${label} ${trimmed} löst auf geblockte Adresse ${ip} auf`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { isPrivateOrBlockedHost } from './ssrfGuard.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zentrale Validierung für nach außen geleitete URLs (Portal-Links,
|
||||||
|
* Anbieter-Portale, Mail-Footer). Konsolidiert die Schema-/Host-Checks,
|
||||||
|
* die bisher pro Feld einzeln (und uneinheitlich) verstreut waren:
|
||||||
|
* - `appSetting.portalLoginUrl` hatte einen vollen Check
|
||||||
|
* - `provider.portalUrl` hatte gar keinen → Stored XSS via
|
||||||
|
* `javascript:alert(...)` (Pentest 46.1 HIGH)
|
||||||
|
* - andere Felder strippten nur `<script>`-Tags
|
||||||
|
*
|
||||||
|
* Regelwerk:
|
||||||
|
* - Leer/null → OK (Feld ist optional, keine Validierung)
|
||||||
|
* - Schema MUSS http oder https sein (keine `javascript:`,
|
||||||
|
* `data:`, `file:`, `vbscript:` …)
|
||||||
|
* - Host muss vorhanden sein
|
||||||
|
* - Bei `allowPrivateHosts=false` (Default): Private/Loopback-IPs
|
||||||
|
* und Cloud-Metadata-Adressen sind gesperrt, weil die URL für
|
||||||
|
* Endkunden gedacht ist und 10.x/192.168.x für die ohnehin
|
||||||
|
* nicht erreichbar wären
|
||||||
|
* - Trailing-Slash wird gestrippt (Komfort beim Speichern)
|
||||||
|
*/
|
||||||
|
export function validateHttpUrl(
|
||||||
|
rawValue: string,
|
||||||
|
opts: { fieldLabel?: string; allowPrivateHosts?: boolean } = {},
|
||||||
|
): { ok: true; value: string } | { ok: false; error: string } {
|
||||||
|
const label = opts.fieldLabel ?? 'URL';
|
||||||
|
const trimmed = rawValue.trim().replace(/\/+$/, '');
|
||||||
|
if (trimmed === '') return { ok: true, value: '' };
|
||||||
|
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(trimmed);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: `${label} muss eine absolute http(s)-URL sein.` };
|
||||||
|
}
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||||
|
return { ok: false, error: `${label}: unzulässiges Schema '${parsed.protocol}'. Nur http(s) erlaubt.` };
|
||||||
|
}
|
||||||
|
if (!parsed.hostname) {
|
||||||
|
return { ok: false, error: `${label}: Host fehlt.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!opts.allowPrivateHosts) {
|
||||||
|
// Node's URL-Parser lässt eckige Klammern im hostname für IPv6
|
||||||
|
// (`http://[::1]` → hostname `"[::1]"`). Klammern strippen, sonst
|
||||||
|
// matcht der Loopback-Pattern `^::1$` nicht.
|
||||||
|
const hostForCheck = parsed.hostname.replace(/^\[|\]$/g, '');
|
||||||
|
if (isPrivateOrBlockedHost(hostForCheck)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `${label}: Host '${hostForCheck}' ist gesperrt (interne, private oder Loopback-Adresse). Bitte öffentlich erreichbare Domain verwenden.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, value: trimmed };
|
||||||
|
}
|
||||||
@@ -473,6 +473,187 @@ Vor jedem Launch mit echten Tokens probieren.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 📝 Bewusste Akzeptanz – URL-encoded Route-Parameter
|
||||||
|
|
||||||
|
**Finding aus Pentest-Runde 85 (INFO, kein Security-Impact):**
|
||||||
|
|
||||||
|
`/api/stressfrei-emails/%31/...` (URL-encoded `1`) liefert dieselbe
|
||||||
|
Antwort wie `/api/stressfrei-emails/1/...` – die `requireIdParam`-
|
||||||
|
Validierung mit `/^\d+$/` sieht die schon dekodierte Form, weil
|
||||||
|
Express URL-Parameter **vor** dem Routing dekodiert.
|
||||||
|
|
||||||
|
**Wir akzeptieren das als by-design:**
|
||||||
|
|
||||||
|
1. RFC 3986 fordert genau dieses Verhalten: prozentual-codierte und
|
||||||
|
roh-Form derselben Zeichen müssen semantisch identisch behandelt
|
||||||
|
werden. `/1` und `/%31` ist HTTP-konform äquivalent.
|
||||||
|
2. Ein nachträglicher Strict-Check auf `req.url` (roh) würde
|
||||||
|
legitime Clients brechen – Browser-Bookmarks mit URL-Encoding,
|
||||||
|
curl-Calls mit `--data-urlencode`, Proxy-Tools die URL-encoden,
|
||||||
|
Mobile-Clients mit kanonischer URL-Normalisierung.
|
||||||
|
3. Der Pentester selbst bestätigt: **kein Security-Impact** – die ID
|
||||||
|
landet nach Dekodierung als gültige Zahl bei der gleichen
|
||||||
|
Auth-/IDOR-Logik, die auch für die rohe Form greift. Insbesondere
|
||||||
|
`canAccessStressfreiEmail` läuft identisch.
|
||||||
|
4. Validierungs- und Access-Control-Kette ist nach der Dekodierung
|
||||||
|
identisch dicht – Auth, Ownership, Rate-Limit, alles greift gleich.
|
||||||
|
|
||||||
|
**Code-Notiz:** kein Patch nötig. Diese Markdown-Sektion ist die
|
||||||
|
einzige Dokumentation, damit das in zukünftigen Pentest-Runden nicht
|
||||||
|
erneut als „offenes Finding" auftaucht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 Runde 86 – Vertrags-Identifier-Validierung
|
||||||
|
|
||||||
|
**Findings (beide LOW):**
|
||||||
|
|
||||||
|
- **R86.1**: Strings >999 Zeichen in `orderNumberAtSalesPlatform` / den
|
||||||
|
vier verwandten Sales-/Provider-Nummern-Feldern endeten mit
|
||||||
|
generischem 500 (DB-Overflow `VARCHAR(191)`) statt sauberem 400.
|
||||||
|
- **R86.2**: Attribut-Injection-Payload `foo" onerror="alert(1)`
|
||||||
|
(kein umschließender Tag) überlebte `stripHtml`. React escaped
|
||||||
|
Attribute, aber sobald der Wert in PDF-/Mail-/CSV-Export fließt,
|
||||||
|
ist es potentiell aktiv.
|
||||||
|
|
||||||
|
**Fix:** `validateContractIdentifier(raw, fieldLabel)` in
|
||||||
|
[`backend/src/utils/sanitize.ts`](../backend/src/utils/sanitize.ts):
|
||||||
|
|
||||||
|
- Max-Länge 100 Zeichen (deutlich unter VARCHAR(191)).
|
||||||
|
- Whitelist `^[A-Za-z0-9_\-/. ]{0,100}$` – Buchstaben, Ziffern,
|
||||||
|
Punkt, Bindestrich, Schrägstrich, Unterstrich und literales
|
||||||
|
Leerzeichen. Bewusst NICHT `\s` (kein CRLF/Tab → kein
|
||||||
|
Header-Injection-Vektor in CSV-/Mail-Exporten).
|
||||||
|
- Bei Verstoß: `ApiError(400, …)` mit konkreter Fehlermeldung
|
||||||
|
statt 500.
|
||||||
|
- Eingehängt in `sanitizeContractBody` → läuft automatisch für alle
|
||||||
|
fünf Identifier-Felder (`customerNumberAtProvider`,
|
||||||
|
`contractNumberAtProvider`, `orderNumberAtSalesPlatform`,
|
||||||
|
`customerNumberAtSalesPlatform`, `contractNumberAtSalesPlatform`)
|
||||||
|
bei jedem Create/Update.
|
||||||
|
- Frontend: `maxLength={100}` als zusätzliche UX-Schicht im
|
||||||
|
ContractForm – Server-seitige Validierung bleibt die einzige
|
||||||
|
Wahrheit, das HTML-Attribut spart nur den unnötigen Round-Trip.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 Runde 87 – Whitelist vor Sanitizer (silent-mutation-Schutz)
|
||||||
|
|
||||||
|
**Finding (LOW): Sanitizer-Order maskiert Tag-Verstöße**
|
||||||
|
|
||||||
|
Im ursprünglichen R86-Fix lief `stripHtml(body)` **vor**
|
||||||
|
`validateContractIdentifier`. Das hatte einen subtilen Bypass:
|
||||||
|
|
||||||
|
| Payload | Status | Tatsächlich gespeichert |
|
||||||
|
|--------------------------------------|------------|-------------------------|
|
||||||
|
| `<b>bold</b>` | 200 OK | `"bold"` (silent strip) |
|
||||||
|
| `EVN<b>2024</b>` | 200 OK | `"EVN2024"` |
|
||||||
|
| `<script>alert(1)</script>` | **200 OK** | `null` – **vorherigen Wert überschrieben** |
|
||||||
|
| `foo<bar>baz` | 200 OK | `"foobarbaz"` |
|
||||||
|
|
||||||
|
Kein direkter XSS-Vektor (React + DB-Whitelist greifen weiterhin),
|
||||||
|
aber zwei reale UX-/Datenintegritäts-Risiken:
|
||||||
|
|
||||||
|
1. Admin tippt `VG<2024>001`, bekommt 200 zurück, gespeichert ist
|
||||||
|
`VG2024001` ohne Hinweis auf die Mutation.
|
||||||
|
2. Werte die komplett aus Tags bestehen (`<script>…</script>`)
|
||||||
|
werden vom Sanitizer auf den leeren String reduziert →
|
||||||
|
`null` in der DB → **vorheriger Wert wird stillschweigend
|
||||||
|
gelöscht**.
|
||||||
|
|
||||||
|
**Fix:** Validierungs-Reihenfolge für die fünf Identifier-Felder
|
||||||
|
umgedreht – `validateContractIdentifier` läuft jetzt **direkt
|
||||||
|
gegen den Raw-Input**, ohne dass `stripHtml` ihn vorher
|
||||||
|
glättet. Die strikte Whitelist
|
||||||
|
`^[A-Za-z0-9_\-/. ]{0,100}$` lehnt sowieso alles ab, was
|
||||||
|
`stripHtml` normalerweise abgefangen hätte (Tags, Schemes,
|
||||||
|
Zero-Width-Chars, Homoglyphe, Percent-Encoding) – Defense-in-
|
||||||
|
Depth bleibt unverändert, nur jetzt ehrlich (400 statt silent-200).
|
||||||
|
|
||||||
|
Single-Line-Patch in [`backend/src/controllers/contract.controller.ts`](../backend/src/controllers/contract.controller.ts)
|
||||||
|
`sanitizeContractBody`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 Runde 89 – Provider-Adressfelder härten
|
||||||
|
|
||||||
|
**Findings (R89.1 MEDIUM + R89.2 LOW):**
|
||||||
|
|
||||||
|
Beim neuen Provider-Modal (`Kontakt + Kündigung`) wurden
|
||||||
|
`contactAddress` und `cancellationAddress` über `sanitizeNotes(…, 500)`
|
||||||
|
geleitet. Zwei Probleme:
|
||||||
|
|
||||||
|
- **R89.1**: `sanitizeNotes` macht `slice(0, 500)` statt 400 – 501+ Zeichen
|
||||||
|
wurden silent auf 500 abgeschnitten und mit 200 OK gespeichert.
|
||||||
|
- **R89.2**: stripHtml lief vor dem Length-Check – derselbe Bug wie R87.
|
||||||
|
`<script>…</script>` → leerer String → `null` in der DB → vorheriger
|
||||||
|
Wert ohne Fehlermeldung überschrieben.
|
||||||
|
|
||||||
|
**Fix:** Eigener `validateProviderAddress(raw, fieldLabel)` in
|
||||||
|
[`backend/src/utils/sanitize.ts`](../backend/src/utils/sanitize.ts):
|
||||||
|
|
||||||
|
- Validiert den Raw-Input direkt – kein stripHtml davor.
|
||||||
|
- Max 500 Zeichen → `ApiError(400, …)` mit klarer Meldung.
|
||||||
|
- Zeichen-Blacklist `[\x00-\x09\x0B\x0C\x0E-\x1F\x7F<>]` – erlaubt ist
|
||||||
|
nur LF (`\n`). HTML-Klammern (`<`, `>`), Tab, NUL, CR-allein, alle
|
||||||
|
anderen Control-Chars → 400. Tab raus weil Header-Injection-Vektor
|
||||||
|
für CSV-/Mail-Exporte und in einer Postadresse nie legitim.
|
||||||
|
- CRLF → LF normalisiert **vor** dem Length-Check, damit ein Editor
|
||||||
|
mit `\r\n`-Zeilenenden nicht jedes Newline doppelt zählt.
|
||||||
|
- Leerer / nur-Whitespace Input → `null` (Feld zurücksetzen).
|
||||||
|
|
||||||
|
Eingehängt in `stripProviderStrings` für die zwei Adressfelder. Die
|
||||||
|
übrigen fünf Kontakt-Felder (Email/Telefon/Fax) gehen weiter durch
|
||||||
|
`isValidEmail` / `sanitizePhoneField` – die hat der Pentester explizit
|
||||||
|
als sauber bestätigt (7/7 + 6/6 Angriffsvektoren geblockt).
|
||||||
|
|
||||||
|
**Bewusst nicht gefixt:** R89.3 (Anführungszeichen) und R89.4 (`\n`).
|
||||||
|
Der Pentester selbst sagt "kein unmittelbares Risiko, React escaped
|
||||||
|
korrekt". Quotes in `Anbieter "GmbH"` sind legitim, `\n` ist Teil
|
||||||
|
einer mehrzeiligen Postadresse.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 Runde 95 – Portal-Username-Validierung
|
||||||
|
|
||||||
|
**Findings (R95.1 MEDIUM + R95.3 LOW + R95.4 LOW):**
|
||||||
|
|
||||||
|
`portalUsername` (Manual-Input-Modus am Vertrag) hatte gar keine
|
||||||
|
Validierung. Drei nachweisbare Effekte:
|
||||||
|
|
||||||
|
- **R95.1**: `foo\r\nBcc:evil@x.de` (CRLF) wurde verbatim
|
||||||
|
gespeichert → Header-Injection-Vektor sobald der Wert in
|
||||||
|
Mail-Templates oder PDF-Footers landet.
|
||||||
|
- **R95.3**: `<script>alert(1)</script>@x.de` lief durch
|
||||||
|
`stripHtml` → stille Mutation zu `@x.de` (R87.1/R89.2-Pattern
|
||||||
|
auf neuem Feld).
|
||||||
|
- **R95.4**: >190 Zeichen → VARCHAR-Overflow → generischer 500
|
||||||
|
statt sauberem 400.
|
||||||
|
|
||||||
|
**Fix:** `validatePortalUsername(raw, fieldLabel)` in
|
||||||
|
[`backend/src/utils/sanitize.ts`](../backend/src/utils/sanitize.ts):
|
||||||
|
|
||||||
|
- Whitelist `^[A-Za-z0-9_\-/.@+ ]{0,100}$`. Strukturell sind
|
||||||
|
CRLF, Tab, alle Control-Chars, Tags (`<`, `>`) und Quotes raus
|
||||||
|
→ R95.1 und R95.3 ohne extra Check.
|
||||||
|
- Max 100 Zeichen → `ApiError(400, …)` → R95.4 mit klarer Meldung.
|
||||||
|
- Raw-Input direkt validiert (kein `stripHtml` davor) – gleicher
|
||||||
|
R87-Pattern wie bei Contract-Identifier und Provider-Address.
|
||||||
|
- Eingehängt in `sanitizeContractBody` als eigener Branch.
|
||||||
|
|
||||||
|
**Bewusst NICHT übernommen: R95.2 (Email-Format-Pflicht).**
|
||||||
|
|
||||||
|
Der Pentester schlägt `z.string().email()` vor, weil der „Kunde
|
||||||
|
sich sonst nicht einloggen kann". Falsche Annahme: `portalUsername`
|
||||||
|
ist im Manual-Modus **nicht zwingend eine E-Mail**. Vodafone, 1&1,
|
||||||
|
EWE und etliche Stadtwerke nutzen reine Kundennummern (`12345678`),
|
||||||
|
Pseudonyme (`max.mustermann`) oder Customer-IDs als Portal-Login.
|
||||||
|
Eine Email-Pflicht würde legitime Logins ablehnen. Der Stressfrei-
|
||||||
|
Modus hängt sowieso an einer schon validierten Email-Stammdate
|
||||||
|
(`assertValidForwardingEmail`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🧭 Wann ist „dicht" dicht?
|
## 🧭 Wann ist „dicht" dicht?
|
||||||
|
|
||||||
100 % gibt es nicht. Erreicht ist:
|
100 % gibt es nicht. Erreicht ist:
|
||||||
|
|||||||
+960
@@ -97,6 +97,848 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
|
|
||||||
## ✅ Erledigt
|
## ✅ Erledigt
|
||||||
|
|
||||||
|
- [x] **🔧 Pentest R101.1 – Inline-Preview-Pfad refaktoriert + Diagnose-Log**
|
||||||
|
- Pentester R101.1 (INFO/funktional) berichtet: `?disposition=inline`
|
||||||
|
bewirkt nichts, Browser zeigt Download-Dialog. Die Logik im
|
||||||
|
`fileDownload.controller` ist eigentlich korrekt – sauberer Magic-
|
||||||
|
Byte-Check für PDF/PNG/JPEG/GIF/WebP – und liefert beim Direkttest
|
||||||
|
gegen echte Vertrags-PDFs `application/pdf`. Wir können das in Prod
|
||||||
|
aber nicht reproduzieren.
|
||||||
|
- Refaktorierung: Magic-Byte-Check in `detectSafeContentType()`
|
||||||
|
extrahiert, finally-Block schließt File-Descriptor garantiert,
|
||||||
|
Short-Read-Fälle (`bytesRead < n`) jetzt sauber geguardet.
|
||||||
|
- Sicherheits-Verhalten unverändert: bei Magic-Byte-Mismatch bleibt
|
||||||
|
es bei `Content-Disposition: attachment` (Stored-XSS-Schutz aus
|
||||||
|
R30.13).
|
||||||
|
- Neu: `console.warn`, wenn `inline` angefragt wurde, aber der
|
||||||
|
Magic-Byte-Check fehlschlägt oder der Read crasht. Damit fällt
|
||||||
|
der Fall im Prod-Log auf, falls er nochmal auftritt – bisher
|
||||||
|
war's silent.
|
||||||
|
|
||||||
|
- [x] **🔒 Pentest R97 – Attachment-Validierung im Send-Handler**
|
||||||
|
- R97.1 (LOW): malformed `content` (`null`, fehlend, `true`, `""`)
|
||||||
|
erzeugte 200/500 mit rohem `Buffer.from()`-Fehlertext in der
|
||||||
|
Response. `content: ""` ließ sogar eine Mail mit 0-Byte-Anhang
|
||||||
|
durchgehen.
|
||||||
|
- R97.2 (INFO): keine App-Level-Caps (Größe + Anzahl) – die im
|
||||||
|
Frontend dokumentierten 10 MB/25 MB/Datei-Limits hingen am
|
||||||
|
bodyParser; falls der je hochgedreht wird, fällt die Sicherung.
|
||||||
|
- Fix: `validateAttachments()` im Controller `sendEmailFromAccount`
|
||||||
|
läuft **vor** dem `sendEmail`-Aufruf:
|
||||||
|
- `attachments` muss Array oder undefined sein
|
||||||
|
- max 25 Anhänge
|
||||||
|
- jeder: `filename` non-empty String, `content` non-empty Base64-
|
||||||
|
String (Regex), optional `contentType` String
|
||||||
|
- max 10 MB/Datei, 25 MB gesamt (Schätzung via base64-Länge × 0.75,
|
||||||
|
kein Buffer.from-Aufruf während der Validierung)
|
||||||
|
- Bei Verstoß harte 400 mit klarer Meldung. Sanity-Test: 18/18 Cases
|
||||||
|
grün inkl. aller R97.1-Pentest-Payloads.
|
||||||
|
|
||||||
|
- [x] **🆕 E-Mail-Compose: Vertragsdokumente anhängen + Kundendaten einfügen**
|
||||||
|
- Im Compose-Modal (nur wenn Vertrag-Kontext) zwei neue Buttons neben
|
||||||
|
"Datei anhängen":
|
||||||
|
- **Vertragsdokumente**: listet alle am Vertrag gespeicherten Dokumente
|
||||||
|
gruppiert nach `documentType`. Auswahl → Server-Download via
|
||||||
|
`fileUrl` (Token-Auth, Per-File-Ownership-Check greift) → base64 →
|
||||||
|
direkt in die Anhang-Liste. Respektiert das 25-MB-Gesamtlimit.
|
||||||
|
- **Kundendaten einfügen**: zeigt nur Sections die tatsächlich Daten
|
||||||
|
haben (Customer, Lieferadresse, ggf. Rechnungsadresse, Vertrag,
|
||||||
|
Bank, Ausweis). Pro Section Checkbox + Preview. Bei Bank +
|
||||||
|
Ausweis zusätzlich Sub-Checkbox "als PDF anhängen", wenn ein
|
||||||
|
`documentPath` vorhanden ist.
|
||||||
|
- Beim Bestätigen werden die Text-Blöcke an das Body-Ende gehängt
|
||||||
|
(mit `\n\n`-Separator), Anhänge per `serverFileToAttachment` aus
|
||||||
|
`composeAttachmentHelpers.ts` gezogen. Anhang-Limit (25 MB gesamt)
|
||||||
|
wird beidseitig geprüft, drüberlaufende Dateien werden mit Toast
|
||||||
|
übersprungen statt silent weggeschluckt.
|
||||||
|
- Helpers (`composeAttachmentHelpers.ts`):
|
||||||
|
- `serverFileToAttachment(path, filename)` – fetch via Token-URL
|
||||||
|
→ Blob → base64 → `EmailAttachment`.
|
||||||
|
- `totalAttachmentBytes` – Größen-Check unter Berücksichtigung der
|
||||||
|
~33 % base64-Overhead.
|
||||||
|
- `bankCardAttachmentName` / `identityDocAttachmentName` –
|
||||||
|
sprechende Dateinamen für den Empfänger.
|
||||||
|
|
||||||
|
- [x] **🔒 Pentest R95 – Portal-Username (Manual-Modus) härten**
|
||||||
|
- R95.1 (MEDIUM): `foo\r\nBcc:evil@x.de` → Header-Injection-Vektor
|
||||||
|
sobald der Wert in Mail-Templates / PDF-Footer landet.
|
||||||
|
- R95.3 (LOW): `<script>…</script>@x.de` → silent stripHtml-Mutation
|
||||||
|
(R87.1-Pattern, dritter Treffer auf demselben Bug).
|
||||||
|
- R95.4 (LOW): >190 Zeichen → VARCHAR-Overflow → 500 statt 400.
|
||||||
|
- Fix: `validatePortalUsername()` in `sanitize.ts` mit Whitelist
|
||||||
|
`^[A-Za-z0-9_\-/.@+ ]{0,100}$`. Strukturell sind CRLF, Tab, alle
|
||||||
|
Control-Chars, Tags und Quotes raus → R95.1+R95.3 ohne extra
|
||||||
|
Check. Max 100 → `ApiError(400)` → R95.4 sauber. Raw-Input direkt
|
||||||
|
validiert (R87-Pattern). Eingehängt in `sanitizeContractBody`.
|
||||||
|
- Frontend: `maxLength={100}` am Input.
|
||||||
|
- **R95.2 bewusst nicht übernommen** (Email-Format-Pflicht): das
|
||||||
|
Feld ist im Manual-Modus nicht zwingend eine E-Mail – Vodafone,
|
||||||
|
1&1, EWE und Stadtwerke nutzen Kundennummern oder Pseudonyme als
|
||||||
|
Portal-Login. Doku in `SECURITY-HARDENING.md § Runde 95`.
|
||||||
|
|
||||||
|
- [x] **🔒 Pentest R93 – Leerer String != fehlender Param**
|
||||||
|
- R93.1 (INFO): `?accountId=` (explizit-leer) wurde wie `?accountId`
|
||||||
|
weggelassen behandelt → 200 statt 400 auf optionalen Endpunkten.
|
||||||
|
Pentester-Spec: leerer String ist KEINE gültige Zahl.
|
||||||
|
- Fix im `parsePositiveIntQuery()`-Helper: striktere Absent-Logik
|
||||||
|
(`v === undefined` ist absent; `''`, `' '`, alles andere muss
|
||||||
|
parsen). Required + optional Modes unverändert.
|
||||||
|
- Float-Grenzfall (`accountId=5.5` → 5 via `parseInt`) bleibt als
|
||||||
|
by-design akzeptiert (Pentester-Bestätigung, kein Security-Impact).
|
||||||
|
|
||||||
|
- [x] **🔒 Pentest R92 – Strict-400 für accountId auf Vertrags-Endpunkten**
|
||||||
|
- R91-Fix war silent-undefined bei invaliden Werten: `accountId=abc`
|
||||||
|
auf `GET /contracts/:id/emails` ergab "kein Filter" → Mailbox-
|
||||||
|
Isolation brach (alle Postfächer sichtbar). Pentester R92: per
|
||||||
|
Design sind Vertrags-Endpunkte immer pro Postfach, also strict-400.
|
||||||
|
- Fix: `parsePositiveIntQuery(v, label, res, { required? })`
|
||||||
|
ersetzt den alten silent-Helper. Modes:
|
||||||
|
- default (optional): fehlend/leer → `undefined` (kein Filter),
|
||||||
|
invalid → 400
|
||||||
|
- `{ required: true }`: fehlend/leer **oder** invalid → 400
|
||||||
|
- Verteilung:
|
||||||
|
- Contract-Emails, Contract-Folder-Counts: `{ required: true }`
|
||||||
|
- Customer-Emails, Trash, Trash-Count: optional (Cross-Mailbox-
|
||||||
|
View ist legitim), invalid → 400
|
||||||
|
- Frontend hat schon ein `enabled: !!selectedAccountId`-Guard auf
|
||||||
|
den Vertrags-Queries – kein UX-Bruch.
|
||||||
|
|
||||||
|
- [x] **🔒 Pentest R91 – NaN-Bypass auf accountId-Query-Param**
|
||||||
|
- R91.1 (LOW): `accountId=abc` → `parseInt('abc')` = `NaN` → der
|
||||||
|
Ternary im Controller gab `NaN` an den Service, `if (NaN)` ist
|
||||||
|
falsy → der Postfach-Filter fiel weg. Folge: ein Portal-User mit
|
||||||
|
ungültigem `accountId` sah alle Mailbox-Mails für seinen Vertrag
|
||||||
|
statt nur die aus dem gewählten Postfach (kein Cross-Customer-
|
||||||
|
Leak — `canAccessContract` greift weiter).
|
||||||
|
- Fix: zentraler `parsePositiveIntParam()` im `cachedEmail.controller.ts`,
|
||||||
|
der nur positive Ganzzahlen aus dem Query-String akzeptiert und
|
||||||
|
alles andere zu `undefined` macht. Eingesetzt in allen 5
|
||||||
|
Endpunkten, die `accountId`/`contractId` aus Query nehmen
|
||||||
|
(Contract-Emails, Contract-Folder-Counts, Customer-Emails,
|
||||||
|
Trash, Trash-Count) – auch da, wo der Pentester nicht getestet
|
||||||
|
hat, weil derselbe Pattern überall stand.
|
||||||
|
|
||||||
|
- [x] **🐞 E-Mail-Ansicht: Postfach-Filter griff in Trash/Sent nicht**
|
||||||
|
- Bug-Bericht 2026-06-21: im Vertrags-Tab (Gesendet/Gelöscht) und im
|
||||||
|
Kunden-Haupt-Postfach (Gelöscht) wurden E-Mails aus ALLEN Postfächern
|
||||||
|
des Kunden angezeigt, egal welches Postfach im Selector aktiv war.
|
||||||
|
Im Vertrag fehlte zusätzlich der Vertrags-Filter für den Papierkorb.
|
||||||
|
- Backend:
|
||||||
|
- `getEmailsForContract` controller akzeptiert jetzt `accountId`-
|
||||||
|
Query-Param und reicht ihn als `stressfreiEmailId` an
|
||||||
|
`getCachedEmails` weiter (der hat den Filter eh schon implementiert,
|
||||||
|
nur niemand hat ihn aufgerufen).
|
||||||
|
- `getTrashEmails` (controller + service) akzeptiert `accountId` und
|
||||||
|
`contractId` als optionale Filter. Default-Verhalten unverändert,
|
||||||
|
wenn keiner gesetzt ist.
|
||||||
|
- `getFolderCountsForContract` akzeptiert optional `stressfreiEmailId`,
|
||||||
|
bekommt zusätzlich `trash` + `trashUnread` ins Result – sonst läge
|
||||||
|
der Trash-Badge im Vertrag wieder account-global, während die Liste
|
||||||
|
contract-scoped ist.
|
||||||
|
- Frontend:
|
||||||
|
- `cachedEmailApi.getForContract` / `getTrash` / `getContractFolderCounts`
|
||||||
|
nehmen den Filter entgegen.
|
||||||
|
- `ContractEmailsSection` reicht `selectedAccountId` in alle drei
|
||||||
|
Queries durch und nimmt es in den queryKey mit auf – sonst greift
|
||||||
|
der React-Query-Cache beim Postfach-Wechsel nicht. Der Trash-Badge
|
||||||
|
kommt jetzt aus den contract-scoped Counts, damit Badge und Liste
|
||||||
|
synchron laufen.
|
||||||
|
- `EmailClientTab` reicht `selectedAccountId` in die Trash-Query
|
||||||
|
durch (Inbox/Sent waren schon korrekt).
|
||||||
|
|
||||||
|
- [x] **🔒 Pentest R89 – Provider-Adressfelder härten**
|
||||||
|
- R89.1 (MEDIUM): `sanitizeNotes(…, 500)` macht silent `slice(0, 500)`
|
||||||
|
statt 400 – 501+ Zeichen wurden auf 500 abgeschnitten und mit
|
||||||
|
200 OK gespeichert.
|
||||||
|
- R89.2 (LOW): `stripHtml` lief vor dem Length-Check – `<script>…</script>`
|
||||||
|
reduzierte auf leeren String → `null` in der DB → vorheriger Wert
|
||||||
|
silent überschrieben (R87.1-Pattern auf Adress-Feldern).
|
||||||
|
- Fix: eigener `validateProviderAddress()` in `sanitize.ts`. Raw-Input,
|
||||||
|
max 500 → `ApiError(400)`, Blacklist `<`, `>`, Tab, alle Control-
|
||||||
|
Chars außer `\n`. CRLF → LF normalisiert vor Length-Check.
|
||||||
|
Eingehängt in `stripProviderStrings`.
|
||||||
|
- R89.3 (Quotes) + R89.4 (`\n`): bewusst nicht gefixt – Pentester
|
||||||
|
bestätigt "kein unmittelbares Risiko", React escaped korrekt,
|
||||||
|
sind legitime Bestandteile mehrzeiliger Postadressen.
|
||||||
|
- Doku in `SECURITY-HARDENING.md § Runde 89`.
|
||||||
|
|
||||||
|
- [x] **🆕 Anbieter: Kontakt + Kündigung als Stammdaten**
|
||||||
|
- Sieben neue optionale Felder am `Provider`-Modell: `contactEmail`,
|
||||||
|
`contactPhone`, `contactFax`, `contactAddress`, `cancellationEmail`,
|
||||||
|
`cancellationFax`, `cancellationAddress`. Postadressen als `TEXT`
|
||||||
|
(mehrzeilig), Rest `VARCHAR(191)`. Migration
|
||||||
|
`20260621100000_provider_contact_and_cancellation` mit `IF NOT EXISTS`.
|
||||||
|
- Modal „Anbieter bearbeiten" bekommt eine neue Sektion **Kontakt &
|
||||||
|
Kündigung** unterhalb der Auto-Login-Felder, getrennt in zwei
|
||||||
|
Untergruppen (Kontakt / Kündigung) mit kleinen Headern.
|
||||||
|
Email-/Telefon-/Fax-Felder als Single-Line-Inputs, Postadressen
|
||||||
|
als `<textarea rows={3}>` mit `maxLength={500}`.
|
||||||
|
- Backend-Validierung: contactEmail/cancellationEmail laufen durch
|
||||||
|
`isValidEmail` (Header-Injection-Schutz für Mail-Templates),
|
||||||
|
contactPhone/contactFax/cancellationFax durch `sanitizePhoneField`
|
||||||
|
(kein CRLF/Control-Char), Postadressen durch `sanitizeNotes` mit
|
||||||
|
500-Cap.
|
||||||
|
- Factory-Defaults Export/Import mitgezogen, sonst gingen die neuen
|
||||||
|
Felder beim Backup/Restore verloren.
|
||||||
|
|
||||||
|
- [x] **🔒 Pentest R87 – Whitelist vor Sanitizer (silent-mutation-Schutz)**
|
||||||
|
- R87.1 (LOW): `stripHtml` lief im R86-Fix VOR der Whitelist.
|
||||||
|
Tags wurden still weggestrippt → 200 OK mit mutierten Werten,
|
||||||
|
`<script>…</script>` reduzierte auf leeren String → `null` in
|
||||||
|
der DB → vorheriger Wert ohne Fehlermeldung überschrieben.
|
||||||
|
- Fix: Validierungs-Reihenfolge für die fünf Identifier-Felder
|
||||||
|
umgedreht – `validateContractIdentifier` läuft jetzt direkt
|
||||||
|
gegen den Raw-Input. Die strikte Whitelist lehnt eh alles
|
||||||
|
ab, was stripHtml normalerweise auffangen würde (Tags,
|
||||||
|
Schemes, Zero-Width, Homoglyphe, Percent-Encoding) – Defense-
|
||||||
|
in-Depth bleibt, nur ehrlich (400 statt silent-200).
|
||||||
|
- Single-Line-Patch in `contract.controller.ts`, Doku in
|
||||||
|
`SECURITY-HARDENING.md § Runde 87`.
|
||||||
|
|
||||||
|
- [x] **🔒 Pentest R86 – Vertrags-Identifier härten**
|
||||||
|
- R86.1 (LOW): >999-Zeichen-Strings auf Kunden-/Vertrags-/
|
||||||
|
Auftragsnummer warfen 500 (DB-Overflow `VARCHAR(191)`) statt 400.
|
||||||
|
- R86.2 (LOW/INFO): Attribut-Injection ohne umschließenden Tag
|
||||||
|
(`foo" onerror=…`) überlebte `stripHtml` – kein Risiko in der React-
|
||||||
|
UI, aber relevant für PDF/Mail/CSV-Export.
|
||||||
|
- Fix: zentraler `validateContractIdentifier()` in `sanitize.ts`
|
||||||
|
mit Max-100 und Whitelist `^[A-Za-z0-9_\-/. ]{0,100}$`. Bewusst
|
||||||
|
literales Space statt `\s`, damit kein CRLF/Tab passiert (Header-
|
||||||
|
Injection). Wirft `ApiError(400, …)` mit klarer Meldung.
|
||||||
|
- Eingehängt in `sanitizeContractBody` → läuft automatisch für alle
|
||||||
|
fünf Identifier-Felder bei Create/Update. ContractForm bekommt
|
||||||
|
`maxLength={100}` als UX-Schicht. Doku in
|
||||||
|
`docs/SECURITY-HARDENING.md` § Runde 86.
|
||||||
|
|
||||||
|
- [x] **🆕 Vertrag: Auftragsnummer bei Vertriebsplattform**
|
||||||
|
- Neues optionales Feld `Contract.orderNumberAtSalesPlatform`
|
||||||
|
(`VARCHAR(191) NULL`), Migration
|
||||||
|
`20260619100000_contract_order_number_at_sales_platform` mit
|
||||||
|
`IF NOT EXISTS`.
|
||||||
|
- Im ContractForm direkt **vor** der Kundennummer der
|
||||||
|
Vertriebsplattform angeordnet (Wunsch des Users).
|
||||||
|
ContractDetail zeigt sie als eigene Zeile mit Copy-Button vor
|
||||||
|
den anderen beiden Sales-Platform-Feldern.
|
||||||
|
- Audit-Log-Mapping, Renewal-Copy (VVL-Folgevertrag) und
|
||||||
|
XSS-Strip-Allowlist (`CONTRACT_DISPLAY_STRING_FIELDS`)
|
||||||
|
mitgezogen, damit das neue Feld die gleichen Garantien wie
|
||||||
|
Kunden-/Vertragsnummer bekommt.
|
||||||
|
|
||||||
|
- [x] **🐞 Entfernte Weiterleitungen kamen via Auto-Import zurück**
|
||||||
|
- Folge-Bug: User löscht Adresse im Modal → DB-Liste wird kürzer →
|
||||||
|
Plesk-Sync läuft → Auto-Import (`Pentest 83.x`) sieht „c ist in
|
||||||
|
Plesk aber nicht in DB" → schreibt `c` zurück in
|
||||||
|
`additionalForwardingEmails` → Diff sagt nichts zu entfernen.
|
||||||
|
- Ursache: Auto-Import war für **alle** Sync-Aufrufe aktiv. Beim
|
||||||
|
Sync-Button-Klick will der User Plesk-Bestand übernehmen (Import
|
||||||
|
sinnvoll), beim Add/Remove im Modal ist die DB-Liste die
|
||||||
|
explizite Intent (Import schädlich).
|
||||||
|
- Fix: `syncForwardingForEmail(id, { autoImportPleskMembers? })`
|
||||||
|
mit Default `true`. `setAdditionalForwards` ruft mit
|
||||||
|
`false` auf → entfernte Adressen verschwinden jetzt sauber bei
|
||||||
|
Plesk. Sync-Button-Pfad bleibt unverändert (importiert weiterhin
|
||||||
|
alte Bestands-Members).
|
||||||
|
|
||||||
|
- [x] **🐞 Plesk-Sync: `-forwarding-addresses set:` existiert gar nicht**
|
||||||
|
- Folge-Bug nach `a83358b`/`24e152b`: Sync verändert Plesk weiterhin
|
||||||
|
nicht. `plesk bin mail --help` zeigt: `-forwarding-addresses`
|
||||||
|
akzeptiert ausschließlich `add:` und `del:` – unser `set:` wurde
|
||||||
|
von Plesk silent verworfen. Außerdem gibt es keine separate
|
||||||
|
`-mailgroup`-Option; was Plesk im `--info` als `Mailgroup: true`
|
||||||
|
zeigt, ist genau das, was `-forwarding true` in der CLI setzt
|
||||||
|
(doppelt benannt). Mein vorheriges `-mailgroup false` lief auf
|
||||||
|
den Phantom-Parameter und triggerte `Unrecognized option`.
|
||||||
|
- `updateForwardTargets` baut jetzt den Diff: aktuelle Mailgroup-
|
||||||
|
Members (aus `emailExists`) gegen Soll-Liste; `del:<entfernt>` +
|
||||||
|
`add:<neu>` in zwei separaten CLI-Calls. Idempotent.
|
||||||
|
Case-insensitive – `Bruns.Gerhard` ≡ `bruns.gerhard`.
|
||||||
|
- Phantom-`-mailgroup`-Parameter entfernt.
|
||||||
|
- Smoke-Test gegen Prod-Stand (3 Bestands-Members + 1 neuer Eintrag):
|
||||||
|
nichts entfernt, nur `bzirks@gmx.de` hinzugefügt.
|
||||||
|
|
||||||
|
- [x] **🔒 Pentest 83.1-83.3: Auto-Import-Pfad härten**
|
||||||
|
- **83.1 MEDIUM:** Auto-Import in `syncForwardingForEmail` umging
|
||||||
|
`assertValidForwardingEmail`. Plesk-Member wie `attacker@plesk.internal`
|
||||||
|
oder `evil@x.local` wären ohne TLD-Block-Check (71.1) in unsere
|
||||||
|
DB gewandert. Fix: jeder importierte Member läuft durch
|
||||||
|
`assertValidForwardingEmail`; ungültige werden silent gedroppt
|
||||||
|
und auf `console.debug`-Level geloggt.
|
||||||
|
- **83.2 LOW:** Self-Forward-Schutz (81.1) lief nur im Add-Pfad.
|
||||||
|
Wenn Plesk die eigene Adresse als Mailgroup-Member führte, wäre
|
||||||
|
sie beim Auto-Import in die DB-Liste gerutscht → nach dem
|
||||||
|
Umschalten auf Forwarding Mail-Loop. Fix:
|
||||||
|
`seenKeys.add(canonicalEmailKey(stressfreiEmail.email))` vor
|
||||||
|
der Import-Schleife.
|
||||||
|
- **83.3 INFO:** PII-Log auf `console.debug` umgestellt (statt
|
||||||
|
`console.log` auf Default-Level).
|
||||||
|
- Smoke-Test mit gemischter Plesk-Liste: `karibik61@web.de` (legit)
|
||||||
|
importiert, `attacker@plesk.internal` + `evil@x.local` per 83.1
|
||||||
|
abgelehnt, exakte Self-Mail + Plus-Tag-Variante per 83.2
|
||||||
|
abgelehnt, Customer-Stamm-Mail + Default deduped.
|
||||||
|
|
||||||
|
- [x] **🐞 Plesk-Sync: Legacy-Mailgroup-Adressen synchronisierten nicht**
|
||||||
|
- Prod-Bug: User trägt zusätzliche Weiterleitung ein, Toast meldet
|
||||||
|
Erfolg, aber Plesk übernimmt nichts. Ursache: Plesk hat zwei
|
||||||
|
Verteil-Mechanismen, **Mailgroup** (alte CLI-Anlagen,
|
||||||
|
`Group member(s):`) und **Forwarding** (`Forward request:`). Unser
|
||||||
|
Sync schrieb nur in Forwarding, die Adresse lief aber via Mailgroup
|
||||||
|
→ unsere `set:`-Befehle landeten in einer ungenutzten Tabelle.
|
||||||
|
Stage funktionierte, weil dort die Adressen frisch vom CRM angelegt
|
||||||
|
wurden (Forwarding-Modus von Anfang an).
|
||||||
|
- `EmailExistsResult` um `mailgroupActive` + `mailgroupMembers` +
|
||||||
|
`forwardingActive` + `forwardingTargets` erweitert.
|
||||||
|
- `pleskProvider.emailExists` parst alle vier Felder aus dem
|
||||||
|
`--info`-stdout (`Mailgroup: true|false`, `Group member(s): ...`,
|
||||||
|
`Forward request: ...`).
|
||||||
|
- `pleskProvider.updateForwardTargets` setzt jetzt zusätzlich
|
||||||
|
`-mailgroup false`, damit der Legacy-Mechanismus deaktiviert wird
|
||||||
|
und nur noch Forwarding aktiv ist.
|
||||||
|
- `syncForwardingForEmail`: vor dem Plesk-Update werden bestehende
|
||||||
|
Mailgroup-Members + Forwarding-Targets abgeholt und in unsere
|
||||||
|
`additionalForwardingEmails`-Liste **importiert** (canonical-Key-
|
||||||
|
Dedup). Verlustfrei – kein bestehender Empfänger fällt beim
|
||||||
|
Umschalten auf Forwarding raus. Import-Fehler werden geloggt,
|
||||||
|
aber der eigentliche Sync läuft trotzdem.
|
||||||
|
|
||||||
|
- [x] **🔒 Pentest 81.1 (MEDIUM): Self-Forward erzeugte Mail-Loop am Provider**
|
||||||
|
- Bug: User konnte die Stressfrei-Adresse selbst (z.B.
|
||||||
|
`max.mustermann@stressfrei-wechseln.net`) als zusätzliches
|
||||||
|
Weiterleitungsziel eintragen – auch Plus-Varianten davon. Plesk
|
||||||
|
leitet auf sich selbst um → Mail-Loop.
|
||||||
|
- Backend (`setAdditionalForwards`): zieht jetzt zusätzlich
|
||||||
|
`meta.email` aus der DB und vergleicht `canonicalEmailKey(eintrag)`
|
||||||
|
gegen `canonicalEmailKey(meta.email)`. Bei Treffer hartes
|
||||||
|
`ApiError(400)` mit klarer Self-Forward-Meldung statt silent dedup
|
||||||
|
– der User soll merken, dass sein Eintrag bewusst abgelehnt wurde.
|
||||||
|
- Frontend (`AdditionalForwardsModal`): zusätzlich proaktive
|
||||||
|
Validierung im Sub-Modal mit identischem `canonicalize`-Helper
|
||||||
|
(Plus-Tag strippen, lowercase). Neuer Prop `selfEmail`, damit
|
||||||
|
auch der Create-Modus (vor dem Persistieren) den Check fahren
|
||||||
|
kann. Spart einen Roundtrip + zeigt sofort eine sprechende
|
||||||
|
Meldung „… zeigt auf die Adresse selbst – Mail-Loop".
|
||||||
|
|
||||||
|
- [x] **🔒 Pentest 77.3 (LOW): `requireIdParam` ließ Float-IDs durch**
|
||||||
|
- `Number.isInteger(parseInt('4.5'))` ist `true`, weil `parseInt`
|
||||||
|
den Nachkomma-Teil silent abschneidet. Damit traf `/.../4.5/...`
|
||||||
|
auf die echte ID 4 statt 400 zurückzuliefern. Gleiches gilt für
|
||||||
|
`4.0` und Exp-Notation `4e1`.
|
||||||
|
- Fix: vorm Parsen Regex `/^\d+$/` auf die rohe `req.params.<name>`-
|
||||||
|
Eingabe. Nur reine Ziffern erlaubt – keine Floats, Exp, Vorzeichen,
|
||||||
|
Whitespace, Hex.
|
||||||
|
- Smoke-Test (17 Cases): `4.0`, `4.5`, `4e1`, `4E2`, `0`, `-4`, `+4`,
|
||||||
|
`0x10`, `1.0e0`, leading/trailing Space alle abgelehnt; `1`, `4`,
|
||||||
|
`100`, `9999999` durchgewunken.
|
||||||
|
|
||||||
|
- [x] **🐞 Stressfrei-Adressen: doppelte E-Mails beim Anlegen erlaubt**
|
||||||
|
- Bug: User konnte dieselbe Adresse zweimal beim selben Kunden
|
||||||
|
anlegen (siehe Screenshot mit 2× `max.mustermann@...`). `createEmail`
|
||||||
|
hatte keinen Duplikatscheck, `updateEmail` ebenfalls nicht.
|
||||||
|
- Service: Vor `prisma.create` jetzt `findFirst` auf
|
||||||
|
`(customerId, email)` (case-insensitive). Bei Treffer → `ApiError(409)`.
|
||||||
|
Unterschiedliche Meldung für aktive vs. inaktive Duplikate
|
||||||
|
(Hinweis bei inaktiv: alten Eintrag reaktivieren statt neu anlegen).
|
||||||
|
- `updateEmail`: gleicher Check beim Umbenennen, mit `NOT id`-Exclude.
|
||||||
|
- Controller: `catch`-Blöcke honorieren jetzt den `ApiError.statusCode`
|
||||||
|
(vorher pauschal 400) → 409 kommt sauber durch.
|
||||||
|
- Frontend: `updateMutation` bekam ein `onError`, damit der 409 nicht
|
||||||
|
nur ins Leere lief.
|
||||||
|
|
||||||
|
- [x] **🔒 Pentest 71.1–71.4: Härtung der Zusatz-Weiterleitungen**
|
||||||
|
- **71.1 MEDIUM:** Reservierte/private TLDs (`local`, `internal`,
|
||||||
|
`corp`, `lan`, `home`, `private`, `invalid`, `test`, `localhost`,
|
||||||
|
`example`, `intranet`, `localdomain`, `arpa`) werden in
|
||||||
|
`assertValidForwardingEmail` jetzt hart abgelehnt. Verhindert
|
||||||
|
Plesk-DNS-Probing ins interne Netz bei On-Prem-Setups.
|
||||||
|
- **71.2 LOW:** Neuer Helper `canonicalEmailKey` normalisiert Mail-
|
||||||
|
Adressen für den Dedup-Vergleich (Plus-Tag wegstrippen,
|
||||||
|
lowercase). `billing+pentest@x.de` und `billing@x.de` werden als
|
||||||
|
dasselbe Ziel erkannt – auch im Vergleich zur Kunden-Stamm-Mail
|
||||||
|
und im sync-Pfad gegen `config.defaultForwardEmail`.
|
||||||
|
- **71.3 INFO:** Neuer `requireIdParam(req, res, paramName)`-Helper
|
||||||
|
fängt nicht-numerische Route-Parameter und liefert 400 statt 500.
|
||||||
|
Alle acht parseInt-Stellen in `stressfreiEmail.controller.ts`
|
||||||
|
umgestellt (auch über das gemeldete Finding hinaus).
|
||||||
|
- **71.4 INFO:** `setAdditionalForwards` rollt den DB-Stand bei
|
||||||
|
Provider-Sync-Fehler zurück, damit DB und Plesk nicht
|
||||||
|
auseinanderlaufen. Vorheriger `additionalForwardingEmails`-Wert
|
||||||
|
wird vor dem Update gemerkt und bei Fail wieder eingespielt.
|
||||||
|
- Smoke-Tests bestätigen: 11 reservierte TLDs abgelehnt, 4 echte
|
||||||
|
TLDs (`de`, `com`, `co.uk`, `museum`) durchgewinkt, Plus-Tag-
|
||||||
|
Strip funktioniert (auch mit Multi-Plus + Casing).
|
||||||
|
|
||||||
|
- [x] **🆕 Stressfrei-Adressen: Zusatz-Weiterleitungen auch beim Anlegen**
|
||||||
|
- Im „Adresse hinzufügen"-Modal erscheint der „Weitere
|
||||||
|
Weiterleitungen"-Button jetzt auch, sobald „Beim E-Mail-Provider
|
||||||
|
anlegen" angehakt ist. Liste wird lokal gepflegt, Provider-Sync
|
||||||
|
läuft direkt nach `createEmail` mit der vollen Liste.
|
||||||
|
- Sub-Modal generalisiert: `value`/`onChange`-Pattern (controlled).
|
||||||
|
Mit `email`-Prop → API-Persist pro Änderung (Edit). Ohne `email`
|
||||||
|
→ lokaler State (Create). Counter-Badge am Button zeigt die
|
||||||
|
Anzahl Adressen.
|
||||||
|
|
||||||
|
- [x] **🆕 Stressfrei-Wechseln-Adressen: zusätzliche Weiterleitungsziele**
|
||||||
|
- Neues Feld `StressfreiEmail.additionalForwardingEmails` (Text/
|
||||||
|
JSON-Array), Migration `20260608100000_stressfrei_email_additional_forwards`
|
||||||
|
mit `IF NOT EXISTS`.
|
||||||
|
- `syncForwardingForEmail` zieht die zusätzlichen Adressen mit
|
||||||
|
in die Plesk-`set:`-Liste ein (case-insensitive Dedup gegen
|
||||||
|
`customer.email` und `config.defaultForwardEmail`).
|
||||||
|
- Neuer Endpoint `PUT /api/stressfrei-emails/:id/additional-forwards`
|
||||||
|
mit Body `{ emails: string[] }` – ersetzt die Liste und syncht
|
||||||
|
direkt mit dem Provider. Hard-Cap 20 Adressen, Format-Check per
|
||||||
|
Regex, Audit-Log.
|
||||||
|
- Im StressfreiEmailModal neuer „Weitere Weiterleitungen"-Button
|
||||||
|
(Edit-Modus + `providerStatus === exists`) öffnet ein Sub-Modal
|
||||||
|
mit Liste + Add/Remove. Jede Änderung geht sofort live.
|
||||||
|
|
||||||
|
- [x] **🐞 Modal-Felder ließen sich nicht editieren (Zähler/Bankkarte/Ausweis/Zählerstand)**
|
||||||
|
- Vier identische Vorkommen desselben Anti-Patterns wie beim
|
||||||
|
AddressModal-Fix von 2026-06-03: `setFormData(getInitialFormData())`
|
||||||
|
im Render-Body, getriggert durch `formData.X !== prop.X`. Jeder
|
||||||
|
Tastendruck setzte den State zurück.
|
||||||
|
- Fix in allen vier Modals (MeterModal, BankCardModal,
|
||||||
|
IdentityDocumentModal, MeterReadingModal): nach `useEffect` mit
|
||||||
|
`[<entity>?.id]`-Dependency umgezogen.
|
||||||
|
|
||||||
|
- [x] **🐞 JpgToPdfModal: PDF blieb trotz vorherigem Fix bei 20+ MB**
|
||||||
|
- Stage-Test: 2 Handy-JPGs → 23 MB PDF. Ursache: Smartphone-Fotos
|
||||||
|
haben 4000-6000 px Kante (24 MP), das vergrößert die JPEG-Datei
|
||||||
|
auch ohne Re-Encode auf 5-10 MB pro Bild.
|
||||||
|
- Fix: Bilder **beim Hinzufügen** auf max. 2400 px lange Kante
|
||||||
|
runterskaliert (~290 DPI auf A4 = Druckqualität) und als JPEG mit
|
||||||
|
Quality 0.92 (Lightroom-Default, kein wahrnehmbarer Unterschied)
|
||||||
|
persistiert. Vorschau-Thumbnail, Rotation/Flip und finaler
|
||||||
|
PDF-Embed laufen alle auf dem skalierten Bild.
|
||||||
|
- Erwartete Größe: 2 Handy-Fotos ≈ 1-2 MB PDF (statt 23 MB).
|
||||||
|
|
||||||
|
- [x] **🆕 Kunden-Detail-Tabs: Pro-Tab-Link „in neuem Tab öffnen"**
|
||||||
|
- `Tabs`-Komponente um optionalen Prop `tabHrefBuilder(tabId)` erweitert.
|
||||||
|
Wenn gesetzt, erscheint neben jedem Tab-Label ein kleines
|
||||||
|
`ExternalLink`-Icon. CustomerDetail übergibt den Builder mit
|
||||||
|
`?tab=<id>`-Query-Param, der eh schon vom URL-Sync genutzt wird.
|
||||||
|
- `target="_blank"` + `rel="noopener noreferrer"` + Klick-stopPropagation,
|
||||||
|
damit der Tab-Wechsel nicht parallel zur Tab-Aktivierung passiert.
|
||||||
|
|
||||||
|
- [x] **🆕 Vertrag: Kunden-/Vertragsnummer bei Vertriebsplattform**
|
||||||
|
- Zwei neue optionale Felder
|
||||||
|
`Contract.customerNumberAtSalesPlatform` +
|
||||||
|
`contractNumberAtSalesPlatform`, Migration
|
||||||
|
`20260603150000_contract_sales_platform_numbers` mit
|
||||||
|
`IF NOT EXISTS`.
|
||||||
|
- Im ContractForm direkt unter „Kundennummer/Vertragsnummer beim
|
||||||
|
Anbieter" angeordnet. ContractDetail zeigt sie als eigene Zeilen
|
||||||
|
mit Copy-Button. Audit-Log-Mapping + Renewal-Copy + XSS-Strip
|
||||||
|
(CONTRACT_DISPLAY_STRING_FIELDS) mitgezogen.
|
||||||
|
- Bonus: das fehlende `contractNumberAtProvider` im Renewal-Copy
|
||||||
|
und Audit-Label-Mapping ist gleich mit drin – wurde bisher
|
||||||
|
nicht in VVL-Folgeverträge kopiert.
|
||||||
|
|
||||||
|
- [x] **🆕 Email-Links öffnen im neuen Tab**
|
||||||
|
- In `EmailDetail` nach der DOMPurify-Sanitize jedes `<a>`-Element
|
||||||
|
auf `target="_blank"` + `rel="noopener noreferrer"` gesetzt. Letzteres
|
||||||
|
verhindert window.opener-Tab-Hijacking. Sanitize + DOM-Walk laufen
|
||||||
|
in einem `useMemo`, das nur bei Wechsel der Email neu rechnet.
|
||||||
|
|
||||||
|
- [x] **🐞 assertSafePdf: jspdf-PDFs mit JPEGs fälschlich als „JavaScript" blockiert**
|
||||||
|
- Stage-Bug: User lädt Ausweis als „JPGs → PDF" hoch → 415 mit
|
||||||
|
Meldung „PDF enthält JavaScript-Action". Backend hat den jspdf-
|
||||||
|
Output korrekt strukturell, aber die JPEG-Bytes im Image-Stream
|
||||||
|
enthielten zufällig die Byte-Folge „/JavaScript" → Pattern-Match.
|
||||||
|
- Fix: vor dem Pattern-Scan `stream..endstream`-Blöcke aus dem
|
||||||
|
PDF-Text rausnehmen. Echte aktive Inhalte stehen IMMER außerhalb
|
||||||
|
von Streams (in PDF-Object-Dictionaries) – Binär-Streams enthalten
|
||||||
|
Bilder/Fonts/Komprimiertes und werden jetzt zu Recht ignoriert.
|
||||||
|
- Smoke-Test: jspdf-Style-PDF mit `/JavaScript`-Bytes im Stream
|
||||||
|
durchgewinkt, echte `/OpenAction /S /JavaScript` weiterhin
|
||||||
|
blockiert, clean PDF weiterhin OK.
|
||||||
|
|
||||||
|
- [x] **🐞 AddressModal: Straße-Feld ließ sich nicht editieren**
|
||||||
|
- `setFormData` wurde unbedingt im Render-Body aufgerufen, wenn
|
||||||
|
`formData.street !== address.street`. Jeder Tastendruck löste neu
|
||||||
|
aus → Reset auf DB-Wert → Cursor sprang zurück → keine Eingabe
|
||||||
|
möglich.
|
||||||
|
- Fix: in `useEffect` mit `[address?.id]`-Dependency umgezogen.
|
||||||
|
Re-Init nur beim Wechsel/Open, nicht bei jedem Render.
|
||||||
|
|
||||||
|
- [x] **🐞 Upload-Limit: Multer 10 MB → 25 MB (Ausweis-Scans, JPGs→PDF)**
|
||||||
|
- Zwei Smartphone-Fotos zu PDF kombiniert kratzten am 10-MB-Limit
|
||||||
|
(auch mit Original-Bytes-Optimierung aus 431792e). Limits in
|
||||||
|
`upload.routes.ts`, `gdpr.routes.ts`, `contract.routes.ts` auf
|
||||||
|
25 MB hochgezogen. `pdfTemplate.routes.ts` war eh schon bei 20 MB.
|
||||||
|
- Frontend: `FileUpload`-Hinweis „max. 10 MB" → „max. 25 MB".
|
||||||
|
|
||||||
|
- [x] **🆕 SIM-Karten: Checkbox „eSIM" zwischen „Hauptkarte" und „Multisim"**
|
||||||
|
- Hardware-Plastikkarte vs. eSIM-Profil ist eine eigene Eigenschaft –
|
||||||
|
eSIM kann sowohl Hauptkarte als auch Multisim sein, also zusätzlich
|
||||||
|
statt entweder/oder.
|
||||||
|
- Schema: `SimCard.isEsim Boolean @default(false)`, Migration
|
||||||
|
`20260603100000_sim_card_esim` mit `IF NOT EXISTS`.
|
||||||
|
- Backend: alle vier SimCard-Schreibpfade in `contract.service.ts`
|
||||||
|
(Create + Update + Follow-Up + Renewal) plus FE-Type-Definition.
|
||||||
|
- UI: dritte Checkbox in `ContractForm` zwischen Hauptkarte und
|
||||||
|
Multisim. ContractDetail zeigt blauen `eSIM`-Badge neben Hauptkarte.
|
||||||
|
|
||||||
|
- [x] **🆕 JpgToPdfModal: PDF-Größe drastisch reduziert (Original-Bytes + Quality 0.95)**
|
||||||
|
- Stage-Bug: 2 Handy-JPGs à 2 MB → PDF >10 MB → Multer 413. Ursache:
|
||||||
|
Canvas-Re-Encode mit JPEG-Quality 1.0 blies jedes Bild auf 8-15 MB
|
||||||
|
auf (Quality 100 % heißt nicht „identisch zum Original", sondern
|
||||||
|
„möglichst viele Bits pro Pixel" – ein schon JPEG-komprimiertes
|
||||||
|
Smartphone-Foto wird so künstlich 4-8× größer).
|
||||||
|
- **Fix 1:** Wenn Rotation/Flip unverändert (Standardfall), wird die
|
||||||
|
Original-DataURL 1:1 in die PDF eingebettet – kein Canvas-Roundtrip,
|
||||||
|
keine Quality-Aufblähung. 2-MB-JPEG bleibt 2 MB. Funktioniert für
|
||||||
|
JPEG und PNG (Format-Detection per `data:image/png`-Prefix).
|
||||||
|
- **Fix 2:** Bei Transformation: `toDataURL('image/jpeg', 0.95)` statt
|
||||||
|
`1.0`. Visuell identisch für Foto-Inhalte (Adobe-Lightroom-Default),
|
||||||
|
aber 50-70 % kleiner.
|
||||||
|
- Kombiniert: 2 untransformierte Handy-Fotos ≈ 4 MB PDF (vorher
|
||||||
|
16-30 MB), 2 gedrehte ≈ 5-8 MB.
|
||||||
|
|
||||||
|
- [x] **🔒 Pentest 70.2 (LOW): falscher 500 statt 415 bei verbotenem MIME-Type**
|
||||||
|
- Globaler Error-Handler in `index.ts:461` matcht
|
||||||
|
`/sind erlaubt|nicht erlaubt/i` und mappt auf 415. Meine 70.1-
|
||||||
|
Message „… WebP erlaubt" (ohne „sind") rutschte durch und landete
|
||||||
|
bei 500 + Error-Log-Spam.
|
||||||
|
- Fix: 1 Zeile in `contract.routes.ts` – `… WebP-Dateien sind
|
||||||
|
erlaubt` macht den Regex glücklich. Andere Routes
|
||||||
|
(`upload.routes.ts`, `gdpr.routes.ts`, `pdfTemplate.routes.ts`,
|
||||||
|
`factoryDefaults.routes.ts`, `appSetting.routes.ts`) nutzen alle
|
||||||
|
schon „sind erlaubt".
|
||||||
|
|
||||||
|
- [x] **🔒 Pentest 70.1 (INFO): GIF/WebP-Inkonsistenz in contract.routes Multer-Filter**
|
||||||
|
- `contract.routes.ts` Vertragsdokumente: Multer-fileFilter blockte
|
||||||
|
`image/gif` und `image/webp`, obwohl `validateUploadedFile` beide
|
||||||
|
Typen zulässt. Folge: GIF mit korrektem MIME `image/gif` → 415, GIF
|
||||||
|
mit gespooftem MIME `image/jpeg` → akzeptiert (vom Magic-Byte als
|
||||||
|
`.gif` erkannt). Kein Sicherheitsproblem (Magic-Byte ist der echte
|
||||||
|
Guard), aber inkonsistent.
|
||||||
|
- Fix: Whitelist um `image/gif` + `image/webp` ergänzt → konsistent
|
||||||
|
zum zentralen `validateUploadedFile` und zu `upload.routes.ts`.
|
||||||
|
|
||||||
|
- [x] **🔒 Pentest 69.3 (INFO → Defense-in-Depth): Magic-Byte-Check auf Vertragsdokumente erweitert**
|
||||||
|
- `contract.routes.ts` Vertragsdokumente-Upload hatte bisher nur den
|
||||||
|
PDF-Inhalts-Scan (`scanUploadedPdfIfPresent` aus 68.1). JPG/PNG-
|
||||||
|
Uploads waren ungeprüft – kompensiert durch Download-Layer
|
||||||
|
(`fileDownload.controller.ts` liefert nur bei Magic-Byte-Match
|
||||||
|
inline aus, sonst attachment). Pentester selbst: "ohne Exploit-
|
||||||
|
Pfad", aber inkonsistent zu `upload.routes.ts`.
|
||||||
|
- **Refactor:** `detectType` + `validateUploadedFile` aus
|
||||||
|
`upload.routes.ts` in neue Middleware
|
||||||
|
`middleware/uploadFileTypeValidator.ts` ausgelagert (Single Source
|
||||||
|
of Truth). Beide Routes nutzen jetzt denselben Helper.
|
||||||
|
- **contract.routes.ts:** `validateUploadedFile` ersetzt das
|
||||||
|
schlankere `scanUploadedPdfIfPresent` – jetzt greift Magic-Byte +
|
||||||
|
canonical Rename + PDF-Scan für Vertragsdokumente analog zu allen
|
||||||
|
anderen Upload-Pfaden.
|
||||||
|
- **pdfUploadSafety.ts:** `scanUploadedPdfIfPresent` entfernt (tot,
|
||||||
|
da nur in contract.routes verwendet wurde). `requireSafeUploadedPdf`
|
||||||
|
bleibt für gdpr.routes Vollmacht + pdfTemplate.routes.
|
||||||
|
|
||||||
|
- [x] **🔒 Pentest 68.1 (LOW) + 68.2 (INFO): PDF-Inhalts-Validierung + Modal-Limit**
|
||||||
|
- **68.1 PDF-Active-Content-Filter:** Magic-Byte-Check prüfte bisher
|
||||||
|
nur `%PDF-`. PDFs mit `/JavaScript`, `/JS`, `/Launch` (externes
|
||||||
|
Programm), `/EmbeddedFile`, `/RichMedia` (Flash) wurden inline an
|
||||||
|
den Viewer ausgeliefert – Browser-PDF-Viewer (Chrome/Firefox)
|
||||||
|
ignorieren JS, Adobe Acrobat aber nicht.
|
||||||
|
- Neuer Helper `assertSafePdf(buf)` in `utils/sanitize.ts`:
|
||||||
|
String-Scan auf die fünf Action-Pattern (case-sensitive nach
|
||||||
|
PDF 32000-1:2008 §7.3.5). Wirft `ApiError(415, ...)` bei Treffer.
|
||||||
|
- Neue Middleware `pdfUploadSafety.ts` mit zwei Varianten:
|
||||||
|
- `requireSafeUploadedPdf` – Datei MUSS PDF sein, sonst 415.
|
||||||
|
- `scanUploadedPdfIfPresent` – durchwinkt JPG/PNG, scannt nur PDFs.
|
||||||
|
- Eingehängt:
|
||||||
|
- `upload.routes.ts` (Magic-Byte-Validator erweitert)
|
||||||
|
- `gdpr.routes.ts` Vollmacht-Upload
|
||||||
|
- `pdfTemplate.routes.ts` Template-Upload
|
||||||
|
- `contract.routes.ts` Vertragsdokumente
|
||||||
|
- `cachedEmail.controller.ts` Email-Anhang-Pfade (3 Stellen:
|
||||||
|
saveAttachmentTo, saveAttachmentAsInvoice,
|
||||||
|
saveAttachmentAsContractDocument)
|
||||||
|
- **Inline-Vorschau bleibt erhalten** – das war die explizite
|
||||||
|
Anforderung (Augen-Button öffnet PDF im neuen Tab). Pentester-
|
||||||
|
Empfehlung „disposition=inline abschalten" wurde bewusst NICHT
|
||||||
|
umgesetzt, weil sie das eigentliche Acrobat-Risiko nicht löst
|
||||||
|
(PDF auf Disk + Doppelklick → Acrobat → JS läuft trotzdem).
|
||||||
|
- Edge-Case-Test bestätigt: `/JSXForm` und `/JavaScriptFooter` werden
|
||||||
|
NICHT als JavaScript-Action erkannt (word-boundary `\b` greift).
|
||||||
|
- **68.2 Modal-Limit:** `JpgToPdfModal` hatte kein Bild-/Größen-Limit.
|
||||||
|
Jetzt `MAX_IMAGES = 50` + `MAX_IMAGE_BYTES = 25 MB` pro Bild.
|
||||||
|
UX-Schutz, kein Security-Bug (Self-DoS only).
|
||||||
|
|
||||||
|
- [x] **🆕 JPGs → PDF: Button überall bei PDF-Upload**
|
||||||
|
- Neue Komponente `JpgToPdfModal` (lokal im Browser via `jspdf`,
|
||||||
|
keine Backend-Round-Trip nötig). Mehrere Bilder hinzufügen per
|
||||||
|
Klick, Drag&Drop oder `Strg+V` (Clipboard-Image), Reihenfolge
|
||||||
|
per Drag&Drop sortierbar, pro Bild 90°/180°-Drehung +
|
||||||
|
Horizontal/Vertikal-Spiegelung. Quality 100%, 1 Bild = 1 Seite,
|
||||||
|
A4 mit automatischer Hoch-/Querformat-Wahl je Bild.
|
||||||
|
- `FileUpload`-Komponente (11 Stellen: Datenschutz-PDF,
|
||||||
|
Vollmacht, Bankkarten-Dokumente, Ausweise, Gewerbeanmeldung,
|
||||||
|
Handelsregister, Kündigungsschreiben + -Bestätigung +
|
||||||
|
deren Optionen) bekommt automatisch einen sekundären
|
||||||
|
"JPGs → PDF"-Button, wenn `accept` PDF einschließt.
|
||||||
|
- Direkt-Inputs ebenfalls erweitert: Vertragsdokumente
|
||||||
|
(ContractDetail), Vollmacht-Dokumente (CustomerDetail Tab),
|
||||||
|
Rechnungen (InvoicesSection).
|
||||||
|
- PdfTemplates **bewusst ausgenommen** – braucht echte
|
||||||
|
AcroForm-PDFs mit Formularfeldern, Bild-PDFs wären unbrauchbar.
|
||||||
|
|
||||||
|
- [x] **🆕 EmailProvider-Settings: Override-Feld „Bezeichnung im UI"**
|
||||||
|
- `customerEmailLabel` existierte im Backend (Schema +
|
||||||
|
Update-Logik + Public-Endpoint), war im UI aber nicht
|
||||||
|
erreichbar – das Label kam ausschließlich aus
|
||||||
|
`deriveLabelFromDomain`. Jetzt neuer optionaler Input
|
||||||
|
„Bezeichnung im UI" unter dem Domain-Block in
|
||||||
|
Einstellungen → E-Mail-Provider. Leer = Auto-Ableitung,
|
||||||
|
befüllt = überschreibt das Tab-Label und alle anderen
|
||||||
|
Stellen, die `customerEmailLabel` nutzen (max 60 Zeichen).
|
||||||
|
|
||||||
|
- [x] **🆕 SIM-Karten: Feld „Kartennutzer"**
|
||||||
|
- Bei Firmen- und Familienverträgen weicht der
|
||||||
|
Vertragsinhaber (Firma/Eltern) vom tatsächlichen Nutzer
|
||||||
|
(Mitarbeiter/Kind) ab. Neuer optionaler `SimCard.cardUser`
|
||||||
|
(String), Migration `20260601100000_sim_card_user` mit
|
||||||
|
`IF NOT EXISTS`.
|
||||||
|
- ContractForm: eigene Zeile „Kartennutzer" oberhalb der
|
||||||
|
technischen SIM-Felder mit erklärendem Placeholder.
|
||||||
|
- ContractDetail: zeigt „Nutzer: \<Name\>" neben den
|
||||||
|
Hauptkarte/Multisim-Badges (nur wenn gefüllt).
|
||||||
|
|
||||||
|
- [x] **🆕 Vorgängervertrag-Modal: Kundennr./Vertragsnr. sichtbar machen**
|
||||||
|
- Die „Anbieter & Tarif"-Card im ContractDetailModal war
|
||||||
|
nur sichtbar, wenn Provider oder Tarif gesetzt waren –
|
||||||
|
Bei Entwürfen ohne Anbieter wurden so auch
|
||||||
|
`customerNumberAtProvider` + `contractNumberAtProvider`
|
||||||
|
versteckt, obwohl sie gepflegt sein können. Fix:
|
||||||
|
Sichtbarkeitsbedingung um die beiden Felder erweitert.
|
||||||
|
|
||||||
|
- [x] **🆕 ContractEmails-Card: Quicklinks auch ohne Postfach**
|
||||||
|
- „Postfach öffnen" + „Stressfrei wechseln Adressen" waren
|
||||||
|
nur im Normal-Zweig sichtbar. Jetzt in einer gemeinsamen
|
||||||
|
`cardTitle`-Variable extrahiert und auch im
|
||||||
|
„Kein-Mailbox"-State sichtbar.
|
||||||
|
|
||||||
|
- [x] **🆕 Vertrags-Forms: Mini-Links zu Stammdaten**
|
||||||
|
- In ContractEmailsSection neben „Postfach öffnen" jetzt
|
||||||
|
zusätzlich „Stressfrei wechseln Adressen" → Kundenakte-Tab.
|
||||||
|
- In ContractForm kleine ExternalLink-Icons neben Select-Labels:
|
||||||
|
Lieferadresse/Rechnungsadresse → `?tab=addresses`,
|
||||||
|
Bankkarte → `?tab=bankcards`, Ausweis → `?tab=documents`,
|
||||||
|
Anbieter + Tarif → `/settings/providers`,
|
||||||
|
Vertriebsplattform → `/settings/platforms`.
|
||||||
|
- Select-Komponente nimmt jetzt `ReactNode` als label
|
||||||
|
(statt nur string), rückwärtskompatibel.
|
||||||
|
- In ContractDetail (Strom/Gas Card-Header) zusätzlich
|
||||||
|
permanenter „Zähler verwalten"-Link in neuem Tab.
|
||||||
|
|
||||||
|
- [x] **🆕 Vertragshistorie: Vertragsnummern als Link**
|
||||||
|
- Erkennt Vertragsnummern (`PREFIX-RANDOM`) in `title` und
|
||||||
|
`description` via Regex, löst sie gegen
|
||||||
|
`previousContract` + `followUpContract` des aktuellen
|
||||||
|
Vertrags auf und rendert sie als Link in neuem Tab.
|
||||||
|
Nicht aufgelöste Nummern bleiben als Text.
|
||||||
|
|
||||||
|
- [x] **🆕 UI-Bug-Fix: „Wurde sondergekündigt?"-Checkbox-Label**
|
||||||
|
- Label-Klasse war `flex` → Block-Layout über volle
|
||||||
|
`col-span-2`-Breite. Klick rechts neben dem Text
|
||||||
|
triggerte ungewollt die Checkbox. Fix: `inline-flex`.
|
||||||
|
|
||||||
|
- [x] **🆕 „Zähler verwalten"-Link im Folgezähler-Form**
|
||||||
|
- In der SuccessorMeterForm (Vertragsansicht) im Header
|
||||||
|
rechts neben „Folgezähler hinzufügen (Zählerwechsel)"
|
||||||
|
ein Link in neuem Tab zur Zähler-Verwaltung des Kunden,
|
||||||
|
damit man bei „Kein passender Zähler verfügbar" direkt
|
||||||
|
rüberspringen kann.
|
||||||
|
|
||||||
|
- [x] **🆕 Folgezähler-Forms: Checkbox „Alten Zähler deaktivieren"**
|
||||||
|
- Beide Folgezähler-Forms (Kundenakte MeterModal +
|
||||||
|
Vertragsansicht SuccessorMeterForm) bekommen eine
|
||||||
|
Checkbox, die standardmäßig angehakt ist. Beim Speichern
|
||||||
|
wird der Vorgänger automatisch auf `isActive=false`
|
||||||
|
gesetzt – ein-klick-fähiger Zählerwechsel.
|
||||||
|
- Backend: `createMeter` mit `successorOf` und
|
||||||
|
`addSuccessorMeter` akzeptieren `deactivatePredecessor`
|
||||||
|
(Default true).
|
||||||
|
|
||||||
|
- [x] **🆕 Kundenakte → Zähler: Verträge-Aufklappliste + Filter**
|
||||||
|
- Pro Zähler ein „Verträge (N)"-Aufklapp, listet alle
|
||||||
|
Verträge auf, die diesen Zähler nutzen – als Hauptzähler
|
||||||
|
(`energyDetails.meterId`) oder über Folgezähler-Kette
|
||||||
|
(`ContractMeter`). Dedupliziert. Jeder Eintrag ist Link
|
||||||
|
in neuen Tab mit Vertragsnummer, Anbieter, Status-Badge.
|
||||||
|
- Neue Checkbox „Zähler ohne Verträge anzeigen" neben
|
||||||
|
„Inaktive anzeigen". Filtert die Liste auf Orphans
|
||||||
|
(Zähler ohne Vertragszuordnung):
|
||||||
|
| Inaktive | ohne Verträge | Ergebnis |
|
||||||
|
|----------|---------------|----------|
|
||||||
|
| ☐ | ☐ | nur aktive Zähler (Default) |
|
||||||
|
| ☑ | ☐ | alle Zähler |
|
||||||
|
| ☐ | ☑ | aktive Zähler ohne Vertrag |
|
||||||
|
| ☑ | ☑ | alle Zähler ohne Vertrag |
|
||||||
|
|
||||||
|
- [x] **🆕 Folgezähler-Button auch bei Single-Meter-Verträgen**
|
||||||
|
- Folgeverträge ohne ContractMeter-Eintrag (alte Daten oder
|
||||||
|
pure Single-Meter-Verträge) bekamen den Button nie zu
|
||||||
|
Gesicht. Fix: Button wird jetzt aus dem if/else gerendert,
|
||||||
|
sobald entweder ein Single-Meter oder ContractMeter-
|
||||||
|
Einträge vorhanden sind. Im Backend wird der bisherige
|
||||||
|
`energyDetails.meterId` bei Single-Meter-Verträgen
|
||||||
|
automatisch als ContractMeter (position 0,
|
||||||
|
`removedAt` = Wechseldatum) backfillt, damit der alte
|
||||||
|
Zähler in der Historie bleibt.
|
||||||
|
|
||||||
|
- [x] **🆕 Multi-Meter-Verbrauch auf Vertragslaufzeit clampen**
|
||||||
|
- Bei Verträgen, die Vorgänger einer Folgevertrags-Kette
|
||||||
|
sind, hängen über `ContractMeter` auch Folgezähler dran,
|
||||||
|
die nach Vertragsende installiert wurden. Die Berechnung
|
||||||
|
nahm `cm.installedAt..cm.removedAt` 1:1 ohne Clamp gegen
|
||||||
|
`contract.startDate/endDate` – damit flossen Zählerstände
|
||||||
|
aus der Folgevertrags-Phase in den Verbrauch des
|
||||||
|
Vorvertrags ein. Fix:
|
||||||
|
`meterStart = max(installedAt, contractStart)`,
|
||||||
|
`meterEnd = min(removedAt, contractEnd)`,
|
||||||
|
Zähler komplett außerhalb der Laufzeit werden übersprungen.
|
||||||
|
|
||||||
|
- [x] **🆕 Vertragsansicht: Standort + Inaktiv-Badge beim Zähler**
|
||||||
|
- Strom/Gas-Card zeigt jetzt neben der Zählernummer ein
|
||||||
|
rotes „Inaktiv"-Badge (falls deaktiviert) und darunter
|
||||||
|
eine kleine Zeile „Standort: …" (falls gepflegt).
|
||||||
|
|
||||||
|
- [x] **🛡️ Pentest 43.5 (INFO) + 43.6 (MEDIUM): History-XSS + blocked:-Marker**
|
||||||
|
- 43.6: `ContractHistoryEntry.title` + `.description` waren
|
||||||
|
auf beiden Pfaden ungestrippt – Admin konnte HTML/Script-
|
||||||
|
Tags einschreiben, Portal-User las sie roh zurück. Fix:
|
||||||
|
`stripHtml()` auf Create + Update (Write-Pfad) und
|
||||||
|
`sanitizeEntry()` im List + Get (Read-Pfad).
|
||||||
|
- 43.5: `stripHtml` ersetzt `javascript:` → `blocked:` –
|
||||||
|
sinnvoll bei URL-Feldern, hässlich in Tarif-/Preis-Namen.
|
||||||
|
Neuer `stripForDisplay`-Wrapper entfernt den Marker
|
||||||
|
zusätzlich in den Display-Feldern.
|
||||||
|
|
||||||
|
- [x] **🛡️ Pentest 42.5 (MEDIUM): priceFirst12Months XSS**
|
||||||
|
- Drei Preisfelder sind im Schema `String?` (freitextliche
|
||||||
|
Tarifangaben). `sanitizeContract` strippte sie auf dem
|
||||||
|
Read-Pfad nicht – Alt-Daten mit XSS-Payloads kamen 1:1
|
||||||
|
raus. Fix: `priceFirst12Months`, `priceFrom13Months`,
|
||||||
|
`priceAfter24Months` in `CONTRACT_DISPLAY_STRING_FIELDS`
|
||||||
|
aufgenommen.
|
||||||
|
|
||||||
|
- [x] **🛡️ Anzeige-Defense: HTML in display-relevanten Strings strippen**
|
||||||
|
- `sanitizeContract` und `sanitizeCustomer` strippen jetzt
|
||||||
|
zusätzlich HTML in den definierten Display-Feldern
|
||||||
|
(`providerName`, `tariffName`, `customerNumberAtProvider`,
|
||||||
|
`firstName`, `lastName`, `companyName`, etc.). Wirkt auch
|
||||||
|
auf nested `previousContract` + `energyDetails`. Damit
|
||||||
|
sehen Pentester-Payloads (`<script>`, `<img onerror>`)
|
||||||
|
in der Vertragsliste nicht mehr als hässlicher Klartext
|
||||||
|
aus, sondern verschwinden komplett.
|
||||||
|
|
||||||
|
- [x] **🆕 Vorvertrag-Verbrauch als Schätzwert im Folgevertrag**
|
||||||
|
- **ContractForm** (Strom/Gas): Wenn ein `previousContractId`
|
||||||
|
gesetzt ist, wird der Vorvertrag samt Readings nachgeladen und
|
||||||
|
der Verbrauch clientseitig über
|
||||||
|
`calculateMultiMeterConsumption` / `calculateConsumption`
|
||||||
|
berechnet. Unter dem Jahresverbrauch-Feld erscheint
|
||||||
|
`Vorvertrag: 1.698 kWh (hochgerechnet) [Übernehmen]` mit
|
||||||
|
Ein-Klick-Button, der den Wert ins Feld kopiert. Bei Gas
|
||||||
|
erscheint der Hinweis sowohl unter „Jahresverbrauch (m³)"
|
||||||
|
(mit m³-Wert) als auch unter „Jahresverbrauch (kWh)".
|
||||||
|
- **ContractDetail** (Strom/Gas): Wenn `annualConsumption` leer
|
||||||
|
ist und ein berechenbarer Vorvertrag existiert, wird die
|
||||||
|
Jahresverbrauch-Zelle stattdessen mit `~1.698 kWh` in blau
|
||||||
|
angezeigt, darunter klein „geschätzt aus Vorvertrag
|
||||||
|
(hochgerechnet)". Verschwindet automatisch, sobald der Wert
|
||||||
|
im Vertrag eingetragen ist.
|
||||||
|
- Funktioniert nur bei Verträgen mit explizitem `previousContract`
|
||||||
|
(Folgevertrag-Kette). Ohne Vorvertrag oder ohne genügend
|
||||||
|
Zählerstände kommt kein Hinweis.
|
||||||
|
|
||||||
|
- [x] **🆕 Endstand alter Zähler fließt in Verbrauchsberechnung ein**
|
||||||
|
- Bisher wurde der Wert „Letzter Stand alter Zähler" zwar als
|
||||||
|
`ContractMeter.finalReading` gespeichert, aber nirgends gelesen
|
||||||
|
– weder in der UI noch in `calculateMultiMeterConsumption`.
|
||||||
|
- Neuer Helper `recordPredecessorFinalReading(meterId, switchAt, value)`
|
||||||
|
in customer.service.ts: legt am Wechseldatum einen regulären
|
||||||
|
`MeterReading`-Eintrag für den Vorgänger an (Notes:
|
||||||
|
„Endstand bei Zählerwechsel"). Idempotent: existiert am
|
||||||
|
Wechseltag schon ein Reading, wird nichts geschrieben.
|
||||||
|
Validierung (monoton-steigend) wird vorab durchgeführt –
|
||||||
|
Konflikt führt zu sprechender 400-Fehlermeldung, ohne
|
||||||
|
halb-geschriebene Zustände zu hinterlassen.
|
||||||
|
- Wird aus beiden Pfaden aufgerufen: `addSuccessorMeter` im
|
||||||
|
contract.controller (Vertragsansicht → „Folgezähler hinzufügen")
|
||||||
|
und `createMeter` mit `successorOf` im customer.service
|
||||||
|
(Kundenakte → „Als Folgezähler deklarieren").
|
||||||
|
- Folge: Der Endstand erscheint jetzt in der Zählerstände-Liste
|
||||||
|
des Vorgänger-Zählers und fließt über
|
||||||
|
`calculateMultiMeterConsumption` automatisch in den Verbrauch
|
||||||
|
(Zeitraum bis `removedAt` ist inklusive).
|
||||||
|
- UI-Hinweise im Folgezähler-Form (Vertragsansicht + MeterModal)
|
||||||
|
erklären den neuen Effekt.
|
||||||
|
|
||||||
|
- [x] **🆕 Folgezähler-Button auch bei Single-Meter-Verträgen**
|
||||||
|
- Bisher nur sichtbar im Multi-Meter-Zweig (`contractMeters.length > 0`)
|
||||||
|
– Folgeverträge ohne ContractMeter-Eintrag konnten so keinen
|
||||||
|
Folgezähler bekommen.
|
||||||
|
- Fix: Button wird jetzt aus dem if/else-Block gerendert, sobald
|
||||||
|
entweder ein Single-Meter (`energyDetails.meter`) oder
|
||||||
|
ContractMeter-Einträge vorhanden sind.
|
||||||
|
- Im Backend `addSuccessorMeter`: bei Single-Meter-Verträgen wird
|
||||||
|
der bisherige `energyDetails.meterId` automatisch als
|
||||||
|
ContractMeter (position 0, `removedAt` = Wechseldatum) backfillt,
|
||||||
|
damit der alte Zähler nicht aus der Vertragshistorie verschwindet.
|
||||||
|
|
||||||
|
- [x] **🆕 Folgezähler-Deklaration in der Kundenakte (Auto-Propagation)**
|
||||||
|
- **Backend**: Neues Feld `Meter.predecessorMeterId`
|
||||||
|
(Self-Relation, `ON DELETE SET NULL`). Migration
|
||||||
|
`20260530140000_meter_predecessor` mit `IF NOT EXISTS`.
|
||||||
|
`createMeter` akzeptiert optional `successorOf: { predecessorMeterId,
|
||||||
|
installedAt?, finalReadingPrevious? }`. Wenn gesetzt: Vorgänger
|
||||||
|
wird validiert (gleicher Kunde + gleicher Typ), und für alle
|
||||||
|
Verträge, die den Vorgänger als aktuellen Zähler nutzen, wird
|
||||||
|
der ContractMeter-Eintrag analog zu `addSuccessorMeter`
|
||||||
|
propagiert (vorhandener ContractMeter wird `removedAt` +
|
||||||
|
`finalReading` gesetzt; neuer ContractMeter wird mit nächster
|
||||||
|
Position + `installedAt` angelegt; `energyDetails.meterId`
|
||||||
|
auf den Neuzähler aktualisiert). Idempotent gegen Doppel-Klick.
|
||||||
|
- **MeterModal** (Kundenakte → Zähler): Bei Neuanlage neue
|
||||||
|
Checkbox „Diesen Zähler als Folgezähler deklarieren". Wenn
|
||||||
|
aktiv: Dropdown Vorgänger-Zähler (alle Zähler des Kunden,
|
||||||
|
inkl. inaktive – mit Suffix), Wechseldatum (default heute),
|
||||||
|
Endstand alter Zähler (optional). Bei Vorgänger-Auswahl werden
|
||||||
|
Typ, Tarifmodell und Adresse vom Vorgänger übernommen und
|
||||||
|
disabled. Info-Banner: „Alle Verträge mit dem alten Zähler
|
||||||
|
werden automatisch umgestellt".
|
||||||
|
- Audit-Log: „Zähler angelegt als Folgezähler von X für Kunde #N".
|
||||||
|
|
||||||
|
- [x] **🆕 Vertragsansicht: Standort + Inaktiv-Badge beim Zähler**
|
||||||
|
- In den Strom/Gas-Details neben der Zählernummer zusätzlich ein
|
||||||
|
rotes „Inaktiv"-Badge und eine Zeile mit Standort, falls
|
||||||
|
hinterlegt.
|
||||||
|
|
||||||
|
- [x] **🆕 Zähler → Lieferadresse-Pflichtfeld + Vertragsfilter**
|
||||||
|
- **Backend**: Neues Feld `Meter.addressId` (optional FK auf
|
||||||
|
`Address`, `ON DELETE SET NULL`). Migration
|
||||||
|
`20260530100000_meter_address` mit `IF NOT EXISTS`. Service
|
||||||
|
erzwingt beim Create: Lieferadresse muss vorhanden, zum Kunden
|
||||||
|
gehören und Typ `DELIVERY_RESIDENCE` haben.
|
||||||
|
- **MeterModal** (Kundenakte → Zähler): Pflicht-Dropdown
|
||||||
|
"Lieferadresse" über allen Feldern. Button "Zähler hinzufügen"
|
||||||
|
ist disabled, wenn keine Lieferadresse existiert – mit gelbem
|
||||||
|
Hinweis-Banner. Bestandszähler ohne Adresse zeigen im
|
||||||
|
MetersTab "Lieferadresse: nicht zugeordnet – bitte über
|
||||||
|
Bearbeiten nachpflegen" in gelb.
|
||||||
|
- **ContractForm** (Strom/Gas): Zähler-Dropdown filtert jetzt
|
||||||
|
auf die im Vertrag gewählte Lieferadresse. Deaktivierte
|
||||||
|
Zähler bleiben sichtbar (Label-Suffix `(deaktiviert)`); wenn
|
||||||
|
sie ausgewählt werden, kommt ein Toast: *„Deaktivierter
|
||||||
|
Zähler ausgewählt. Ist das gewollt? Handelt es sich um einen
|
||||||
|
Altvertrag?"*. Platzhalter wechselt zwischen "Erst
|
||||||
|
Lieferadresse wählen…" / "Kein Zähler für diese Adresse
|
||||||
|
vorhanden" / "Zähler wählen…".
|
||||||
|
- Audit-Log loggt Adress-Änderung am Zähler als Feld
|
||||||
|
"Lieferadresse".
|
||||||
|
|
||||||
- [x] **🆕 Backup-Operations-Log + EBUSY-Fix beim Restore**
|
- [x] **🆕 Backup-Operations-Log + EBUSY-Fix beim Restore**
|
||||||
- Zwei neue Log-Panels auf der DB-Backup-Seite: links
|
- Zwei neue Log-Panels auf der DB-Backup-Seite: links
|
||||||
"Backup-Erstellung", rechts "Backup-Wiederherstellung". Jeder
|
"Backup-Erstellung", rechts "Backup-Wiederherstellung". Jeder
|
||||||
@@ -120,6 +962,124 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
- **Live-verifiziert**: 4867 Datensätze + 1 Datei in 13.2s
|
- **Live-verifiziert**: 4867 Datensätze + 1 Datei in 13.2s
|
||||||
wiederhergestellt, Log-Modal zeigt den vollständigen Verlauf.
|
wiederhergestellt, Log-Modal zeigt den vollständigen Verlauf.
|
||||||
|
|
||||||
|
- [x] **🛡️ Pentest 2026-05-28 LOW 34.5: Backend-URL-Validierung für AppSettings**
|
||||||
|
- Schema-Whitelist + Trailing-Slash-Strip standen NUR im Frontend.
|
||||||
|
API-Endpoint akzeptierte sonst `/relative/path`, `javascript:`,
|
||||||
|
`ftp://`, `http://192.168.1.1` etc. → Open-Redirect / SSRF-Vektor
|
||||||
|
in den an Kunden verschickten Portal-Mails.
|
||||||
|
- Neuer `validateSettingValue(key, value)` in appSetting.service.ts
|
||||||
|
mit per-Key-Logik: portalLoginUrl → nur http(s), absoluter Host,
|
||||||
|
`isBlockedSsrfHost`-Check, Trailing-Slash-Strip. Schwellenwerte
|
||||||
|
(deadline*/documentExpiry*) → positive Integer. Bool-Settings
|
||||||
|
→ strict `true`/`false`. monitoringAlertEmail → RFC-5322-light.
|
||||||
|
- Controller (updateSetting + updateSettings) ruft Validator nach
|
||||||
|
der HTML-Strip-Sanitisierung; bei Fehler 400 mit aussagekräftiger
|
||||||
|
Message. Bulk-PUT validiert ALLE Werte bevor irgendwas gespeichert
|
||||||
|
wird (kein halb-committed-State bei einem ungültigen Eintrag).
|
||||||
|
- **Live-verifiziert** auf dev:
|
||||||
|
- `/evil/path` → 400 "muss absolute http(s)-URL sein"
|
||||||
|
- `javascript:alert(1)` → 400 (durch stripHtml zu blocked: → Validator: unzulässiges Schema)
|
||||||
|
- `ftp://evil.com` / `data:text/html` → 400
|
||||||
|
- `http://169.254.169.254` → 400 (Cloud-Metadata immer geblockt)
|
||||||
|
- `http://192.168.1.1` → 200 (on-prem-Default; mit SSRF_BLOCK_PRIVATE_IPS=true → 400)
|
||||||
|
- `https://crm.example.de/` → DB: `https://crm.example.de` (Slash gestrippt)
|
||||||
|
- `https://crm.example.de//abc/` → DB: `https://crm.example.de//abc`
|
||||||
|
(nur trailing slash; doppelte slashes mittendrin bleiben)
|
||||||
|
|
||||||
|
- [x] **🐛 Bugfix: Portal-Passwörter in Verträgen wurden mutiliert**
|
||||||
|
- Folgefehler aus Pentest 31.1 (Stored-XSS-Strip): die rekursive
|
||||||
|
`sanitizeContractBody`-Funktion lief auch über `portalPassword`.
|
||||||
|
Passwörter mit HTML-Pattern (`Pass<TAG>word!` → `Password!`) oder
|
||||||
|
URI-Schema-Prefix (`data:secret` → `blocked:secret`) wurden
|
||||||
|
irreparabel zerstört.
|
||||||
|
- Fix: `PASSTHROUGH_KEYS = {'portalPassword', 'password'}` – beim
|
||||||
|
Walk werden String-Werte unter diesen Keys NICHT durch
|
||||||
|
`stripHtml` geschickt. PW wird sowieso `encrypt()`-verschlüsselt
|
||||||
|
persistiert und niemals als HTML ausgegeben → kein XSS-Risk.
|
||||||
|
- Live-verifiziert: PW `MyP@ss<word>123!&data:foo` → byte-genau im
|
||||||
|
GET-Decrypt-Endpoint zurück. `providerName: <script>…` → weiter
|
||||||
|
auf `EvilProvider` gestrippt. `portalUsername: u<test>` → weiter
|
||||||
|
auf `u` gestrippt.
|
||||||
|
|
||||||
|
- [x] **🛡️ Pentest 2026-05-24 Pen-31-Befunde (2× MEDIUM)**
|
||||||
|
- **31.1 Stored XSS in Vertragsfeldern**: `providerName`, `tariffName`,
|
||||||
|
`priceFirst12Months`, `priceFrom13Months`, `priceAfter24Months`
|
||||||
|
nahmen rohe HTML/Script-Payloads an und lieferten sie 1:1 zurück.
|
||||||
|
Fix: rekursiver `sanitizeContractBody()` (Walk-and-Strip) im
|
||||||
|
contract.controller wird auf `req.body` von POST + PUT
|
||||||
|
angewandt. Nutzt das bestehende `stripHtml()` aus utils/sanitize,
|
||||||
|
inkl. URI-Schema-Block + Entity-Decode. Verträge enthalten keine
|
||||||
|
legitimen HTML-Felder (Editor-HTML lebt in AppSettings), daher
|
||||||
|
Strip ohne Risiko. Audit-Vergleich nutzt jetzt die sanitisierte
|
||||||
|
Version, sonst Audit ↔ DB-Drift.
|
||||||
|
- **31.2 IDOR auf `GET /customers/:id/stressfrei-emails`** (und 4
|
||||||
|
weiteren Routes mit `requireCustomerAccess`): das Middleware
|
||||||
|
short-circuitete auf `customers:read` – aber Portal-User haben
|
||||||
|
diese Perm im JWT (für eigene Daten). Damit kam Portal-Kunde 1
|
||||||
|
an IMAP-Konten/Adressen/Bank-Cards/Documents/Meters von
|
||||||
|
Kunde 3. Fix in `middleware/auth.ts:requireCustomerAccess`:
|
||||||
|
erst `isCustomerPortal`-Check (eigene + vertretene IDs), DANN
|
||||||
|
erst Perm-Check für Mitarbeiter. Damit sind alle 6 Routes
|
||||||
|
mit einem Middleware-Patch dicht. Defense-in-Depth: in
|
||||||
|
`stressfreiEmail.controller.getEmailsByCustomer` zusätzlich
|
||||||
|
`canAccessCustomer`-Call analog zum POST-Handler.
|
||||||
|
- **Infos** (keine Code-Änderung):
|
||||||
|
- `type:"STROM"` ist deprecated – richtige Enum ist `ELECTRICITY`.
|
||||||
|
- HSTS auf Staging fehlt: HSTS macht der nginx-Reverse-Proxy,
|
||||||
|
Backend setzt's bewusst nicht (Doppel-Header-Vermeidung).
|
||||||
|
Auf Staging muss der Proxy-Op das HSTS-Header-Add aktivieren.
|
||||||
|
- Portal-Login-Rate-Limit 5 vs 10: Env-Drift, identische Codebase.
|
||||||
|
- **Live-verifiziert** auf dev:
|
||||||
|
- Portal-User 1 vs Customer 3: alle 6 Routes 403
|
||||||
|
(`/customers/3`, `.../addresses`, `.../bank-cards`,
|
||||||
|
`.../documents`, `.../meters`, `.../stressfrei-emails`).
|
||||||
|
- XSS-Payloads `<script>`, `<svg/onload>`, `<img onerror>`,
|
||||||
|
`javascript:`, `<script>` in 5 Vertragsfeldern →
|
||||||
|
DB-Werte bereinigt (`EvilProvider`, `blocked:alert(4) 35€` etc.).
|
||||||
|
|
||||||
|
- [x] **🆕 Vertragsansicht: Kunden-Schnellansicht-Modal + Cent/Euro-Doppel-Input**
|
||||||
|
- **Info-Icon neben Kundennamen** öffnet ein Modal mit den
|
||||||
|
wichtigsten Kundendaten (Firma, Name, Geburtsdatum/-ort,
|
||||||
|
Gründungsdatum, primäre Adresse, Telefon, Mobil, E-Mail,
|
||||||
|
Portal-E-Mail, Steuer-/Handelsregister-Nr.). Jedes Feld hat
|
||||||
|
einen Copy-Button (bestehende `CopyableValue`-Komponente).
|
||||||
|
Neue Komponente: `CustomerInfoModal.tsx`. Lazy-Fetch via
|
||||||
|
`customerApi.getById`, staleTime 30s.
|
||||||
|
- **Cent/Euro-Doppel-Input** für Arbeitspreise (Strom + Gas):
|
||||||
|
Neben dem €/kWh-Feld jetzt ein zweites Feld ct/kWh. Bidirektional
|
||||||
|
verkoppelt – Tippen in € aktualisiert ct (×100), Tippen in ct
|
||||||
|
aktualisiert € (÷100). Im Backend wird unverändert nur der
|
||||||
|
Euro-Wert persistiert; Cent ist reine UI-Hilfe. Float-Rausch-
|
||||||
|
Schutz (Math.round × 1e6) verhindert "0.25 → 25.0000000000…".
|
||||||
|
Greift für `unitPrice` und (bei DUAL-Zählern) `unitPriceNt`.
|
||||||
|
|
||||||
|
- [x] **🆕 Bonus-Feld aufgeteilt: Sofort-Bonus + Neukunden-Bonus (Strom/Gas)**
|
||||||
|
- Bisher gab es ein einzelnes `bonus`-Feld auf `EnergyContractDetails`.
|
||||||
|
Jetzt zwei Felder `instantBonus` (Sofort) und `newCustomerBonus`
|
||||||
|
(Neukunden), die zusammen den Gesamtbonus ergeben.
|
||||||
|
- **Migration** `20260524100000_split_energy_bonus`:
|
||||||
|
`ADD COLUMN IF NOT EXISTS instantBonus`, `... newCustomerBonus`,
|
||||||
|
`UPDATE` kopiert bestehendes `bonus` → `instantBonus`
|
||||||
|
(Annahme: Bestandsverträge hatten primär Sofort-Bonus), dann
|
||||||
|
`DROP COLUMN IF EXISTS bonus`. Idempotent.
|
||||||
|
- **Form** (`ContractForm.tsx`): zwei Input-Felder „Sofort-Bonus (€)"
|
||||||
|
+ „Neukunden-Bonus (€)" statt einem.
|
||||||
|
- **Detail-Ansicht**: zeigt beide Felder einzeln und einen
|
||||||
|
aggregierten „Gesamtbonus" (fett) darunter.
|
||||||
|
- **Kostenvorschau**: listet Sofort + Neukunden einzeln auf
|
||||||
|
(jeweils grün), dann „Gesamtbonus" (mittel grün, fett), danach
|
||||||
|
Effektive Jahreskosten.
|
||||||
|
- **Cost-Calc** (`energyCalculations.ts`): `calculateCosts()` nimmt
|
||||||
|
jetzt beide Bonus-Werte; `CostCalculation` liefert `instantBonus`,
|
||||||
|
`newCustomerBonus` + `totalBonus`.
|
||||||
|
- **PDF-Template-Variablen**: drei neue Placeholder
|
||||||
|
`energyDetails.instantBonus`, `.newCustomerBonus`, `.totalBonus`
|
||||||
|
(alter `.bonus`-Placeholder entfernt).
|
||||||
|
- **Audit-Log** unterscheidet jetzt beide Felder.
|
||||||
|
- **Live-verifiziert** auf dev: PUT mit beiden Feldern → DB hat
|
||||||
|
`instantBonus=75, newCustomerBonus=125`, GET liefert beide
|
||||||
|
Werte zurück.
|
||||||
|
|
||||||
- [x] **🛡️ Pentest 2026-05-20 Pen-30-Befunde (MEDIUM+INFO)**
|
- [x] **🛡️ Pentest 2026-05-20 Pen-30-Befunde (MEDIUM+INFO)**
|
||||||
- **30.13 MIME-Extension-XSS** (MEDIUM): `GET /api/files/download`
|
- **30.13 MIME-Extension-XSS** (MEDIUM): `GET /api/files/download`
|
||||||
lieferte hochgeladene Dateien via `res.sendFile` aus. Da multer
|
lieferte hochgeladene Dateien via `res.sendFile` aus. Da multer
|
||||||
|
|||||||
Generated
+190
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "opencrm-frontend",
|
"name": "opencrm-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "opencrm-frontend",
|
"name": "opencrm-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.59.0",
|
"@tanstack/react-query": "^5.59.0",
|
||||||
"@tiptap/extension-link": "^3.19.0",
|
"@tiptap/extension-link": "^3.19.0",
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
"@tiptap/starter-kit": "^3.19.0",
|
"@tiptap/starter-kit": "^3.19.0",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"dompurify": "^3.4.1",
|
"dompurify": "^3.4.1",
|
||||||
|
"jspdf": "^4.2.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",
|
||||||
@@ -264,6 +265,14 @@
|
|||||||
"@babel/core": "^7.0.0-0"
|
"@babel/core": "^7.0.0-0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/runtime": {
|
||||||
|
"version": "7.29.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||||
|
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/template": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.28.6",
|
"version": "7.28.6",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||||
@@ -1632,11 +1641,22 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
|
||||||
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="
|
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/pako": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="
|
||||||
|
},
|
||||||
"node_modules/@types/prop-types": {
|
"node_modules/@types/prop-types": {
|
||||||
"version": "15.7.15",
|
"version": "15.7.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="
|
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/raf": {
|
||||||
|
"version": "3.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
|
||||||
|
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "18.3.27",
|
"version": "18.3.27",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
||||||
@@ -1767,6 +1787,15 @@
|
|||||||
"proxy-from-env": "^1.1.0"
|
"proxy-from-env": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/base64-arraybuffer": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.9.16",
|
"version": "2.9.16",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.16.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.16.tgz",
|
||||||
@@ -1874,6 +1903,25 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/canvg": {
|
||||||
|
"version": "3.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
|
||||||
|
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.12.5",
|
||||||
|
"@types/raf": "^3.4.0",
|
||||||
|
"core-js": "^3.8.3",
|
||||||
|
"raf": "^3.4.1",
|
||||||
|
"regenerator-runtime": "^0.13.7",
|
||||||
|
"rgbcolor": "^1.0.1",
|
||||||
|
"stackblur-canvas": "^2.0.0",
|
||||||
|
"svg-pathdata": "^6.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||||
@@ -1936,11 +1984,31 @@
|
|||||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/core-js": {
|
||||||
|
"version": "3.49.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
|
||||||
|
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"optional": true,
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/core-js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/crelt": {
|
"node_modules/crelt": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||||
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="
|
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="
|
||||||
},
|
},
|
||||||
|
"node_modules/css-line-break": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"utrie": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cssesc": {
|
"node_modules/cssesc": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||||
@@ -2169,6 +2237,16 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-png": {
|
||||||
|
"version": "6.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
|
||||||
|
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/pako": "^2.0.3",
|
||||||
|
"iobuffer": "^5.3.2",
|
||||||
|
"pako": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fastq": {
|
"node_modules/fastq": {
|
||||||
"version": "1.20.1",
|
"version": "1.20.1",
|
||||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||||
@@ -2178,6 +2256,11 @@
|
|||||||
"reusify": "^1.0.4"
|
"reusify": "^1.0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fflate": {
|
||||||
|
"version": "0.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||||
|
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="
|
||||||
|
},
|
||||||
"node_modules/fill-range": {
|
"node_modules/fill-range": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
@@ -2370,6 +2453,24 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/html2canvas": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"css-line-break": "^2.1.0",
|
||||||
|
"text-segmentation": "^1.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/iobuffer": {
|
||||||
|
"version": "5.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
|
||||||
|
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA=="
|
||||||
|
},
|
||||||
"node_modules/is-binary-path": {
|
"node_modules/is-binary-path": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||||
@@ -2465,6 +2566,22 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jspdf": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.28.6",
|
||||||
|
"fast-png": "^6.2.0",
|
||||||
|
"fflate": "^0.8.1"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"canvg": "^3.0.11",
|
||||||
|
"core-js": "^3.6.0",
|
||||||
|
"dompurify": "^3.3.1",
|
||||||
|
"html2canvas": "^1.0.0-rc.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lilconfig": {
|
"node_modules/lilconfig": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||||
@@ -2667,12 +2784,23 @@
|
|||||||
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
||||||
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="
|
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="
|
||||||
},
|
},
|
||||||
|
"node_modules/pako": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug=="
|
||||||
|
},
|
||||||
"node_modules/path-parse": {
|
"node_modules/path-parse": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/performance-now": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -3075,6 +3203,15 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/raf": {
|
||||||
|
"version": "3.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
|
||||||
|
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"performance-now": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
@@ -3189,6 +3326,12 @@
|
|||||||
"node": ">=8.10.0"
|
"node": ">=8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/regenerator-runtime": {
|
||||||
|
"version": "0.13.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||||
|
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
@@ -3219,6 +3362,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rgbcolor": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.15"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.55.2",
|
"version": "4.55.2",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.2.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.2.tgz",
|
||||||
@@ -3317,6 +3469,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/stackblur-canvas": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.1.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/sucrase": {
|
"node_modules/sucrase": {
|
||||||
"version": "3.35.1",
|
"version": "3.35.1",
|
||||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
||||||
@@ -3351,6 +3512,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/svg-pathdata": {
|
||||||
|
"version": "6.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
|
||||||
|
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tailwindcss": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "3.4.19",
|
"version": "3.4.19",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||||
@@ -3388,6 +3558,15 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/text-segmentation": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"utrie": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/thenify": {
|
"node_modules/thenify": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
||||||
@@ -3534,6 +3713,15 @@
|
|||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/utrie": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"base64-arraybuffer": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "5.4.21",
|
"version": "5.4.21",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"@tiptap/starter-kit": "^3.19.0",
|
"@tiptap/starter-kit": "^3.19.0",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"dompurify": "^3.4.1",
|
"dompurify": "^3.4.1",
|
||||||
|
"jspdf": "^4.2.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",
|
||||||
|
|||||||
@@ -75,7 +75,8 @@ export default function ContractDetailModal({ contractId, isOpen, onClose }: Con
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Anbieter & Tarif */}
|
{/* Anbieter & Tarif */}
|
||||||
{(c.providerName || c.provider?.name || c.tariffName || c.tariff?.name) && (
|
{(c.providerName || c.provider?.name || c.tariffName || c.tariff?.name
|
||||||
|
|| c.customerNumberAtProvider || c.contractNumberAtProvider) && (
|
||||||
<Card title="Anbieter & Tarif">
|
<Card title="Anbieter & Tarif">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{(c.providerName || c.provider?.name) && (
|
{(c.providerName || c.provider?.name) && (
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Plus, Edit, Trash2, ChevronDown, ChevronUp, History, Clock, Bot, User } from 'lucide-react';
|
import { Plus, Edit, Trash2, ChevronDown, ChevronUp, History, Clock, Bot, User } from 'lucide-react';
|
||||||
import Modal from '../ui/Modal';
|
import Modal from '../ui/Modal';
|
||||||
@@ -11,11 +12,57 @@ import type { ContractHistoryEntry } from '../../types';
|
|||||||
interface ContractHistorySectionProps {
|
interface ContractHistorySectionProps {
|
||||||
contractId: number;
|
contractId: number;
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
|
// Map: contractNumber → contractId. Wird genutzt um in title/description
|
||||||
|
// erwähnte Vertragsnummern als Link auf den jeweiligen Vertrag zu rendern.
|
||||||
|
// Aufgebaut aus previousContract + followUpContract des aktuellen Vertrags.
|
||||||
|
knownContracts?: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vertragsnummer-Pattern: 3 Großbuchstaben + Bindestrich + Alphanumerisch
|
||||||
|
// (siehe backend/src/utils/helpers.ts generateContractNumber).
|
||||||
|
const CONTRACT_NUMBER_REGEX = /\b([A-Z]{3}-[A-Z0-9]{6,})\b/g;
|
||||||
|
|
||||||
|
// Rendert einen Text und ersetzt enthaltene Vertragsnummern durch Links,
|
||||||
|
// falls sie in der knownContracts-Map auflösbar sind. Nicht aufgelöste Nummern
|
||||||
|
// bleiben als normaler Text.
|
||||||
|
function renderTextWithContractLinks(
|
||||||
|
text: string,
|
||||||
|
knownContracts?: Record<string, number>,
|
||||||
|
): React.ReactNode {
|
||||||
|
if (!knownContracts || Object.keys(knownContracts).length === 0) return text;
|
||||||
|
const parts: React.ReactNode[] = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
CONTRACT_NUMBER_REGEX.lastIndex = 0;
|
||||||
|
while ((match = CONTRACT_NUMBER_REGEX.exec(text)) !== null) {
|
||||||
|
const num = match[1];
|
||||||
|
const id = knownContracts[num];
|
||||||
|
if (match.index > lastIndex) parts.push(text.slice(lastIndex, match.index));
|
||||||
|
if (id) {
|
||||||
|
parts.push(
|
||||||
|
<Link
|
||||||
|
key={`${match.index}-${num}`}
|
||||||
|
to={`/contracts/${id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600 hover:text-blue-800 hover:underline font-mono"
|
||||||
|
>
|
||||||
|
{num}
|
||||||
|
</Link>,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
parts.push(num);
|
||||||
|
}
|
||||||
|
lastIndex = match.index + num.length;
|
||||||
|
}
|
||||||
|
if (lastIndex < text.length) parts.push(text.slice(lastIndex));
|
||||||
|
return parts.length > 0 ? <>{parts}</> : text;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ContractHistorySection({
|
export default function ContractHistorySection({
|
||||||
contractId,
|
contractId,
|
||||||
canEdit,
|
canEdit,
|
||||||
|
knownContracts,
|
||||||
}: ContractHistorySectionProps) {
|
}: ContractHistorySectionProps) {
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
const [showAddModal, setShowAddModal] = useState(false);
|
const [showAddModal, setShowAddModal] = useState(false);
|
||||||
@@ -84,7 +131,7 @@ export default function ContractHistorySection({
|
|||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
{' - '}
|
{' - '}
|
||||||
{sortedEntries[0].title}
|
{renderTextWithContractLinks(sortedEntries[0].title, knownContracts)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -99,7 +146,7 @@ export default function ContractHistorySection({
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<span className="text-sm font-medium text-gray-800">
|
<span className="text-sm font-medium text-gray-800">
|
||||||
{entry.title}
|
{renderTextWithContractLinks(entry.title, knownContracts)}
|
||||||
</span>
|
</span>
|
||||||
{entry.isAutomatic ? (
|
{entry.isAutomatic ? (
|
||||||
<span className="flex items-center gap-1 px-1.5 py-0.5 text-xs rounded bg-blue-100 text-blue-700" title="Automatisch erstellt">
|
<span className="flex items-center gap-1 px-1.5 py-0.5 text-xs rounded bg-blue-100 text-blue-700" title="Automatisch erstellt">
|
||||||
@@ -115,7 +162,7 @@ export default function ContractHistorySection({
|
|||||||
</div>
|
</div>
|
||||||
{entry.description && (
|
{entry.description && (
|
||||||
<p className="text-sm text-gray-600 whitespace-pre-wrap mb-1">
|
<p className="text-sm text-gray-600 whitespace-pre-wrap mb-1">
|
||||||
{entry.description}
|
{renderTextWithContractLinks(entry.description, knownContracts)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-3 text-xs text-gray-400">
|
<div className="flex items-center gap-3 text-xs text-gray-400">
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { X, RefreshCw, User } from 'lucide-react';
|
||||||
|
import { customerApi } from '../../services/api';
|
||||||
|
import { CopyableValue } from '../ui/CopyButton';
|
||||||
|
import Button from '../ui/Button';
|
||||||
|
|
||||||
|
interface CustomerInfoModalProps {
|
||||||
|
customerId: number;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schnellansicht der wichtigsten Kundendaten – wird aus der
|
||||||
|
* Vertragsdetail-Seite per Info-Icon neben dem Kundennamen geöffnet.
|
||||||
|
* Jedes Feld hat einen Copy-Button rechts. Modal-only, schreibt nichts.
|
||||||
|
*/
|
||||||
|
export default function CustomerInfoModal({ customerId, open, onClose }: CustomerInfoModalProps) {
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['customer-info-modal', customerId],
|
||||||
|
queryFn: () => customerApi.getById(customerId),
|
||||||
|
enabled: open,
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const c = data?.data;
|
||||||
|
|
||||||
|
const fullName = c
|
||||||
|
? [c.salutation, c.firstName, c.lastName].filter(Boolean).join(' ')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const primaryAddress = c?.addresses?.find((a) => a.isDefault) || c?.addresses?.[0];
|
||||||
|
const addressString = primaryAddress
|
||||||
|
? `${primaryAddress.street} ${primaryAddress.houseNumber ?? ''}, ${primaryAddress.postalCode} ${primaryAddress.city}`.trim()
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const formatDate = (iso?: string) => {
|
||||||
|
if (!iso) return '';
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg max-h-[85vh] flex flex-col">
|
||||||
|
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||||
|
<User className="w-5 h-5 text-blue-600" />
|
||||||
|
Kundendaten
|
||||||
|
{c && (
|
||||||
|
<span className="text-sm font-normal text-gray-500">
|
||||||
|
· {c.customerNumber}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-700 p-1"
|
||||||
|
aria-label="Schließen"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-5 py-4 overflow-auto flex-1">
|
||||||
|
{isLoading && (
|
||||||
|
<div className="text-gray-500 text-sm flex items-center gap-2 py-6">
|
||||||
|
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||||
|
Lade Kundendaten…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{c && (
|
||||||
|
<dl className="space-y-3">
|
||||||
|
{c.type === 'BUSINESS' && c.companyName && (
|
||||||
|
<CopyableValue label="Firma" value={c.companyName} />
|
||||||
|
)}
|
||||||
|
{fullName && <CopyableValue label="Name" value={fullName} />}
|
||||||
|
{c.birthDate && (
|
||||||
|
<CopyableValue label="Geburtsdatum" value={formatDate(c.birthDate)} />
|
||||||
|
)}
|
||||||
|
{c.birthPlace && <CopyableValue label="Geburtsort" value={c.birthPlace} />}
|
||||||
|
{c.foundingDate && (
|
||||||
|
<CopyableValue label="Gründungsdatum" value={formatDate(c.foundingDate)} />
|
||||||
|
)}
|
||||||
|
{addressString && (
|
||||||
|
<CopyableValue label="Adresse" value={addressString} />
|
||||||
|
)}
|
||||||
|
{c.phone && <CopyableValue label="Telefon" value={c.phone} />}
|
||||||
|
{c.mobile && <CopyableValue label="Mobil" value={c.mobile} />}
|
||||||
|
{c.email && <CopyableValue label="E-Mail" value={c.email} />}
|
||||||
|
{c.portalEmail && c.portalEmail !== c.email && (
|
||||||
|
<CopyableValue label="Portal-E-Mail" value={c.portalEmail} />
|
||||||
|
)}
|
||||||
|
{c.taxNumber && (
|
||||||
|
<CopyableValue label="Steuernummer" value={c.taxNumber} />
|
||||||
|
)}
|
||||||
|
{c.commercialRegisterNumber && (
|
||||||
|
<CopyableValue
|
||||||
|
label="Handelsregisternummer"
|
||||||
|
value={c.commercialRegisterNumber}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-5 py-3 border-t border-gray-200 flex justify-end">
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
Schließen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { formatDate } from '../../utils/dateFormat';
|
import { formatDate } from '../../utils/dateFormat';
|
||||||
import { Plus, Edit, Trash2, ChevronDown, ChevronUp, FileText, Download, AlertTriangle, Check, Eye } from 'lucide-react';
|
import { Plus, Edit, Trash2, ChevronDown, ChevronUp, FileText, Download, AlertTriangle, Check, Eye, Images } from 'lucide-react';
|
||||||
|
import JpgToPdfModal from '../ui/JpgToPdfModal';
|
||||||
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,7 +10,7 @@ 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';
|
import { fileUrl, viewUrl } from '../../utils/fileUrl';
|
||||||
|
|
||||||
const invoiceTypeLabels: Record<InvoiceType, string> = {
|
const invoiceTypeLabels: Record<InvoiceType, string> = {
|
||||||
INTERIM: 'Zwischenrechnung',
|
INTERIM: 'Zwischenrechnung',
|
||||||
@@ -121,7 +122,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={viewUrl(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"
|
||||||
@@ -218,6 +219,7 @@ function InvoiceModal({
|
|||||||
});
|
});
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isJpgModalOpen, setIsJpgModalOpen] = useState(false);
|
||||||
|
|
||||||
const addInvoiceFn = async (data: { invoiceDate: string; invoiceType: string; notes?: string }) => {
|
const addInvoiceFn = async (data: { invoiceDate: string; invoiceType: string; notes?: string }) => {
|
||||||
if (ecdId) {
|
if (ecdId) {
|
||||||
@@ -386,15 +388,31 @@ function InvoiceModal({
|
|||||||
onChange={handleFileSelect}
|
onChange={handleFileSelect}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
<Button
|
<div className="flex gap-2 flex-wrap">
|
||||||
type="button"
|
<Button
|
||||||
variant="secondary"
|
type="button"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
variant="secondary"
|
||||||
>
|
onClick={() => fileInputRef.current?.click()}
|
||||||
{invoice?.documentPath || selectedFile ? 'Ersetzen' : 'PDF hochladen'}
|
>
|
||||||
</Button>
|
{invoice?.documentPath || selectedFile ? 'Ersetzen' : 'PDF hochladen'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setIsJpgModalOpen(true)}
|
||||||
|
title="Mehrere JPGs zu einer PDF kombinieren"
|
||||||
|
>
|
||||||
|
<Images className="w-4 h-4 mr-1" /> JPGs → PDF
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<JpgToPdfModal
|
||||||
|
isOpen={isJpgModalOpen}
|
||||||
|
onClose={() => setIsJpgModalOpen(false)}
|
||||||
|
onComplete={(file) => setSelectedFile(file)}
|
||||||
|
fileNameHint="rechnung"
|
||||||
|
/>
|
||||||
|
|
||||||
{formData.invoiceType === 'NOT_AVAILABLE' && (
|
{formData.invoiceType === 'NOT_AVAILABLE' && (
|
||||||
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg text-yellow-800 text-sm">
|
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg text-yellow-800 text-sm">
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { FileText, Loader2, ExternalLink } from 'lucide-react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import Modal from '../ui/Modal';
|
||||||
|
import Button from '../ui/Button';
|
||||||
|
import { contractApi, EmailAttachment } from '../../services/api';
|
||||||
|
import { serverFileToAttachment, totalAttachmentBytes } from './composeAttachmentHelpers';
|
||||||
|
import { viewUrl } from '../../utils/fileUrl';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
contractId: number;
|
||||||
|
currentAttachments: EmailAttachment[];
|
||||||
|
onAttach: (added: EmailAttachment[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_TOTAL_SIZE = 25 * 1024 * 1024; // identisch zur Compose-Modal
|
||||||
|
|
||||||
|
export default function AttachContractDocumentsModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
contractId,
|
||||||
|
currentAttachments,
|
||||||
|
onAttach,
|
||||||
|
}: Props) {
|
||||||
|
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['contract-documents', contractId],
|
||||||
|
queryFn: () => contractApi.getDocuments(contractId),
|
||||||
|
enabled: isOpen,
|
||||||
|
});
|
||||||
|
|
||||||
|
const documents = data?.data || [];
|
||||||
|
|
||||||
|
const toggle = (id: number) => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (busy) return; // Kein Abbruch während Download läuft
|
||||||
|
setSelected(new Set());
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAttach = async () => {
|
||||||
|
if (selected.size === 0) {
|
||||||
|
handleClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
const docsToFetch = documents.filter((d) => selected.has(d.id));
|
||||||
|
const newAttachments: EmailAttachment[] = [];
|
||||||
|
let runningSize = totalAttachmentBytes(currentAttachments);
|
||||||
|
try {
|
||||||
|
for (const doc of docsToFetch) {
|
||||||
|
try {
|
||||||
|
const att = await serverFileToAttachment(doc.documentPath, doc.originalName);
|
||||||
|
const approxBytes = Math.ceil(att.content.length * 0.75);
|
||||||
|
if (runningSize + approxBytes > MAX_TOTAL_SIZE) {
|
||||||
|
toast.error(
|
||||||
|
`Maximale Gesamtgröße erreicht (25 MB). "${doc.originalName}" und folgende übersprungen.`,
|
||||||
|
{ duration: 6000 },
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
newAttachments.push(att);
|
||||||
|
runningSize += approxBytes;
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err?.message || `Fehler beim Anhängen von "${doc.originalName}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (newAttachments.length > 0) {
|
||||||
|
onAttach(newAttachments);
|
||||||
|
toast.success(
|
||||||
|
newAttachments.length === 1
|
||||||
|
? '1 Dokument angehängt'
|
||||||
|
: `${newAttachments.length} Dokumente angehängt`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setSelected(new Set());
|
||||||
|
onClose();
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Nach documentType gruppieren für übersichtliche Darstellung
|
||||||
|
const grouped = documents.reduce<Record<string, typeof documents>>((acc, doc) => {
|
||||||
|
const key = doc.documentType || 'Sonstiges';
|
||||||
|
if (!acc[key]) acc[key] = [];
|
||||||
|
acc[key].push(doc);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={handleClose}
|
||||||
|
title="Vertragsdokumente anhängen"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8 text-gray-500">
|
||||||
|
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
|
||||||
|
Dokumente werden geladen…
|
||||||
|
</div>
|
||||||
|
) : documents.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-gray-500">
|
||||||
|
<FileText className="w-10 h-10 mb-2 opacity-30" />
|
||||||
|
<p className="text-sm">Keine Dokumente am Vertrag hinterlegt</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4 max-h-96 overflow-y-auto">
|
||||||
|
{Object.entries(grouped).map(([type, docs]) => (
|
||||||
|
<div key={type}>
|
||||||
|
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">
|
||||||
|
{type}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{docs.map((doc) => (
|
||||||
|
<div
|
||||||
|
key={doc.id}
|
||||||
|
className="flex items-start gap-2 p-2 rounded hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<label className="flex items-start gap-2 flex-1 min-w-0 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.has(doc.id)}
|
||||||
|
onChange={() => toggle(doc.id)}
|
||||||
|
disabled={busy}
|
||||||
|
className="mt-0.5 rounded"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<FileText className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||||
|
<span className="truncate">{doc.originalName}</span>
|
||||||
|
</div>
|
||||||
|
{doc.notes && (
|
||||||
|
<div className="text-xs text-gray-500 mt-0.5 ml-6 truncate">
|
||||||
|
{doc.notes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<a
|
||||||
|
href={viewUrl(doc.documentPath)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="flex-shrink-0 inline-flex items-center gap-1 px-2 py-1 text-xs text-blue-600 hover:text-blue-800 hover:bg-blue-50 rounded transition-colors"
|
||||||
|
title="Dokument in neuem Tab öffnen"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
|
<span>Vorschau</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center pt-4 border-t border-gray-200">
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
{selected.size > 0 ? `${selected.size} ausgewählt` : 'Keine Auswahl'}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button variant="secondary" onClick={handleClose} disabled={busy}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleAttach}
|
||||||
|
disabled={busy || selected.size === 0}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Anhängen…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Anhängen'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
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, FilePlus, UserPlus } from 'lucide-react';
|
||||||
import toast from 'react-hot-toast';
|
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';
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import AttachContractDocumentsModal from './AttachContractDocumentsModal';
|
||||||
|
import InsertCustomerDataModal from './InsertCustomerDataModal';
|
||||||
|
|
||||||
interface ComposeEmailModalProps {
|
interface ComposeEmailModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -31,6 +33,8 @@ export default function ComposeEmailModal({
|
|||||||
const [body, setBody] = useState('');
|
const [body, setBody] = useState('');
|
||||||
const [attachments, setAttachments] = useState<EmailAttachment[]>([]);
|
const [attachments, setAttachments] = useState<EmailAttachment[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showAttachDocsModal, setShowAttachDocsModal] = useState(false);
|
||||||
|
const [showInsertDataModal, setShowInsertDataModal] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Formular bei Modal-Öffnung initialisieren
|
// Formular bei Modal-Öffnung initialisieren
|
||||||
@@ -308,15 +312,39 @@ export default function ComposeEmailModal({
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Anhang hinzufügen Button */}
|
{/* Anhang-/Daten-Buttons */}
|
||||||
<button
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
type="button"
|
<button
|
||||||
onClick={() => fileInputRef.current?.click()}
|
type="button"
|
||||||
className="inline-flex items-center px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
onClick={() => fileInputRef.current?.click()}
|
||||||
>
|
className="inline-flex items-center px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
<Paperclip className="w-4 h-4 mr-2" />
|
>
|
||||||
Datei anhängen
|
<Paperclip className="w-4 h-4 mr-2" />
|
||||||
</button>
|
Datei anhängen
|
||||||
|
</button>
|
||||||
|
{contractId && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAttachDocsModal(true)}
|
||||||
|
className="inline-flex items-center px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
|
title="Bereits am Vertrag hinterlegte Dokumente direkt anhängen"
|
||||||
|
>
|
||||||
|
<FilePlus className="w-4 h-4 mr-2" />
|
||||||
|
Vertragsdokumente
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowInsertDataModal(true)}
|
||||||
|
className="inline-flex items-center px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
|
title="Kunden- und Vertragsdaten in die Nachricht einfügen, optional Ausweis/Bankkarte als PDF anhängen"
|
||||||
|
>
|
||||||
|
<UserPlus className="w-4 h-4 mr-2" />
|
||||||
|
Kundendaten einfügen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Anhang-Liste */}
|
{/* Anhang-Liste */}
|
||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
@@ -374,6 +402,35 @@ export default function ComposeEmailModal({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Sub-Modal: Vertragsdokumente anhängen */}
|
||||||
|
{contractId && (
|
||||||
|
<AttachContractDocumentsModal
|
||||||
|
isOpen={showAttachDocsModal}
|
||||||
|
onClose={() => setShowAttachDocsModal(false)}
|
||||||
|
contractId={contractId}
|
||||||
|
currentAttachments={attachments}
|
||||||
|
onAttach={(added) => setAttachments((prev) => [...prev, ...added])}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sub-Modal: Kundendaten einfügen */}
|
||||||
|
{contractId && (
|
||||||
|
<InsertCustomerDataModal
|
||||||
|
isOpen={showInsertDataModal}
|
||||||
|
onClose={() => setShowInsertDataModal(false)}
|
||||||
|
contractId={contractId}
|
||||||
|
senderEmail={account.email}
|
||||||
|
currentBody={body}
|
||||||
|
currentAttachments={attachments}
|
||||||
|
onResult={(newBody, addedAtt) => {
|
||||||
|
setBody(newBody);
|
||||||
|
if (addedAtt.length > 0) {
|
||||||
|
setAttachments((prev) => [...prev, ...addedAtt]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Mail, MailOpen, Star, Paperclip, Plus, X, ChevronRight, Inbox, Send, RefreshCw, Trash2 } from 'lucide-react';
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Mail, MailOpen, Star, Paperclip, Plus, X, ChevronRight, Inbox, Send, RefreshCw, Trash2, ExternalLink } from 'lucide-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
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 Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
import Card from '../ui/Card';
|
import Card from '../ui/Card';
|
||||||
|
import CopyButton from '../ui/CopyButton';
|
||||||
import EmailDetail from './EmailDetail';
|
import EmailDetail from './EmailDetail';
|
||||||
import ComposeEmailModal from './ComposeEmailModal';
|
import ComposeEmailModal from './ComposeEmailModal';
|
||||||
import TrashEmailList from './TrashEmailList';
|
import TrashEmailList from './TrashEmailList';
|
||||||
@@ -48,28 +50,44 @@ export default function ContractEmailsSection({
|
|||||||
|
|
||||||
const selectedAccount = accounts.find((a) => a.id === selectedAccountId);
|
const selectedAccount = accounts.find((a) => a.id === selectedAccountId);
|
||||||
|
|
||||||
// E-Mails für den Vertrag laden (nach Ordner gefiltert, nicht für TRASH)
|
// E-Mails für den Vertrag laden (nach Ordner UND Postfach gefiltert).
|
||||||
|
// Bug 2026-06-21: vorher gingen Mails aus allen Postfächern in den
|
||||||
|
// gewählten Vertrags-Ordner – obwohl der User ein bestimmtes Postfach
|
||||||
|
// ausgewählt hatte. selectedAccountId muss in queryKey + queryFn.
|
||||||
const { data: emailsData, isLoading, refetch: refetchEmails } = useQuery({
|
const { data: emailsData, isLoading, refetch: refetchEmails } = useQuery({
|
||||||
queryKey: ['emails', 'contract', contractId, selectedFolder],
|
queryKey: ['emails', 'contract', contractId, selectedAccountId, selectedFolder],
|
||||||
queryFn: () => cachedEmailApi.getForContract(contractId, { folder: selectedFolder as 'INBOX' | 'SENT' }),
|
queryFn: () => cachedEmailApi.getForContract(contractId, {
|
||||||
enabled: selectedFolder !== 'TRASH',
|
folder: selectedFolder as 'INBOX' | 'SENT',
|
||||||
|
accountId: selectedAccountId ?? undefined,
|
||||||
|
}),
|
||||||
|
enabled: selectedFolder !== 'TRASH' && !!selectedAccountId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const emails = emailsData?.data || [];
|
const emails = emailsData?.data || [];
|
||||||
|
|
||||||
// Papierkorb-E-Mails laden (für den ganzen Kunden, da Trash nicht vertragsgebunden)
|
// Papierkorb-E-Mails laden – jetzt strikt: nur das aktuell ausgewählte
|
||||||
|
// Postfach UND nur dem Vertrag zugeordnete Mails. Wenn man also den
|
||||||
|
// Vertrags-Papierkorb öffnet, sieht man nicht mehr alle gelöschten
|
||||||
|
// E-Mails des Kunden, sondern wirklich nur die, die diesem Vertrag
|
||||||
|
// aus diesem Postfach zugeordnet waren.
|
||||||
const { data: trashData, isLoading: trashLoading } = useQuery({
|
const { data: trashData, isLoading: trashLoading } = useQuery({
|
||||||
queryKey: ['emails', 'trash', customerId],
|
queryKey: ['emails', 'trash', customerId, selectedAccountId, contractId],
|
||||||
queryFn: () => cachedEmailApi.getTrash(customerId),
|
queryFn: () => cachedEmailApi.getTrash(customerId, {
|
||||||
enabled: selectedFolder === 'TRASH' && canAccessTrash,
|
accountId: selectedAccountId ?? undefined,
|
||||||
|
contractId,
|
||||||
|
}),
|
||||||
|
enabled: selectedFolder === 'TRASH' && canAccessTrash && !!selectedAccountId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const trashEmails = trashData?.data || [];
|
const trashEmails = trashData?.data || [];
|
||||||
|
|
||||||
// Ordner-Anzahlen für Badges (Vertrag)
|
// Ordner-Anzahlen für Badges (Vertrag + Postfach). Badge und Liste
|
||||||
|
// müssen mit derselben Filter-Kombination laufen, sonst zeigt der
|
||||||
|
// Badge eine andere Zahl als die sichtbare Liste.
|
||||||
const { data: folderCountsData } = useQuery({
|
const { data: folderCountsData } = useQuery({
|
||||||
queryKey: ['contract-folder-counts', contractId],
|
queryKey: ['contract-folder-counts', contractId, selectedAccountId],
|
||||||
queryFn: () => cachedEmailApi.getContractFolderCounts(contractId),
|
queryFn: () => cachedEmailApi.getContractFolderCounts(contractId, selectedAccountId ?? undefined),
|
||||||
|
enabled: !!selectedAccountId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const folderCounts = folderCountsData?.data || {
|
const folderCounts = folderCountsData?.data || {
|
||||||
@@ -77,16 +95,6 @@ export default function ContractEmailsSection({
|
|||||||
inboxUnread: 0,
|
inboxUnread: 0,
|
||||||
sent: 0,
|
sent: 0,
|
||||||
sentUnread: 0,
|
sentUnread: 0,
|
||||||
};
|
|
||||||
|
|
||||||
// Ordner-Anzahlen für das Konto (für Trash-Badge)
|
|
||||||
const { data: accountFolderCountsData } = useQuery({
|
|
||||||
queryKey: ['folder-counts', selectedAccountId],
|
|
||||||
queryFn: () => stressfreiEmailApi.getFolderCounts(selectedAccountId!),
|
|
||||||
enabled: !!selectedAccountId && canAccessTrash,
|
|
||||||
});
|
|
||||||
|
|
||||||
const accountFolderCounts = accountFolderCountsData?.data || {
|
|
||||||
trash: 0,
|
trash: 0,
|
||||||
trashUnread: 0,
|
trashUnread: 0,
|
||||||
};
|
};
|
||||||
@@ -267,10 +275,40 @@ export default function ContractEmailsSection({
|
|||||||
return email.fromName || email.fromAddress;
|
return email.fromName || email.fromAddress;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Shared Card-Titel mit Quicklinks zur Kundenakte – wird sowohl im
|
||||||
|
// Normal-Zweig als auch im "keine Mailbox vorhanden"-Zweig gezeigt,
|
||||||
|
// damit man auch ohne eingerichtetes Postfach direkt zu den
|
||||||
|
// Stressfrei-Wechseln-Adressen springen kann.
|
||||||
|
const cardTitle = (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span>E-Mails</span>
|
||||||
|
<Link
|
||||||
|
to={`/customers/${customerId}?tab=emails`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800 hover:underline font-normal"
|
||||||
|
title="Komplettes E-Mail-Postfach des Kunden in neuem Tab öffnen"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
Postfach öffnen
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to={`/customers/${customerId}?tab=stressfrei`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800 hover:underline font-normal"
|
||||||
|
title="Stressfrei-Wechseln-Adressen des Kunden in neuem Tab öffnen"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
Stressfrei wechseln Adressen
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
// Keine Mailbox-Konten vorhanden
|
// Keine Mailbox-Konten vorhanden
|
||||||
if (!accountsLoading && accounts.length === 0) {
|
if (!accountsLoading && accounts.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Card title="E-Mails">
|
<Card title={cardTitle}>
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-gray-500">
|
<div className="flex flex-col items-center justify-center py-8 text-gray-500">
|
||||||
<Mail className="w-10 h-10 mb-2 opacity-30" />
|
<Mail className="w-10 h-10 mb-2 opacity-30" />
|
||||||
<p className="text-sm">Keine E-Mail-Konten vorhanden</p>
|
<p className="text-sm">Keine E-Mail-Konten vorhanden</p>
|
||||||
@@ -284,11 +322,7 @@ export default function ContractEmailsSection({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
title={
|
title={cardTitle}
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span>E-Mails</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
actions={
|
actions={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{selectedFolder !== 'TRASH' && (
|
{selectedFolder !== 'TRASH' && (
|
||||||
@@ -331,11 +365,23 @@ export default function ContractEmailsSection({
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
{selectedAccount?.email && (
|
||||||
|
<CopyButton
|
||||||
|
value={selectedAccount.email}
|
||||||
|
title={`Postfach-Adresse "${selectedAccount.email}" in Zwischenablage kopieren`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||||
<Inbox className="w-4 h-4 text-gray-500" />
|
<Inbox className="w-4 h-4 text-gray-500" />
|
||||||
<span>{selectedAccount?.email}</span>
|
<span>{selectedAccount?.email}</span>
|
||||||
|
{selectedAccount?.email && (
|
||||||
|
<CopyButton
|
||||||
|
value={selectedAccount.email}
|
||||||
|
title={`Postfach-Adresse "${selectedAccount.email}" in Zwischenablage kopieren`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -402,18 +448,18 @@ export default function ContractEmailsSection({
|
|||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
Papierkorb
|
Papierkorb
|
||||||
{accountFolderCounts.trash > 0 && (
|
{folderCounts.trash > 0 && (
|
||||||
<span
|
<span
|
||||||
className={`ml-1 px-1.5 py-0.5 text-xs rounded-full cursor-help ${
|
className={`ml-1 px-1.5 py-0.5 text-xs rounded-full cursor-help ${
|
||||||
accountFolderCounts.trashUnread > 0
|
folderCounts.trashUnread > 0
|
||||||
? 'bg-red-100 text-red-600 font-medium'
|
? 'bg-red-100 text-red-600 font-medium'
|
||||||
: 'bg-gray-100 text-gray-500'
|
: 'bg-gray-100 text-gray-500'
|
||||||
}`}
|
}`}
|
||||||
title={`${accountFolderCounts.trashUnread} ungelesen / ${accountFolderCounts.trash} gesamt`}
|
title={`${folderCounts.trashUnread} ungelesen / ${folderCounts.trash} gesamt`}
|
||||||
>
|
>
|
||||||
{accountFolderCounts.trashUnread > 0
|
{folderCounts.trashUnread > 0
|
||||||
? `${accountFolderCounts.trashUnread}/${accountFolderCounts.trash}`
|
? `${folderCounts.trashUnread}/${folderCounts.trash}`
|
||||||
: accountFolderCounts.trash}
|
: folderCounts.trash}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -443,9 +489,13 @@ export default function ContractEmailsSection({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex -mx-6 -mb-6" style={{ minHeight: '400px' }}>
|
<div
|
||||||
{/* Email List */}
|
className="flex -mx-6 -mb-6"
|
||||||
<div className="w-1/3 border-r border-gray-200 overflow-auto">
|
style={{ height: '600px' }}
|
||||||
|
>
|
||||||
|
{/* Email List – scrollt intern, damit die Vertrags-Seite nicht
|
||||||
|
elendig lang wird. */}
|
||||||
|
<div className="w-1/3 border-r border-gray-200 overflow-y-auto">
|
||||||
{selectedFolder === 'TRASH' ? (
|
{selectedFolder === 'TRASH' ? (
|
||||||
<TrashEmailList
|
<TrashEmailList
|
||||||
emails={trashEmails}
|
emails={trashEmails}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { cachedEmailApi, stressfreiEmailApi, CachedEmail, EmailFilterParams } fr
|
|||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import { useProviderSettings } from '../../hooks/useProviderSettings';
|
import { useProviderSettings } from '../../hooks/useProviderSettings';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
|
import CopyButton from '../ui/CopyButton';
|
||||||
import EmailList from './EmailList';
|
import EmailList from './EmailList';
|
||||||
import EmailDetail from './EmailDetail';
|
import EmailDetail from './EmailDetail';
|
||||||
import ComposeEmailModal from './ComposeEmailModal';
|
import ComposeEmailModal from './ComposeEmailModal';
|
||||||
@@ -124,11 +125,17 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
|
|||||||
|
|
||||||
const emails = emailsData?.data || [];
|
const emails = emailsData?.data || [];
|
||||||
|
|
||||||
// Papierkorb-E-Mails laden
|
// Papierkorb-E-Mails laden – jetzt strikt pro Postfach.
|
||||||
|
// Bug 2026-06-21: vorher kamen alle gelöschten E-Mails des Kunden
|
||||||
|
// raus, egal welches Postfach selektiert war. selectedAccountId muss
|
||||||
|
// in queryKey + queryFn, sonst greift React-Query-Cache bei Wechsel
|
||||||
|
// nicht und der Folder-Count aus folderCountsData liefe auseinander.
|
||||||
const { data: trashData, isLoading: trashLoading } = useQuery({
|
const { data: trashData, isLoading: trashLoading } = useQuery({
|
||||||
queryKey: ['emails', 'trash', customerId],
|
queryKey: ['emails', 'trash', customerId, selectedAccountId],
|
||||||
queryFn: () => cachedEmailApi.getTrash(customerId),
|
queryFn: () => cachedEmailApi.getTrash(customerId, {
|
||||||
enabled: selectedFolder === 'TRASH' && canAccessTrash,
|
accountId: selectedAccountId ?? undefined,
|
||||||
|
}),
|
||||||
|
enabled: selectedFolder === 'TRASH' && canAccessTrash && !!selectedAccountId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const trashEmails = trashData?.data || [];
|
const trashEmails = trashData?.data || [];
|
||||||
@@ -288,7 +295,13 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full" style={{ minHeight: '600px' }}>
|
// Bounded auf Viewport-Höhe – sonst ignoriert h-full ohnehin den
|
||||||
|
// Tab-Container und der Postfach-Inhalt wächst beliebig, sodass die
|
||||||
|
// ganze Seite scrollt statt nur die E-Mail-Liste.
|
||||||
|
<div
|
||||||
|
className="flex flex-col"
|
||||||
|
style={{ height: 'calc(100vh - 240px)', minHeight: '500px' }}
|
||||||
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between gap-4 p-4 border-b border-gray-200 bg-gray-50">
|
<div className="flex items-center justify-between gap-4 p-4 border-b border-gray-200 bg-gray-50">
|
||||||
{/* Account Selector */}
|
{/* Account Selector */}
|
||||||
@@ -309,11 +322,25 @@ export default function EmailClientTab({ customerId }: EmailClientTabProps) {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
{selectedAccount?.email && (
|
||||||
|
<CopyButton
|
||||||
|
value={selectedAccount.email}
|
||||||
|
size="md"
|
||||||
|
title={`Postfach-Adresse "${selectedAccount.email}" in Zwischenablage kopieren`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-3 text-sm text-gray-600">
|
<div className="flex items-center gap-3 text-sm text-gray-600">
|
||||||
<Inbox className="w-5 h-5 text-gray-500" />
|
<Inbox className="w-5 h-5 text-gray-500" />
|
||||||
<span>{selectedAccount?.email}</span>
|
<span>{selectedAccount?.email}</span>
|
||||||
|
{selectedAccount?.email && (
|
||||||
|
<CopyButton
|
||||||
|
value={selectedAccount.email}
|
||||||
|
size="md"
|
||||||
|
title={`Postfach-Adresse "${selectedAccount.email}" in Zwischenablage kopieren`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { Reply, Forward, RotateCcw, Star, Paperclip, Link2, X, Download, ExternalLink, Trash2, Undo2, Save, FileDown } from 'lucide-react';
|
import { Reply, Forward, RotateCcw, Star, Paperclip, Link2, X, Download, ExternalLink, Trash2, Undo2, Save, FileDown } from 'lucide-react';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
import { CachedEmail, cachedEmailApi } from '../../services/api';
|
import { CachedEmail, cachedEmailApi } from '../../services/api';
|
||||||
@@ -52,6 +52,24 @@ export default function EmailDetail({
|
|||||||
setLocalStarred(email.isStarred);
|
setLocalStarred(email.isStarred);
|
||||||
}, [email.id, email.isStarred]);
|
}, [email.id, email.isStarred]);
|
||||||
|
|
||||||
|
// Email-Body sanitizen + alle <a>-Links auf neuen Tab umstellen.
|
||||||
|
// rel="noopener noreferrer" verhindert window.opener-Tab-Hijacking.
|
||||||
|
const safeHtmlBody = useMemo(() => {
|
||||||
|
if (!email.htmlBody) return '';
|
||||||
|
const sanitized = DOMPurify.sanitize(email.htmlBody, {
|
||||||
|
FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form'],
|
||||||
|
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus'],
|
||||||
|
ADD_ATTR: ['target'],
|
||||||
|
});
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.innerHTML = sanitized;
|
||||||
|
wrapper.querySelectorAll('a').forEach((a) => {
|
||||||
|
a.setAttribute('target', '_blank');
|
||||||
|
a.setAttribute('rel', 'noopener noreferrer');
|
||||||
|
});
|
||||||
|
return wrapper.innerHTML;
|
||||||
|
}, [email.htmlBody]);
|
||||||
|
|
||||||
const toggleStarMutation = useMutation({
|
const toggleStarMutation = useMutation({
|
||||||
mutationFn: () => cachedEmailApi.toggleStar(email.id),
|
mutationFn: () => cachedEmailApi.toggleStar(email.id),
|
||||||
onMutate: () => {
|
onMutate: () => {
|
||||||
@@ -312,6 +330,16 @@ export default function EmailDetail({
|
|||||||
{email.contract.contractNumber}
|
{email.contract.contractNumber}
|
||||||
</Link>
|
</Link>
|
||||||
</span>
|
</span>
|
||||||
|
<a
|
||||||
|
href={`/contracts/${email.contract.id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="p-1 text-green-600 hover:text-green-800"
|
||||||
|
title="Vertrag in neuem Tab öffnen"
|
||||||
|
aria-label="Vertrag in neuem Tab öffnen"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
|
</a>
|
||||||
{/* X-Button nur für manuell zugeordnete E-Mails (nicht für automatisch zugeordnete aus Vertrag gesendete) */}
|
{/* X-Button nur für manuell zugeordnete E-Mails (nicht für automatisch zugeordnete aus Vertrag gesendete) */}
|
||||||
{!email.isAutoAssigned && (
|
{!email.isAutoAssigned && (
|
||||||
<button
|
<button
|
||||||
@@ -411,16 +439,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: safeHtmlBody }}
|
||||||
__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">
|
||||||
|
|||||||
@@ -0,0 +1,613 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import Modal from '../ui/Modal';
|
||||||
|
import Button from '../ui/Button';
|
||||||
|
import { contractApi, EmailAttachment } from '../../services/api';
|
||||||
|
import { formatDate } from '../../utils/dateFormat';
|
||||||
|
import {
|
||||||
|
bankCardAttachmentName,
|
||||||
|
identityDocAttachmentName,
|
||||||
|
serverFileToAttachment,
|
||||||
|
totalAttachmentBytes,
|
||||||
|
} from './composeAttachmentHelpers';
|
||||||
|
import type { Contract, Address, BankCard, IdentityDocument } from '../../types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
contractId: number;
|
||||||
|
/**
|
||||||
|
* E-Mail-Adresse des Postfachs, von dem die Mail abgeschickt wird.
|
||||||
|
* Wird in der "Anrede & Name"-Section als Alternative zur Stammdaten-
|
||||||
|
* E-Mail angeboten – User-Wunsch 2026-06-21: bei Kundendaten wählen,
|
||||||
|
* ob die Customer-Email oder die Stressfrei-Wechseln-Absender-Adresse
|
||||||
|
* eingefügt wird.
|
||||||
|
*/
|
||||||
|
senderEmail: string;
|
||||||
|
currentBody: string;
|
||||||
|
currentAttachments: EmailAttachment[];
|
||||||
|
onResult: (newBody: string, addedAttachments: EmailAttachment[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmailChoice = 'master' | 'sender' | 'none';
|
||||||
|
|
||||||
|
const MAX_TOTAL_SIZE = 25 * 1024 * 1024;
|
||||||
|
|
||||||
|
type SectionKey =
|
||||||
|
| 'customer'
|
||||||
|
| 'deliveryAddress'
|
||||||
|
| 'billingAddress'
|
||||||
|
| 'contract';
|
||||||
|
|
||||||
|
export default function InsertCustomerDataModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
contractId,
|
||||||
|
senderEmail,
|
||||||
|
currentBody,
|
||||||
|
currentAttachments,
|
||||||
|
onResult,
|
||||||
|
}: Props) {
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['contract', contractId, 'for-insert-data'],
|
||||||
|
queryFn: () => contractApi.getById(contractId),
|
||||||
|
enabled: isOpen,
|
||||||
|
});
|
||||||
|
|
||||||
|
const contract = data?.data;
|
||||||
|
const customer = contract?.customer;
|
||||||
|
const deliveryAddress = contract?.address;
|
||||||
|
// Rechnungsadresse nur eigenständig zeigen, wenn sie sich tatsächlich
|
||||||
|
// von der Lieferadresse unterscheidet – sonst doppelt im Text.
|
||||||
|
const billingAddress = useMemo(() => {
|
||||||
|
if (!contract?.billingAddress) return undefined;
|
||||||
|
if (!deliveryAddress) return contract.billingAddress;
|
||||||
|
return contract.billingAddress.id !== deliveryAddress.id ? contract.billingAddress : undefined;
|
||||||
|
}, [contract?.billingAddress, deliveryAddress]);
|
||||||
|
|
||||||
|
const bankCard = contract?.bankCard;
|
||||||
|
const identityDocument = contract?.identityDocument;
|
||||||
|
|
||||||
|
// Sections die default-an sind: Anrede + Vertragsdaten. Anhang-/Text-
|
||||||
|
// Schalter für Bank + Ausweis bleiben default-aus (User-Intent: bewusst
|
||||||
|
// entscheiden, was vertraulich verschickt wird).
|
||||||
|
const [checked, setChecked] = useState<Record<SectionKey, boolean>>({
|
||||||
|
customer: true,
|
||||||
|
deliveryAddress: true,
|
||||||
|
billingAddress: false,
|
||||||
|
contract: true,
|
||||||
|
});
|
||||||
|
// Bank: zwei unabhängige Schalter. Text fügt nur die letzten 4 IBAN-
|
||||||
|
// Stellen ein (kein vollständiger IBAN-Versand per Mail = Default-Hygiene).
|
||||||
|
const [insertBankText, setInsertBankText] = useState(false);
|
||||||
|
const [attachBankPdf, setAttachBankPdf] = useState(false);
|
||||||
|
// Ausweis: Text-Schalter fügt nur die Ausweisnummer ein, kein Geburtsdatum
|
||||||
|
// / keine Ausstellungsdaten – falls der Empfänger nur die Nummer braucht.
|
||||||
|
const [insertIdentityText, setInsertIdentityText] = useState(false);
|
||||||
|
const [attachIdentityPdf, setAttachIdentityPdf] = useState(false);
|
||||||
|
// Welche E-Mail-Adresse in der Customer-Section steht:
|
||||||
|
// - 'master' = Stammdaten-E-Mail (customer.email)
|
||||||
|
// - 'sender' = Postfach-Adresse, von der die Mail abgeht (Stressfrei)
|
||||||
|
// - 'none' = E-Mail-Zeile weglassen
|
||||||
|
const [emailChoice, setEmailChoice] = useState<EmailChoice>('master');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
// Bei jedem Öffnen sinnvoll vorbelegen (sonst bleiben "checked" stale
|
||||||
|
// wenn das Modal mal mit anderen Daten wieder aufgeht).
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && contract) {
|
||||||
|
setChecked({
|
||||||
|
customer: !!customer,
|
||||||
|
deliveryAddress: !!deliveryAddress,
|
||||||
|
billingAddress: false, // nur wenn vorhanden, aber default aus
|
||||||
|
contract: true,
|
||||||
|
});
|
||||||
|
setInsertBankText(false);
|
||||||
|
setAttachBankPdf(false);
|
||||||
|
setInsertIdentityText(false);
|
||||||
|
setAttachIdentityPdf(false);
|
||||||
|
// Default: Stammdaten-E-Mail wenn vorhanden, sonst Absender-Adresse.
|
||||||
|
setEmailChoice(customer?.email ? 'master' : 'sender');
|
||||||
|
}
|
||||||
|
}, [isOpen, contract, customer, deliveryAddress]);
|
||||||
|
|
||||||
|
const toggle = (key: SectionKey) => {
|
||||||
|
setChecked((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (busy) return;
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
if (!contract) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const blocks: string[] = [];
|
||||||
|
|
||||||
|
if (checked.customer && customer) {
|
||||||
|
const chosenEmail =
|
||||||
|
emailChoice === 'master'
|
||||||
|
? customer.email || ''
|
||||||
|
: emailChoice === 'sender'
|
||||||
|
? senderEmail
|
||||||
|
: '';
|
||||||
|
blocks.push(formatCustomerBlock(customer, contract, chosenEmail));
|
||||||
|
}
|
||||||
|
if (checked.deliveryAddress && deliveryAddress) {
|
||||||
|
blocks.push(formatAddressBlock('Lieferadresse', deliveryAddress));
|
||||||
|
}
|
||||||
|
if (checked.billingAddress && billingAddress) {
|
||||||
|
blocks.push(formatAddressBlock('Rechnungsadresse', billingAddress));
|
||||||
|
}
|
||||||
|
if (checked.contract) {
|
||||||
|
blocks.push(formatContractBlock(contract));
|
||||||
|
}
|
||||||
|
if (insertBankText && bankCard) {
|
||||||
|
blocks.push(formatBankBlock(bankCard));
|
||||||
|
}
|
||||||
|
if (insertIdentityText && identityDocument) {
|
||||||
|
blocks.push(formatIdentityBlock(identityDocument));
|
||||||
|
}
|
||||||
|
|
||||||
|
const textToInsert = blocks
|
||||||
|
.filter((b) => b.trim().length > 0)
|
||||||
|
.join('\n\n');
|
||||||
|
|
||||||
|
// Anhänge sammeln
|
||||||
|
const newAttachments: EmailAttachment[] = [];
|
||||||
|
let runningSize = totalAttachmentBytes(currentAttachments);
|
||||||
|
|
||||||
|
const tryAttach = async (
|
||||||
|
documentPath: string | undefined,
|
||||||
|
filename: string,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
if (!documentPath) return false;
|
||||||
|
try {
|
||||||
|
const att = await serverFileToAttachment(documentPath, filename);
|
||||||
|
const approxBytes = Math.ceil(att.content.length * 0.75);
|
||||||
|
if (runningSize + approxBytes > MAX_TOTAL_SIZE) {
|
||||||
|
toast.error(`"${filename}" gesprengt das 25-MB-Anhang-Limit.`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
newAttachments.push(att);
|
||||||
|
runningSize += approxBytes;
|
||||||
|
return true;
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err?.message || `Fehler beim Anhängen von "${filename}"`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (attachBankPdf && bankCard?.documentPath) {
|
||||||
|
await tryAttach(
|
||||||
|
bankCard.documentPath,
|
||||||
|
bankCardAttachmentName(bankCard.iban),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (attachIdentityPdf && identityDocument?.documentPath) {
|
||||||
|
await tryAttach(
|
||||||
|
identityDocument.documentPath,
|
||||||
|
identityDocAttachmentName(
|
||||||
|
identityDocument.type,
|
||||||
|
identityDocument.documentNumber,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const separator = currentBody && !currentBody.endsWith('\n') ? '\n\n' : '';
|
||||||
|
const newBody = textToInsert
|
||||||
|
? currentBody + separator + textToInsert
|
||||||
|
: currentBody;
|
||||||
|
|
||||||
|
onResult(newBody, newAttachments);
|
||||||
|
onClose();
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const nothingSelected =
|
||||||
|
!checked.customer &&
|
||||||
|
!checked.deliveryAddress &&
|
||||||
|
!checked.billingAddress &&
|
||||||
|
!checked.contract &&
|
||||||
|
!insertBankText &&
|
||||||
|
!attachBankPdf &&
|
||||||
|
!insertIdentityText &&
|
||||||
|
!attachIdentityPdf;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={handleClose}
|
||||||
|
title="Kundendaten einfügen"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{isLoading || !contract ? (
|
||||||
|
<div className="flex items-center justify-center py-8 text-gray-500">
|
||||||
|
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
|
||||||
|
Daten werden geladen…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||||
|
{customer && (
|
||||||
|
<SectionRow
|
||||||
|
title="Anrede & Name"
|
||||||
|
checked={checked.customer}
|
||||||
|
onToggle={() => toggle('customer')}
|
||||||
|
preview={previewCustomer(customer, contract)}
|
||||||
|
extra={
|
||||||
|
checked.customer && (
|
||||||
|
<div className="mt-2 ml-6 space-y-1">
|
||||||
|
<div className="text-xs font-medium text-gray-600">
|
||||||
|
E-Mail im Text:
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-xs text-gray-700 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="emailChoice"
|
||||||
|
checked={emailChoice === 'master'}
|
||||||
|
onChange={() => setEmailChoice('master')}
|
||||||
|
disabled={!customer.email}
|
||||||
|
className="text-blue-600"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Stammdaten-E-Mail
|
||||||
|
{customer.email ? (
|
||||||
|
<span className="text-gray-400"> ({customer.email})</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400"> (nicht hinterlegt)</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-xs text-gray-700 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="emailChoice"
|
||||||
|
checked={emailChoice === 'sender'}
|
||||||
|
onChange={() => setEmailChoice('sender')}
|
||||||
|
className="text-blue-600"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Absender-Adresse
|
||||||
|
<span className="text-gray-400"> ({senderEmail})</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-xs text-gray-700 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="emailChoice"
|
||||||
|
checked={emailChoice === 'none'}
|
||||||
|
onChange={() => setEmailChoice('none')}
|
||||||
|
className="text-blue-600"
|
||||||
|
/>
|
||||||
|
<span>Keine E-Mail einfügen</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{deliveryAddress && (
|
||||||
|
<SectionRow
|
||||||
|
title="Lieferadresse"
|
||||||
|
checked={checked.deliveryAddress}
|
||||||
|
onToggle={() => toggle('deliveryAddress')}
|
||||||
|
preview={previewAddress(deliveryAddress)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{billingAddress && (
|
||||||
|
<SectionRow
|
||||||
|
title="Rechnungsadresse"
|
||||||
|
checked={checked.billingAddress}
|
||||||
|
onToggle={() => toggle('billingAddress')}
|
||||||
|
preview={previewAddress(billingAddress)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<SectionRow
|
||||||
|
title="Vertragsdaten"
|
||||||
|
checked={checked.contract}
|
||||||
|
onToggle={() => toggle('contract')}
|
||||||
|
preview={previewContract(contract)}
|
||||||
|
/>
|
||||||
|
{bankCard && (
|
||||||
|
<DualChoiceRow
|
||||||
|
title="Bankverbindung"
|
||||||
|
preview={previewBank(bankCard)}
|
||||||
|
textChecked={insertBankText}
|
||||||
|
onToggleText={() => setInsertBankText((v) => !v)}
|
||||||
|
textLabel="Letzte 4 IBAN-Stellen einfügen"
|
||||||
|
textDisabled={!lastFourIban(bankCard.iban)}
|
||||||
|
pdfChecked={attachBankPdf}
|
||||||
|
onTogglePdf={() => setAttachBankPdf((v) => !v)}
|
||||||
|
pdfLabel="Bankkarte als PDF anhängen"
|
||||||
|
pdfDisabled={!bankCard.documentPath}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{identityDocument && (
|
||||||
|
<DualChoiceRow
|
||||||
|
title={identityTypeLabel(identityDocument.type)}
|
||||||
|
preview={previewIdentity(identityDocument)}
|
||||||
|
textChecked={insertIdentityText}
|
||||||
|
onToggleText={() => setInsertIdentityText((v) => !v)}
|
||||||
|
textLabel={`${identityTypeLabel(identityDocument.type)}-Nummer einfügen`}
|
||||||
|
textDisabled={!identityDocument.documentNumber}
|
||||||
|
pdfChecked={attachIdentityPdf}
|
||||||
|
onTogglePdf={() => setAttachIdentityPdf((v) => !v)}
|
||||||
|
pdfLabel={`${identityTypeLabel(identityDocument.type)} als PDF anhängen`}
|
||||||
|
pdfDisabled={!identityDocument.documentPath}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Falls weder Customer noch Address etc. da sind */}
|
||||||
|
{!customer && !deliveryAddress && !bankCard && !identityDocument && (
|
||||||
|
<p className="text-sm text-gray-500 text-center py-4">
|
||||||
|
Keine weiteren Daten am Kunden hinterlegt.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center pt-4 border-t border-gray-200">
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
Text wird ans Ende der Nachricht angehängt.
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button variant="secondary" onClick={handleClose} disabled={busy}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={busy || isLoading || nothingSelected}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Einfügen…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Einfügen'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== UI-Helper ====================
|
||||||
|
|
||||||
|
interface SectionRowProps {
|
||||||
|
title: string;
|
||||||
|
checked: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
preview: string;
|
||||||
|
extra?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DualChoiceRowProps {
|
||||||
|
title: string;
|
||||||
|
preview: string;
|
||||||
|
textChecked: boolean;
|
||||||
|
onToggleText: () => void;
|
||||||
|
textLabel: string;
|
||||||
|
textDisabled?: boolean;
|
||||||
|
pdfChecked: boolean;
|
||||||
|
onTogglePdf: () => void;
|
||||||
|
pdfLabel: string;
|
||||||
|
pdfDisabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sections, die unabhängig Text und PDF anbieten (Bank, Ausweis).
|
||||||
|
* Keine primäre Checkbox – beide Schalter wirken einzeln, deshalb
|
||||||
|
* kein "alle-ein/alle-aus" auf Section-Ebene nötig.
|
||||||
|
*/
|
||||||
|
function DualChoiceRow({
|
||||||
|
title,
|
||||||
|
preview,
|
||||||
|
textChecked,
|
||||||
|
onToggleText,
|
||||||
|
textLabel,
|
||||||
|
textDisabled,
|
||||||
|
pdfChecked,
|
||||||
|
onTogglePdf,
|
||||||
|
pdfLabel,
|
||||||
|
pdfDisabled,
|
||||||
|
}: DualChoiceRowProps) {
|
||||||
|
return (
|
||||||
|
<div className="border border-gray-200 rounded-lg p-3">
|
||||||
|
<div className="text-sm font-medium text-gray-700">{title}</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-1">{preview}</div>
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
<label className={`flex items-center gap-2 text-xs cursor-pointer ${textDisabled ? 'text-gray-400 cursor-not-allowed' : 'text-gray-700'}`}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={textChecked}
|
||||||
|
onChange={onToggleText}
|
||||||
|
disabled={textDisabled}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<span>{textLabel}</span>
|
||||||
|
</label>
|
||||||
|
<label className={`flex items-center gap-2 text-xs cursor-pointer ${pdfDisabled ? 'text-gray-400 cursor-not-allowed' : 'text-gray-700'}`}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={pdfChecked}
|
||||||
|
onChange={onTogglePdf}
|
||||||
|
disabled={pdfDisabled}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{pdfLabel}
|
||||||
|
{pdfDisabled && <span className="ml-1">(keine PDF hinterlegt)</span>}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionRow({ title, checked, onToggle, preview, extra }: SectionRowProps) {
|
||||||
|
return (
|
||||||
|
<div className="border border-gray-200 rounded-lg p-3">
|
||||||
|
<label className="flex items-start gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={onToggle}
|
||||||
|
className="mt-1 rounded"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-gray-700">{title}</div>
|
||||||
|
<pre className="text-xs text-gray-500 mt-1 whitespace-pre-wrap font-sans">
|
||||||
|
{preview}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
{extra}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Text-Block-Formatierung ====================
|
||||||
|
|
||||||
|
function fullName(
|
||||||
|
customer: { salutation?: string; firstName: string; lastName: string; companyName?: string },
|
||||||
|
contractType: string,
|
||||||
|
): string {
|
||||||
|
if (contractType === 'BUSINESS' && customer.companyName) {
|
||||||
|
return customer.companyName;
|
||||||
|
}
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (customer.salutation) parts.push(customer.salutation);
|
||||||
|
parts.push(customer.firstName);
|
||||||
|
parts.push(customer.lastName);
|
||||||
|
return parts.filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// User-Wunsch 2026-06-21: das Modal ist für Mails AN den Anbieter gedacht.
|
||||||
|
// Die interne CRM-Kundennummer / -Vertragsnummer interessiert dort
|
||||||
|
// niemanden – relevant ist nur, was der Anbieter selbst vergeben hat
|
||||||
|
// (`customerNumberAtProvider`, `contractNumberAtProvider`). Wir blenden
|
||||||
|
// die internen Nummern komplett aus.
|
||||||
|
function formatCustomerBlock(
|
||||||
|
customer: NonNullable<Contract['customer']>,
|
||||||
|
contract: Contract,
|
||||||
|
email: string,
|
||||||
|
): string {
|
||||||
|
const lines: string[] = ['Kundendaten:'];
|
||||||
|
lines.push(fullName(customer, contract.type));
|
||||||
|
if (contract.customerNumberAtProvider) {
|
||||||
|
lines.push(`Kundennummer beim Anbieter: ${contract.customerNumberAtProvider}`);
|
||||||
|
}
|
||||||
|
if (customer.birthDate) lines.push(`Geburtsdatum: ${formatDate(customer.birthDate)}`);
|
||||||
|
if (email) lines.push(`E-Mail: ${email}`);
|
||||||
|
if (customer.phone) lines.push(`Telefon: ${customer.phone}`);
|
||||||
|
if (customer.mobile) lines.push(`Mobil: ${customer.mobile}`);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewCustomer(customer: NonNullable<Contract['customer']>, contract: Contract): string {
|
||||||
|
return [
|
||||||
|
fullName(customer, contract.type),
|
||||||
|
contract.customerNumberAtProvider
|
||||||
|
? `Anbieter-Kdnr.: ${contract.customerNumberAtProvider}`
|
||||||
|
: '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAddressBlock(label: string, addr: Address): string {
|
||||||
|
const lines: string[] = [`${label}:`];
|
||||||
|
lines.push(`${addr.street} ${addr.houseNumber}`);
|
||||||
|
lines.push(`${addr.postalCode} ${addr.city}`);
|
||||||
|
if (addr.country && addr.country.toLowerCase() !== 'deutschland' && addr.country !== 'DE') {
|
||||||
|
lines.push(addr.country);
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewAddress(addr: Address): string {
|
||||||
|
return `${addr.street} ${addr.houseNumber}, ${addr.postalCode} ${addr.city}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interne `contractNumber` raus (User-Wunsch 2026-06-21): für eine Mail
|
||||||
|
// an den Provider zählt nur die Vertragsnummer, die der Provider selbst
|
||||||
|
// vergeben hat. Vertriebsplattform-Nummern bleiben drin – die nutzt der
|
||||||
|
// CRM-Mitarbeiter teilweise auch für die Plattform-Korrespondenz.
|
||||||
|
function formatContractBlock(c: Contract): string {
|
||||||
|
const lines: string[] = ['Vertragsdaten:'];
|
||||||
|
if (c.provider?.name) lines.push(`Anbieter: ${c.provider.name}`);
|
||||||
|
if (c.tariff?.name) lines.push(`Tarif: ${c.tariff.name}`);
|
||||||
|
if (c.customerNumberAtProvider) lines.push(`Kundennummer beim Anbieter: ${c.customerNumberAtProvider}`);
|
||||||
|
if (c.contractNumberAtProvider) lines.push(`Vertragsnummer beim Anbieter: ${c.contractNumberAtProvider}`);
|
||||||
|
if (c.orderNumberAtSalesPlatform) lines.push(`Auftragsnummer Vertriebsplattform: ${c.orderNumberAtSalesPlatform}`);
|
||||||
|
if (c.customerNumberAtSalesPlatform) lines.push(`Kundennummer Vertriebsplattform: ${c.customerNumberAtSalesPlatform}`);
|
||||||
|
if (c.contractNumberAtSalesPlatform) lines.push(`Vertragsnummer Vertriebsplattform: ${c.contractNumberAtSalesPlatform}`);
|
||||||
|
if (c.startDate) lines.push(`Vertragsbeginn: ${formatDate(c.startDate)}`);
|
||||||
|
if (c.endDate) lines.push(`Vertragsende: ${formatDate(c.endDate)}`);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewContract(c: Contract): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (c.contractNumberAtProvider) {
|
||||||
|
parts.push(`Anbieter-Vtr.: ${c.contractNumberAtProvider}`);
|
||||||
|
} else if (c.provider?.name) {
|
||||||
|
parts.push('(keine Anbieter-Vertragsnummer hinterlegt)');
|
||||||
|
}
|
||||||
|
if (c.provider?.name) parts.push(c.provider.name);
|
||||||
|
if (c.tariff?.name) parts.push(c.tariff.name);
|
||||||
|
return parts.join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// User-Wunsch 2026-06-21: nur die letzten 4 IBAN-Stellen einfügen, nicht
|
||||||
|
// die komplette IBAN/BIC/Bank-Liste. Vollständige Kontonummern per Mail
|
||||||
|
// versenden ist sowieso heikel – der Empfänger kann sich mit den letzten
|
||||||
|
// 4 Stellen für Identifikationszwecke ausweisen, ohne dass die ganze
|
||||||
|
// IBAN im Mail-Verlauf hängenbleibt.
|
||||||
|
function lastFourIban(iban: string | undefined | null): string {
|
||||||
|
if (!iban) return '';
|
||||||
|
return iban.replace(/\s+/g, '').slice(-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBankBlock(b: BankCard): string {
|
||||||
|
const last4 = lastFourIban(b.iban);
|
||||||
|
if (!last4) return '';
|
||||||
|
return `Bankverbindung:\nIBAN endet auf: ${last4}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewBank(b: BankCard): string {
|
||||||
|
const last4 = lastFourIban(b.iban);
|
||||||
|
return last4 ? `IBAN …${last4}` : 'IBAN nicht hinterlegt';
|
||||||
|
}
|
||||||
|
|
||||||
|
function identityTypeLabel(type: IdentityDocument['type']): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'PASSPORT': return 'Reisepass';
|
||||||
|
case 'DRIVERS_LICENSE': return 'Führerschein';
|
||||||
|
case 'OTHER': return 'Ausweisdokument';
|
||||||
|
case 'ID_CARD':
|
||||||
|
default: return 'Personalausweis';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// User-Wunsch 2026-06-21: nur die Ausweisnummer einfügen, keine
|
||||||
|
// Behörde / Daten – wenn der Empfänger mehr Details braucht, soll er
|
||||||
|
// die beigefügte PDF benutzen.
|
||||||
|
function formatIdentityBlock(d: IdentityDocument): string {
|
||||||
|
if (!d.documentNumber) return '';
|
||||||
|
return `${identityTypeLabel(d.type)}-Nummer: ${d.documentNumber}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewIdentity(d: IdentityDocument): string {
|
||||||
|
return d.documentNumber ? `Nr. ${d.documentNumber}` : 'Keine Nummer hinterlegt';
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { FileText, User, CreditCard, IdCard, AlertTriangle, Check, ChevronDown, ChevronRight, Receipt } from 'lucide-react';
|
import { FileText, User, CreditCard, IdCard, AlertTriangle, Check, ChevronDown, ChevronRight, Receipt, FolderOpen } 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,6 +9,17 @@ 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 SaveEmailAsPdfModalProps {
|
interface SaveEmailAsPdfModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -24,7 +35,7 @@ type SelectedTarget = {
|
|||||||
label: string;
|
label: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SaveMode = 'document' | 'invoice';
|
type SaveMode = 'document' | 'invoice' | 'contractDocument';
|
||||||
|
|
||||||
export default function SaveEmailAsPdfModal({
|
export default function SaveEmailAsPdfModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
@@ -40,6 +51,11 @@ export default function SaveEmailAsPdfModal({
|
|||||||
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 (gleiche wie bei Anhängen)
|
// Ziele laden (gleiche wie bei Anhängen)
|
||||||
@@ -51,8 +67,10 @@ export default function SaveEmailAsPdfModal({
|
|||||||
|
|
||||||
const targets = targetsData?.data;
|
const targets = targetsData?.data;
|
||||||
|
|
||||||
// Prüfen ob es ein Energievertrag ist
|
// Prüfen ob es ein Energievertrag ist (für Rechnungs-Modus)
|
||||||
const isEnergyContract = targets?.contract?.type === 'ELECTRICITY' || targets?.contract?.type === 'GAS';
|
const isEnergyContract = targets?.contract?.type === 'ELECTRICITY' || targets?.contract?.type === 'GAS';
|
||||||
|
// Vertrag zugeordnet? → Vertragsdokument-Modus erlaubt
|
||||||
|
const hasContract = !!targets?.contract;
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: () => {
|
mutationFn: () => {
|
||||||
@@ -111,6 +129,33 @@ export default function SaveEmailAsPdfModal({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const saveContractDocumentMutation = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
const isDelivery = contractDocumentData.documentType.trim().toLowerCase() === 'lieferbestätigung';
|
||||||
|
return cachedEmailApi.saveEmailAsContractDocument(emailId, {
|
||||||
|
documentType: contractDocumentData.documentType,
|
||||||
|
notes: contractDocumentData.notes || undefined,
|
||||||
|
deliveryDate: isDelivery ? contractDocumentData.deliveryDate : undefined,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('E-Mail 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');
|
||||||
@@ -119,6 +164,11 @@ export default function SaveEmailAsPdfModal({
|
|||||||
invoiceType: 'INTERIM',
|
invoiceType: 'INTERIM',
|
||||||
notes: '',
|
notes: '',
|
||||||
});
|
});
|
||||||
|
setContractDocumentData({
|
||||||
|
documentType: CONTRACT_DOCUMENT_TYPES[0],
|
||||||
|
notes: '',
|
||||||
|
deliveryDate: new Date().toISOString().split('T')[0],
|
||||||
|
});
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -235,7 +285,7 @@ export default function SaveEmailAsPdfModal({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isPending = saveMutation.isPending || saveInvoiceMutation.isPending;
|
const isPending = saveMutation.isPending || saveInvoiceMutation.isPending || saveContractDocumentMutation.isPending;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={handleClose} title="E-Mail als PDF speichern" size="lg">
|
<Modal isOpen={isOpen} onClose={handleClose} title="E-Mail als PDF speichern" size="lg">
|
||||||
@@ -263,12 +313,13 @@ export default function SaveEmailAsPdfModal({
|
|||||||
|
|
||||||
{targets && (
|
{targets && (
|
||||||
<>
|
<>
|
||||||
{/* Mode Toggle für Energieverträge */}
|
{/* Mode Toggle: Vertragsdokument-Tab wenn ein Vertrag verknüpft ist,
|
||||||
{isEnergyContract && (
|
Rechnungs-Tab nur bei Energieverträgen. */}
|
||||||
|
{hasContract && (
|
||||||
<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-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
className={`flex-1 flex items-center justify-center gap-2 px-3 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'
|
||||||
@@ -278,16 +329,29 @@ export default function SaveEmailAsPdfModal({
|
|||||||
Als Dokument
|
Als Dokument
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSaveMode('invoice')}
|
onClick={() => setSaveMode('contractDocument')}
|
||||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||||
saveMode === 'invoice'
|
saveMode === 'contractDocument'
|
||||||
? 'bg-white text-green-600 shadow-sm'
|
? 'bg-white text-orange-600 shadow-sm'
|
||||||
: 'text-gray-600 hover:text-gray-900'
|
: 'text-gray-600 hover:text-gray-900'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Receipt className="w-4 h-4" />
|
<FolderOpen className="w-4 h-4" />
|
||||||
Als Rechnung
|
Vertragsdokument
|
||||||
</button>
|
</button>
|
||||||
|
{isEnergyContract && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSaveMode('invoice')}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||||
|
saveMode === 'invoice'
|
||||||
|
? 'bg-white text-green-600 shadow-sm'
|
||||||
|
: 'text-gray-600 hover:text-gray-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Receipt className="w-4 h-4" />
|
||||||
|
Als Rechnung
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -377,6 +441,46 @@ export default function SaveEmailAsPdfModal({
|
|||||||
/>
|
/>
|
||||||
</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">
|
||||||
|
Die E-Mail 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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -396,14 +500,15 @@ export default function SaveEmailAsPdfModal({
|
|||||||
<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={!selectedTarget || isPending}
|
disabled={!selectedTarget || isPending}
|
||||||
>
|
>
|
||||||
{isPending ? 'Wird erstellt...' : 'Als PDF speichern'}
|
{isPending ? 'Wird erstellt...' : 'Als PDF speichern'}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
)}
|
||||||
|
{saveMode === 'invoice' && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => saveInvoiceMutation.mutate()}
|
onClick={() => saveInvoiceMutation.mutate()}
|
||||||
disabled={!invoiceData.invoiceDate || isPending}
|
disabled={!invoiceData.invoiceDate || isPending}
|
||||||
@@ -411,6 +516,14 @@ export default function SaveEmailAsPdfModal({
|
|||||||
{isPending ? 'Wird erstellt...' : 'Als Rechnung speichern'}
|
{isPending ? 'Wird erstellt...' : 'Als Rechnung speichern'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{saveMode === 'contractDocument' && (
|
||||||
|
<Button
|
||||||
|
onClick={() => saveContractDocumentMutation.mutate()}
|
||||||
|
disabled={!contractDocumentData.documentType || isPending}
|
||||||
|
>
|
||||||
|
{isPending ? 'Wird erstellt...' : 'Als Vertragsdokument speichern'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// Hilfs-Funktionen für ComposeEmailModal und die zwei neuen Modals
|
||||||
|
// (Vertragsdokumente anhängen, Kundendaten einfügen).
|
||||||
|
|
||||||
|
import { fileUrl } from '../../utils/fileUrl';
|
||||||
|
import type { EmailAttachment } from '../../services/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holt eine Server-Datei (per fileUrl mit Token) und gibt sie als
|
||||||
|
* EmailAttachment zurück. Wird sowohl für ContractDocuments als auch
|
||||||
|
* für BankCard- und IdentityDocument-PDFs benutzt.
|
||||||
|
*
|
||||||
|
* Wirft mit aussagekräftiger Message, wenn der Download fehlschlägt –
|
||||||
|
* der Caller fängt das ab und zeigt einen Toast.
|
||||||
|
*/
|
||||||
|
export async function serverFileToAttachment(
|
||||||
|
documentPath: string,
|
||||||
|
filename: string,
|
||||||
|
): Promise<EmailAttachment> {
|
||||||
|
const url = fileUrl(documentPath);
|
||||||
|
if (!url) throw new Error(`Datei "${filename}" hat keinen Pfad.`);
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Download von "${filename}" fehlgeschlagen (HTTP ${response.status}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
const base64 = await blobToBase64(blob);
|
||||||
|
return {
|
||||||
|
filename,
|
||||||
|
content: base64,
|
||||||
|
contentType: blob.type || 'application/octet-stream',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function blobToBase64(blob: Blob): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
// data:application/pdf;base64,XYZ → XYZ
|
||||||
|
const result = reader.result as string;
|
||||||
|
const base64 = result.split(',')[1] ?? '';
|
||||||
|
resolve(base64);
|
||||||
|
};
|
||||||
|
reader.onerror = () => reject(reader.error || new Error('FileReader-Fehler'));
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gesamtgröße aller Anhänge berechnen (in Bytes, näherungsweise).
|
||||||
|
* Base64 ist ~33% größer als die Original-Bytes.
|
||||||
|
*/
|
||||||
|
export function totalAttachmentBytes(attachments: EmailAttachment[]): number {
|
||||||
|
return attachments.reduce(
|
||||||
|
(sum, att) => sum + Math.ceil(att.content.length * 0.75),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filename-Vorschlag für eine Bankkarte – mit IBAN-Suffix damit beim
|
||||||
|
* Empfänger klar ist, welches Konto gemeint ist.
|
||||||
|
*/
|
||||||
|
export function bankCardAttachmentName(iban: string | undefined): string {
|
||||||
|
if (!iban) return 'Bankkarte.pdf';
|
||||||
|
const lastFour = iban.replace(/\s+/g, '').slice(-4);
|
||||||
|
return `Bankkarte-${lastFour}.pdf`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filename-Vorschlag für Ausweis-PDF abhängig vom Typ.
|
||||||
|
*/
|
||||||
|
export function identityDocAttachmentName(
|
||||||
|
type: string,
|
||||||
|
documentNumber: string | undefined,
|
||||||
|
): string {
|
||||||
|
const base = type === 'PASSPORT'
|
||||||
|
? 'Reisepass'
|
||||||
|
: type === 'DRIVERS_LICENSE'
|
||||||
|
? 'Fuehrerschein'
|
||||||
|
: type === 'OTHER'
|
||||||
|
? 'Ausweisdokument'
|
||||||
|
: 'Personalausweis';
|
||||||
|
return documentNumber ? `${base}-${documentNumber}.pdf` : `${base}.pdf`;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { Upload } from 'lucide-react';
|
import { Upload, Images } from 'lucide-react';
|
||||||
import Button from './Button';
|
import Button from './Button';
|
||||||
|
import JpgToPdfModal from './JpgToPdfModal';
|
||||||
|
|
||||||
interface FileUploadProps {
|
interface FileUploadProps {
|
||||||
onUpload: (file: File) => Promise<void>;
|
onUpload: (file: File) => Promise<void>;
|
||||||
@@ -8,6 +9,10 @@ interface FileUploadProps {
|
|||||||
accept?: string;
|
accept?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
/** Standard: aktiv, sobald `accept` PDF einschließt. Explizit auf false setzen, um den Button auszublenden. */
|
||||||
|
enableJpgToPdf?: boolean;
|
||||||
|
/** Default-Name (ohne .pdf) für die aus JPGs erzeugte PDF. */
|
||||||
|
jpgToPdfFileNameHint?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FileUpload({
|
export default function FileUpload({
|
||||||
@@ -16,10 +21,16 @@ export default function FileUpload({
|
|||||||
accept = '.pdf,.jpg,.jpeg,.png',
|
accept = '.pdf,.jpg,.jpeg,.png',
|
||||||
label = 'Dokument hochladen',
|
label = 'Dokument hochladen',
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
enableJpgToPdf,
|
||||||
|
jpgToPdfFileNameHint,
|
||||||
}: FileUploadProps) {
|
}: FileUploadProps) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [dragOver, setDragOver] = useState(false);
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const [isJpgModalOpen, setIsJpgModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const acceptsPdf = /pdf/i.test(accept);
|
||||||
|
const showJpgButton = (enableJpgToPdf ?? acceptsPdf) && !disabled;
|
||||||
|
|
||||||
const handleFileSelect = async (file: File) => {
|
const handleFileSelect = async (file: File) => {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@@ -64,40 +75,67 @@ export default function FileUpload({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{existingFile ? (
|
{existingFile ? (
|
||||||
!disabled && (
|
!disabled && (
|
||||||
<Button
|
<div className="flex flex-wrap gap-2">
|
||||||
variant="secondary"
|
<Button
|
||||||
size="sm"
|
variant="secondary"
|
||||||
onClick={() => inputRef.current?.click()}
|
size="sm"
|
||||||
disabled={isUploading}
|
onClick={() => inputRef.current?.click()}
|
||||||
>
|
disabled={isUploading}
|
||||||
{isUploading ? 'Wird hochgeladen...' : 'Ersetzen'}
|
>
|
||||||
</Button>
|
{isUploading ? 'Wird hochgeladen...' : 'Ersetzen'}
|
||||||
|
</Button>
|
||||||
|
{showJpgButton && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsJpgModalOpen(true)}
|
||||||
|
disabled={isUploading}
|
||||||
|
title="Mehrere JPGs zu einer PDF kombinieren"
|
||||||
|
>
|
||||||
|
<Images className="w-4 h-4 mr-1" /> JPGs → PDF
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<div
|
<>
|
||||||
className={`border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors ${
|
<div
|
||||||
dragOver
|
className={`border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors ${
|
||||||
? 'border-blue-500 bg-blue-50'
|
dragOver
|
||||||
: 'border-gray-300 hover:border-gray-400'
|
? 'border-blue-500 bg-blue-50'
|
||||||
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
: 'border-gray-300 hover:border-gray-400'
|
||||||
onClick={() => !disabled && inputRef.current?.click()}
|
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
onDrop={!disabled ? handleDrop : undefined}
|
onClick={() => !disabled && inputRef.current?.click()}
|
||||||
onDragOver={!disabled ? handleDragOver : undefined}
|
onDrop={!disabled ? handleDrop : undefined}
|
||||||
onDragLeave={!disabled ? handleDragLeave : undefined}
|
onDragOver={!disabled ? handleDragOver : undefined}
|
||||||
>
|
onDragLeave={!disabled ? handleDragLeave : undefined}
|
||||||
{isUploading ? (
|
>
|
||||||
<div className="text-gray-500">
|
{isUploading ? (
|
||||||
<div className="animate-spin w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full mx-auto mb-2" />
|
<div className="text-gray-500">
|
||||||
Wird hochgeladen...
|
<div className="animate-spin w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full mx-auto mb-2" />
|
||||||
|
Wird hochgeladen...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload className="w-6 h-6 text-gray-400 mx-auto mb-2" />
|
||||||
|
<p className="text-sm text-gray-600">{label}</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">PDF, JPG oder PNG (max. 25 MB)</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showJpgButton && !isUploading && (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsJpgModalOpen(true)}
|
||||||
|
title="Mehrere JPGs zu einer PDF kombinieren"
|
||||||
|
>
|
||||||
|
<Images className="w-4 h-4 mr-1" /> JPGs → PDF
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Upload className="w-6 h-6 text-gray-400 mx-auto mb-2" />
|
|
||||||
<p className="text-sm text-gray-600">{label}</p>
|
|
||||||
<p className="text-xs text-gray-400 mt-1">PDF, JPG oder PNG (max. 10MB)</p>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<input
|
<input
|
||||||
@@ -108,6 +146,15 @@ export default function FileUpload({
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
disabled={disabled || isUploading}
|
disabled={disabled || isUploading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<JpgToPdfModal
|
||||||
|
isOpen={isJpgModalOpen}
|
||||||
|
onClose={() => setIsJpgModalOpen(false)}
|
||||||
|
onComplete={(file) => {
|
||||||
|
handleFileSelect(file);
|
||||||
|
}}
|
||||||
|
fileNameHint={jpgToPdfFileNameHint}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,515 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { jsPDF } from 'jspdf';
|
||||||
|
import {
|
||||||
|
Upload,
|
||||||
|
RotateCcw,
|
||||||
|
RotateCw,
|
||||||
|
FlipHorizontal,
|
||||||
|
FlipVertical,
|
||||||
|
Trash2,
|
||||||
|
FileText,
|
||||||
|
Repeat,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import Modal from './Modal';
|
||||||
|
import Button from './Button';
|
||||||
|
|
||||||
|
interface ImageItem {
|
||||||
|
id: string;
|
||||||
|
dataUrl: string;
|
||||||
|
naturalWidth: number;
|
||||||
|
naturalHeight: number;
|
||||||
|
rotation: 0 | 90 | 180 | 270;
|
||||||
|
flipH: boolean;
|
||||||
|
flipV: boolean;
|
||||||
|
fileName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JpgToPdfModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onComplete: (pdfFile: File) => void;
|
||||||
|
fileNameHint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pentest 68.2 (INFO): Self-DoS-Schutz – Modal kann sonst den Tab des
|
||||||
|
// Uploaders selbst zum Absturz bringen. Werte konservativ gewählt:
|
||||||
|
// 50 Bilder × 25 MB = 1.25 GB ist mehr als jede legitime Vollmacht.
|
||||||
|
const MAX_IMAGES = 50;
|
||||||
|
const MAX_IMAGE_BYTES = 25 * 1024 * 1024;
|
||||||
|
|
||||||
|
// Smartphone-Fotos haben oft 4000-6000 px Kante. Bei JPEG-Quality
|
||||||
|
// 0.95 sind das 5-10 MB pro Seite, zwei Bilder = >10 MB PDF.
|
||||||
|
// 2400 px lange Kante entspricht ~290 DPI auf A4 (Druckqualität) und
|
||||||
|
// reduziert die Pixelmenge auf 25-36 % vom Original → PDF wird
|
||||||
|
// drastisch kleiner, sichtbarer Unterschied praktisch null.
|
||||||
|
const MAX_DIMENSION = 2400;
|
||||||
|
const EMBED_QUALITY = 0.92;
|
||||||
|
|
||||||
|
function makeId() {
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFileAsDataUrl(file: File): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(reader.result as string);
|
||||||
|
reader.onerror = () => reject(reader.error);
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadImage(dataUrl: string): Promise<HTMLImageElement> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => resolve(img);
|
||||||
|
img.onerror = () => reject(new Error('Bild-Decode fehlgeschlagen'));
|
||||||
|
img.src = dataUrl;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function downscaleIfNeeded(image: HTMLImageElement): HTMLCanvasElement | null {
|
||||||
|
const w = image.naturalWidth;
|
||||||
|
const h = image.naturalHeight;
|
||||||
|
if (w <= MAX_DIMENSION && h <= MAX_DIMENSION) return null;
|
||||||
|
const scale = MAX_DIMENSION / Math.max(w, h);
|
||||||
|
const newW = Math.round(w * scale);
|
||||||
|
const newH = Math.round(h * scale);
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = newW;
|
||||||
|
canvas.height = newH;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return null;
|
||||||
|
ctx.imageSmoothingQuality = 'high';
|
||||||
|
ctx.drawImage(image, 0, 0, newW, newH);
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderImageToCanvas(image: HTMLImageElement, item: ImageItem): HTMLCanvasElement {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) throw new Error('Canvas-Kontext konnte nicht erstellt werden');
|
||||||
|
|
||||||
|
const w = image.naturalWidth;
|
||||||
|
const h = image.naturalHeight;
|
||||||
|
const rotated = item.rotation === 90 || item.rotation === 270;
|
||||||
|
|
||||||
|
canvas.width = rotated ? h : w;
|
||||||
|
canvas.height = rotated ? w : h;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(canvas.width / 2, canvas.height / 2);
|
||||||
|
ctx.rotate((item.rotation * Math.PI) / 180);
|
||||||
|
ctx.scale(item.flipH ? -1 : 1, item.flipV ? -1 : 1);
|
||||||
|
ctx.drawImage(image, -w / 2, -h / 2);
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function JpgToPdfModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onComplete,
|
||||||
|
fileNameHint,
|
||||||
|
}: JpgToPdfModalProps) {
|
||||||
|
const [images, setImages] = useState<ImageItem[]>([]);
|
||||||
|
const [dragSrcIdx, setDragSrcIdx] = useState<number | null>(null);
|
||||||
|
const [isOverModal, setIsOverModal] = useState(false);
|
||||||
|
const [isBuilding, setIsBuilding] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setImages([]);
|
||||||
|
setDragSrcIdx(null);
|
||||||
|
setIsOverModal(false);
|
||||||
|
setIsBuilding(false);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const addFiles = useCallback(async (files: FileList | File[]) => {
|
||||||
|
const list = Array.from(files).filter((f) => f.type.startsWith('image/'));
|
||||||
|
if (list.length === 0) {
|
||||||
|
setError('Nur Bilddateien erlaubt (JPG/PNG).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
const added: ImageItem[] = [];
|
||||||
|
for (const file of list) {
|
||||||
|
// 68.2: Self-DoS-Schutz – harte Schranken pro Bild und gesamt.
|
||||||
|
if (file.size > MAX_IMAGE_BYTES) {
|
||||||
|
setError(`Bild zu groß (max. ${Math.round(MAX_IMAGE_BYTES / 1024 / 1024)} MB): ${file.name || 'unbenannt'}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (images.length + added.length >= MAX_IMAGES) {
|
||||||
|
setError(`Maximal ${MAX_IMAGES} Bilder pro PDF erlaubt.`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const rawDataUrl = await readFileAsDataUrl(file);
|
||||||
|
const img = await loadImage(rawDataUrl);
|
||||||
|
// Beim Hinzufügen direkt auf MAX_DIMENSION runterskalieren, damit
|
||||||
|
// die Vorschau, das Rendern in der PDF und die finale Dateigröße
|
||||||
|
// alle auf vernünftigen Pixelmaßen arbeiten.
|
||||||
|
const downscaled = downscaleIfNeeded(img);
|
||||||
|
const dataUrl = downscaled
|
||||||
|
? downscaled.toDataURL('image/jpeg', EMBED_QUALITY)
|
||||||
|
: rawDataUrl;
|
||||||
|
const finalW = downscaled ? downscaled.width : img.naturalWidth;
|
||||||
|
const finalH = downscaled ? downscaled.height : img.naturalHeight;
|
||||||
|
added.push({
|
||||||
|
id: makeId(),
|
||||||
|
dataUrl,
|
||||||
|
naturalWidth: finalW,
|
||||||
|
naturalHeight: finalH,
|
||||||
|
rotation: 0,
|
||||||
|
flipH: false,
|
||||||
|
flipV: false,
|
||||||
|
fileName: file.name || 'clipboard.png',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
setError(`Bild konnte nicht geladen werden: ${file.name || 'unbenannt'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (added.length > 0) {
|
||||||
|
setImages((prev) => [...prev, ...added]);
|
||||||
|
}
|
||||||
|
}, [images.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const handler = (e: ClipboardEvent) => {
|
||||||
|
const items = e.clipboardData?.items;
|
||||||
|
if (!items) return;
|
||||||
|
const files: File[] = [];
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const it = items[i];
|
||||||
|
if (it.kind === 'file' && it.type.startsWith('image/')) {
|
||||||
|
const f = it.getAsFile();
|
||||||
|
if (f) files.push(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (files.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
addFiles(files);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('paste', handler);
|
||||||
|
return () => document.removeEventListener('paste', handler);
|
||||||
|
}, [isOpen, addFiles]);
|
||||||
|
|
||||||
|
const rotate = (id: string, delta: 90 | -90 | 180) => {
|
||||||
|
setImages((prev) =>
|
||||||
|
prev.map((it) =>
|
||||||
|
it.id === id
|
||||||
|
? {
|
||||||
|
...it,
|
||||||
|
rotation: ((((it.rotation + delta) % 360) + 360) % 360) as ImageItem['rotation'],
|
||||||
|
}
|
||||||
|
: it
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const flip = (id: string, axis: 'h' | 'v') => {
|
||||||
|
setImages((prev) =>
|
||||||
|
prev.map((it) =>
|
||||||
|
it.id === id
|
||||||
|
? { ...it, ...(axis === 'h' ? { flipH: !it.flipH } : { flipV: !it.flipV }) }
|
||||||
|
: it
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = (id: string) => {
|
||||||
|
setImages((prev) => prev.filter((it) => it.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragStart = (idx: number) => (e: React.DragEvent) => {
|
||||||
|
setDragSrcIdx(idx);
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
e.dataTransfer.setData('text/plain', String(idx));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleItemDragOver = (e: React.DragEvent) => {
|
||||||
|
if (dragSrcIdx === null) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDropOnItem = (idx: number) => (e: React.DragEvent) => {
|
||||||
|
if (dragSrcIdx === null) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (dragSrcIdx === idx) {
|
||||||
|
setDragSrcIdx(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setImages((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
const [moved] = next.splice(dragSrcIdx, 1);
|
||||||
|
next.splice(idx, 0, moved);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setDragSrcIdx(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModalDragOver = (e: React.DragEvent) => {
|
||||||
|
if (e.dataTransfer.types.includes('Files')) {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsOverModal(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleModalDragLeave = (e: React.DragEvent) => {
|
||||||
|
if (e.target === e.currentTarget) setIsOverModal(false);
|
||||||
|
};
|
||||||
|
const handleModalDrop = (e: React.DragEvent) => {
|
||||||
|
if (e.dataTransfer.files.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsOverModal(false);
|
||||||
|
addFiles(e.dataTransfer.files);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPdf = async () => {
|
||||||
|
if (images.length === 0) return;
|
||||||
|
setIsBuilding(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const a4w = 210;
|
||||||
|
const a4h = 297;
|
||||||
|
let pdf: jsPDF | null = null;
|
||||||
|
|
||||||
|
for (let i = 0; i < images.length; i++) {
|
||||||
|
const item = images[i];
|
||||||
|
const untouched = item.rotation === 0 && !item.flipH && !item.flipV;
|
||||||
|
|
||||||
|
// Fix 1: ungedrehte/ungespiegelte Bilder bekommen ihre Original-Bytes
|
||||||
|
// direkt in die PDF eingebettet – kein Canvas-Re-Encode, kein
|
||||||
|
// Quality-Aufblasen. Ein 2-MB-JPEG bleibt 2 MB statt 8-15 MB zu werden.
|
||||||
|
// Fix 2: wenn doch transformiert wird (Rotation/Flip), Canvas mit
|
||||||
|
// Quality 0.95 statt 1.0 – visuell identisch für Foto-Inhalte, aber
|
||||||
|
// 50-70 % kleiner.
|
||||||
|
let imageData: string;
|
||||||
|
let imageFormat: 'JPEG' | 'PNG';
|
||||||
|
let srcW: number;
|
||||||
|
let srcH: number;
|
||||||
|
|
||||||
|
if (untouched) {
|
||||||
|
imageData = item.dataUrl;
|
||||||
|
imageFormat = item.dataUrl.startsWith('data:image/png') ? 'PNG' : 'JPEG';
|
||||||
|
srcW = item.naturalWidth;
|
||||||
|
srcH = item.naturalHeight;
|
||||||
|
} else {
|
||||||
|
const img = await loadImage(item.dataUrl);
|
||||||
|
const canvas = renderImageToCanvas(img, item);
|
||||||
|
imageData = canvas.toDataURL('image/jpeg', EMBED_QUALITY);
|
||||||
|
imageFormat = 'JPEG';
|
||||||
|
srcW = canvas.width;
|
||||||
|
srcH = canvas.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orientation: 'portrait' | 'landscape' =
|
||||||
|
srcW > srcH ? 'landscape' : 'portrait';
|
||||||
|
const pageW = orientation === 'landscape' ? a4h : a4w;
|
||||||
|
const pageH = orientation === 'landscape' ? a4w : a4h;
|
||||||
|
|
||||||
|
if (!pdf) {
|
||||||
|
pdf = new jsPDF({ orientation, unit: 'mm', format: 'a4' });
|
||||||
|
} else {
|
||||||
|
pdf.addPage('a4', orientation);
|
||||||
|
}
|
||||||
|
|
||||||
|
const margin = 5;
|
||||||
|
const maxW = pageW - 2 * margin;
|
||||||
|
const maxH = pageH - 2 * margin;
|
||||||
|
const ratio = Math.min(maxW / srcW, maxH / srcH);
|
||||||
|
const w = srcW * ratio;
|
||||||
|
const h = srcH * ratio;
|
||||||
|
const x = (pageW - w) / 2;
|
||||||
|
const y = (pageH - h) / 2;
|
||||||
|
|
||||||
|
pdf.addImage(imageData, imageFormat, x, y, w, h, undefined, 'SLOW');
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = pdf!.output('blob');
|
||||||
|
const base = (fileNameHint || 'bilder').replace(/[^\w.-]+/g, '_').slice(0, 80) || 'bilder';
|
||||||
|
const file = new File([blob], `${base}.pdf`, { type: 'application/pdf' });
|
||||||
|
onComplete(file);
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('PDF-Erstellung fehlgeschlagen:', e);
|
||||||
|
setError('PDF konnte nicht erstellt werden.');
|
||||||
|
} finally {
|
||||||
|
setIsBuilding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} title="JPGs zu PDF" size="xl">
|
||||||
|
<div
|
||||||
|
onDragOver={handleModalDragOver}
|
||||||
|
onDragLeave={handleModalDragLeave}
|
||||||
|
onDrop={handleModalDrop}
|
||||||
|
className={`space-y-4 ${isOverModal ? 'ring-2 ring-blue-400 rounded-lg' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Bilder per Klick wählen, hier reinziehen, oder mit{' '}
|
||||||
|
<kbd className="px-1.5 py-0.5 border rounded text-xs">Strg</kbd>+
|
||||||
|
<kbd className="px-1.5 py-0.5 border rounded text-xs">V</kbd> aus der Zwischenablage
|
||||||
|
einfügen.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={isBuilding}
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4 mr-1" /> Bilder hinzufügen
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.files && e.target.files.length > 0) addFiles(e.target.files);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{images.length === 0 ? (
|
||||||
|
<div
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="border-2 border-dashed rounded-lg p-12 text-center cursor-pointer border-gray-300 hover:border-gray-400 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<Upload className="w-8 h-8 text-gray-400 mx-auto mb-2" />
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Bilder hier hineinziehen oder zum Auswählen klicken
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
JPG / PNG · Reihenfolge per Drag & Drop, einzeln drehen/spiegeln
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{images.length} {images.length === 1 ? 'Bild' : 'Bilder'} · Reihenfolge per
|
||||||
|
Drag&Drop, einzeln drehen/spiegeln. Jedes Bild = eine Seite.
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3 max-h-[60vh] overflow-y-auto p-1">
|
||||||
|
{images.map((item, idx) => {
|
||||||
|
const transforms = [
|
||||||
|
`rotate(${item.rotation}deg)`,
|
||||||
|
`scaleX(${item.flipH ? -1 : 1})`,
|
||||||
|
`scaleY(${item.flipV ? -1 : 1})`,
|
||||||
|
].join(' ');
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
draggable
|
||||||
|
onDragStart={handleDragStart(idx)}
|
||||||
|
onDragOver={handleItemDragOver}
|
||||||
|
onDrop={handleDropOnItem(idx)}
|
||||||
|
className={`border rounded-lg p-2 bg-white shadow-sm cursor-move ${
|
||||||
|
dragSrcIdx === idx ? 'opacity-40' : ''
|
||||||
|
}`}
|
||||||
|
title="Zum Sortieren ziehen"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-1.5 text-xs">
|
||||||
|
<span className="font-semibold text-gray-700">#{idx + 1}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => remove(item.id)}
|
||||||
|
className="text-red-500 hover:text-red-700"
|
||||||
|
title="Entfernen"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="aspect-square bg-gray-100 rounded flex items-center justify-center overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={item.dataUrl}
|
||||||
|
alt={item.fileName}
|
||||||
|
style={{ transform: transforms }}
|
||||||
|
className="max-w-full max-h-full object-contain transition-transform"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 flex justify-center gap-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => rotate(item.id, -90)}
|
||||||
|
className="p-1 hover:bg-gray-100 rounded"
|
||||||
|
title="90° gegen Uhrzeigersinn"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => rotate(item.id, 90)}
|
||||||
|
className="p-1 hover:bg-gray-100 rounded"
|
||||||
|
title="90° im Uhrzeigersinn"
|
||||||
|
>
|
||||||
|
<RotateCw className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => rotate(item.id, 180)}
|
||||||
|
className="p-1 hover:bg-gray-100 rounded"
|
||||||
|
title="180° drehen"
|
||||||
|
>
|
||||||
|
<Repeat className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => flip(item.id, 'h')}
|
||||||
|
className={`p-1 rounded ${
|
||||||
|
item.flipH ? 'bg-blue-100 text-blue-700' : 'hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
title="Horizontal spiegeln"
|
||||||
|
>
|
||||||
|
<FlipHorizontal className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => flip(item.id, 'v')}
|
||||||
|
className={`p-1 rounded ${
|
||||||
|
item.flipV ? 'bg-blue-100 text-blue-700' : 'hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
title="Vertikal spiegeln"
|
||||||
|
>
|
||||||
|
<FlipVertical className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2 border-t">
|
||||||
|
<Button variant="ghost" onClick={onClose} disabled={isBuilding}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button onClick={buildPdf} disabled={images.length === 0 || isBuilding}>
|
||||||
|
<FileText className="w-4 h-4 mr-1" />
|
||||||
|
{isBuilding ? 'Erstelle PDF...' : 'PDF erstellen & hochladen'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { SelectHTMLAttributes, forwardRef } from 'react';
|
import { SelectHTMLAttributes, forwardRef, ReactNode } from 'react';
|
||||||
|
|
||||||
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||||
label?: string;
|
label?: ReactNode;
|
||||||
error?: string;
|
error?: string;
|
||||||
options: { value: string | number; label: string }[];
|
options: { value: string | number; label: string }[];
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ReactNode, useState, useEffect } from 'react';
|
import { ReactNode, useState, useEffect } from 'react';
|
||||||
|
import { ExternalLink } from 'lucide-react';
|
||||||
|
|
||||||
interface Tab {
|
interface Tab {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -11,9 +12,21 @@ interface TabsProps {
|
|||||||
defaultTab?: string;
|
defaultTab?: string;
|
||||||
activeTab?: string;
|
activeTab?: string;
|
||||||
onTabChange?: (tabId: string) => void;
|
onTabChange?: (tabId: string) => void;
|
||||||
|
/**
|
||||||
|
* Optional: liefert die URL, unter der ein einzelner Tab in einem
|
||||||
|
* neuen Tab geöffnet werden kann. Wenn gesetzt, erscheint neben jedem
|
||||||
|
* Tab-Label ein kleines „im neuen Tab öffnen"-Icon.
|
||||||
|
*/
|
||||||
|
tabHrefBuilder?: (tabId: string) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Tabs({ tabs, defaultTab, activeTab: controlledTab, onTabChange }: TabsProps) {
|
export default function Tabs({
|
||||||
|
tabs,
|
||||||
|
defaultTab,
|
||||||
|
activeTab: controlledTab,
|
||||||
|
onTabChange,
|
||||||
|
tabHrefBuilder,
|
||||||
|
}: TabsProps) {
|
||||||
const [internalTab, setInternalTab] = useState(defaultTab || tabs[0]?.id);
|
const [internalTab, setInternalTab] = useState(defaultTab || tabs[0]?.id);
|
||||||
const activeTab = controlledTab ?? internalTab;
|
const activeTab = controlledTab ?? internalTab;
|
||||||
|
|
||||||
@@ -31,19 +44,33 @@ export default function Tabs({ tabs, defaultTab, activeTab: controlledTab, onTab
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="border-b border-gray-200">
|
<div className="border-b border-gray-200">
|
||||||
<nav className="flex -mb-px space-x-8">
|
<nav className="flex -mb-px space-x-6">
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<button
|
<div key={tab.id} className="flex items-center gap-1">
|
||||||
key={tab.id}
|
<button
|
||||||
onClick={() => handleTabChange(tab.id)}
|
onClick={() => handleTabChange(tab.id)}
|
||||||
className={`py-4 px-1 border-b-2 font-medium text-sm whitespace-nowrap ${
|
className={`py-4 px-1 border-b-2 font-medium text-sm whitespace-nowrap ${
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? 'border-blue-500 text-blue-600'
|
? 'border-blue-500 text-blue-600'
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</button>
|
</button>
|
||||||
|
{tabHrefBuilder && (
|
||||||
|
<a
|
||||||
|
href={tabHrefBuilder(tab.id)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="text-gray-400 hover:text-blue-600 p-0.5"
|
||||||
|
title={`${tab.label} in neuem Tab öffnen`}
|
||||||
|
aria-label={`${tab.label} in neuem Tab öffnen`}
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { contractApi, uploadApi, meterApi, contractTaskApi, appSettingsApi, gdpr
|
|||||||
import { ContractEmailsSection } from '../../components/email';
|
import { ContractEmailsSection } from '../../components/email';
|
||||||
import { ContractDetailModal, ContractHistorySection } from '../../components/contracts';
|
import { ContractDetailModal, ContractHistorySection } from '../../components/contracts';
|
||||||
import InvoicesSection from '../../components/contracts/InvoicesSection';
|
import InvoicesSection from '../../components/contracts/InvoicesSection';
|
||||||
|
import CustomerInfoModal from '../../components/contracts/CustomerInfoModal';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import Card from '../../components/ui/Card';
|
import Card from '../../components/ui/Card';
|
||||||
import Button from '../../components/ui/Button';
|
import Button from '../../components/ui/Button';
|
||||||
@@ -13,13 +14,15 @@ import Badge from '../../components/ui/Badge';
|
|||||||
import Input from '../../components/ui/Input';
|
import Input from '../../components/ui/Input';
|
||||||
import Modal from '../../components/ui/Modal';
|
import Modal from '../../components/ui/Modal';
|
||||||
import FileUpload from '../../components/ui/FileUpload';
|
import FileUpload from '../../components/ui/FileUpload';
|
||||||
import { Edit, Trash2, Copy, Eye, EyeOff, ArrowLeft, ArrowRight, Download, ExternalLink, Plus, ChevronDown, ChevronUp, Gauge, CheckCircle, Circle, ClipboardList, MessageSquare, Calculator, Info, X, BellOff, Lock, Shield, FileText } from 'lucide-react';
|
import { Edit, Trash2, Copy, Eye, EyeOff, ArrowLeft, ArrowRight, Download, ExternalLink, Plus, ChevronDown, ChevronUp, Gauge, CheckCircle, Circle, ClipboardList, MessageSquare, Calculator, Info, X, BellOff, Lock, Shield, FileText, Images } from 'lucide-react';
|
||||||
|
import JpgToPdfModal from '../../components/ui/JpgToPdfModal';
|
||||||
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 { 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';
|
import { fileUrl, viewUrl } from '../../utils/fileUrl';
|
||||||
|
import { safeHttpUrl } from '../../utils/url';
|
||||||
|
|
||||||
const typeLabels: Record<ContractType, string> = {
|
const typeLabels: Record<ContractType, string> = {
|
||||||
ELECTRICITY: 'Strom',
|
ELECTRICITY: 'Strom',
|
||||||
@@ -133,9 +136,16 @@ function SimCardDisplay({ simCard }: { simCard: SimCard }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-3 bg-gray-50 rounded-lg border">
|
<div className="p-3 bg-gray-50 rounded-lg border">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
{simCard.isMain && <Badge variant="success">Hauptkarte</Badge>}
|
{simCard.isMain && <Badge variant="success">Hauptkarte</Badge>}
|
||||||
|
{simCard.isEsim && <Badge variant="info">eSIM</Badge>}
|
||||||
{simCard.isMultisim && <Badge variant="warning">Multisim</Badge>}
|
{simCard.isMultisim && <Badge variant="warning">Multisim</Badge>}
|
||||||
|
{simCard.cardUser && (
|
||||||
|
<span className="text-sm text-gray-700">
|
||||||
|
<span className="text-gray-500">Nutzer:</span>{' '}
|
||||||
|
<span className="font-medium">{simCard.cardUser}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<dl className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
|
<dl className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
|
||||||
{simCard.phoneNumber && (
|
{simCard.phoneNumber && (
|
||||||
@@ -360,6 +370,7 @@ function SuccessorMeterButton({
|
|||||||
const [selectedMeterId, setSelectedMeterId] = useState('');
|
const [selectedMeterId, setSelectedMeterId] = useState('');
|
||||||
const [installedAt, setInstalledAt] = useState(new Date().toISOString().split('T')[0]);
|
const [installedAt, setInstalledAt] = useState(new Date().toISOString().split('T')[0]);
|
||||||
const [finalReading, setFinalReading] = useState('');
|
const [finalReading, setFinalReading] = useState('');
|
||||||
|
const [deactivatePredecessor, setDeactivatePredecessor] = useState(true);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: metersData } = useQuery({
|
const { data: metersData } = useQuery({
|
||||||
@@ -369,13 +380,14 @@ function SuccessorMeterButton({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const addMutation = useMutation({
|
const addMutation = useMutation({
|
||||||
mutationFn: (data: { meterId: number; installedAt?: string; finalReadingPrevious?: number }) =>
|
mutationFn: (data: { meterId: number; installedAt?: string; finalReadingPrevious?: number; deactivatePredecessor?: boolean }) =>
|
||||||
contractApi.addSuccessorMeter(contractId, data),
|
contractApi.addSuccessorMeter(contractId, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['contract'] });
|
queryClient.invalidateQueries({ queryKey: ['contract'] });
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
setSelectedMeterId('');
|
setSelectedMeterId('');
|
||||||
setFinalReading('');
|
setFinalReading('');
|
||||||
|
setDeactivatePredecessor(true);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -397,7 +409,19 @@ function SuccessorMeterButton({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 pt-3 border-t bg-blue-50 rounded-lg p-4">
|
<div className="mt-3 pt-3 border-t bg-blue-50 rounded-lg p-4">
|
||||||
<h4 className="text-sm font-medium mb-3">Folgezähler hinzufügen (Zählerwechsel)</h4>
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<h4 className="text-sm font-medium">Folgezähler hinzufügen (Zählerwechsel)</h4>
|
||||||
|
<Link
|
||||||
|
to={`/customers/${customerId}?tab=meters`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800 hover:underline font-normal"
|
||||||
|
title="Zähler-Übersicht des Kunden in neuem Tab öffnen"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
Zähler verwalten
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Neuer Zähler *</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">Neuer Zähler *</label>
|
||||||
@@ -432,6 +456,21 @@ function SuccessorMeterButton({
|
|||||||
placeholder="Optional"
|
placeholder="Optional"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{finalReading && (
|
||||||
|
<p className="text-xs text-blue-700 mt-2">
|
||||||
|
Wird automatisch als Zählerstand des alten Zählers zum Wechseldatum
|
||||||
|
erfasst und fließt damit in die Verbrauchsberechnung ein.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<label className="flex items-center gap-2 mt-3 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={deactivatePredecessor}
|
||||||
|
onChange={(e) => setDeactivatePredecessor(e.target.checked)}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
Alten Zähler deaktivieren
|
||||||
|
</label>
|
||||||
<div className="flex gap-2 mt-3">
|
<div className="flex gap-2 mt-3">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -439,6 +478,7 @@ function SuccessorMeterButton({
|
|||||||
meterId: parseInt(selectedMeterId),
|
meterId: parseInt(selectedMeterId),
|
||||||
installedAt,
|
installedAt,
|
||||||
finalReadingPrevious: finalReading ? parseFloat(finalReading) : undefined,
|
finalReadingPrevious: finalReading ? parseFloat(finalReading) : undefined,
|
||||||
|
deactivatePredecessor,
|
||||||
})}
|
})}
|
||||||
disabled={!selectedMeterId || addMutation.isPending}
|
disabled={!selectedMeterId || addMutation.isPending}
|
||||||
>
|
>
|
||||||
@@ -609,7 +649,8 @@ function EnergyConsumptionCalculation({
|
|||||||
basePrice,
|
basePrice,
|
||||||
unitPrice,
|
unitPrice,
|
||||||
unitPriceNt,
|
unitPriceNt,
|
||||||
bonus,
|
instantBonus,
|
||||||
|
newCustomerBonus,
|
||||||
hasMeter,
|
hasMeter,
|
||||||
contractMeters,
|
contractMeters,
|
||||||
}: {
|
}: {
|
||||||
@@ -619,7 +660,8 @@ function EnergyConsumptionCalculation({
|
|||||||
endDate?: string;
|
endDate?: string;
|
||||||
basePrice?: number;
|
basePrice?: number;
|
||||||
unitPrice?: number;
|
unitPrice?: number;
|
||||||
bonus?: number;
|
instantBonus?: number;
|
||||||
|
newCustomerBonus?: number;
|
||||||
unitPriceNt?: number;
|
unitPriceNt?: number;
|
||||||
hasMeter?: boolean;
|
hasMeter?: boolean;
|
||||||
contractMeters?: ContractMeter[];
|
contractMeters?: ContractMeter[];
|
||||||
@@ -656,7 +698,7 @@ function EnergyConsumptionCalculation({
|
|||||||
const htKwh = consumption?.consumptionHt ?? consumption?.consumptionKwh ?? 0;
|
const htKwh = consumption?.consumptionHt ?? consumption?.consumptionKwh ?? 0;
|
||||||
const ntKwh = consumption?.consumptionNt;
|
const ntKwh = consumption?.consumptionNt;
|
||||||
const costs = consumption && consumption.consumptionKwh > 0
|
const costs = consumption && consumption.consumptionKwh > 0
|
||||||
? calculateCosts(htKwh, basePrice, unitPrice, bonus, ntKwh, unitPriceNt)
|
? calculateCosts(htKwh, basePrice, unitPrice, instantBonus, ntKwh, unitPriceNt, newCustomerBonus)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const canCalculate = consumption && (consumption.type === 'exact' || consumption.type === 'projected');
|
const canCalculate = consumption && (consumption.type === 'exact' || consumption.type === 'projected');
|
||||||
@@ -780,12 +822,24 @@ function EnergyConsumptionCalculation({
|
|||||||
<span className="font-mono">{formatNumber(costs.annualTotalCost)} €</span>
|
<span className="font-mono">{formatNumber(costs.annualTotalCost)} €</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Bonus */}
|
{/* Bonus – Sofort + Neukunden einzeln, dann Gesamt */}
|
||||||
{costs.bonus != null && costs.bonus > 0 && (
|
{costs.totalBonus != null && costs.totalBonus > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="flex justify-between text-green-600">
|
{costs.instantBonus != null && costs.instantBonus > 0 && (
|
||||||
<span>Bonus</span>
|
<div className="flex justify-between text-green-600">
|
||||||
<span className="font-mono">- {formatNumber(costs.bonus)} €</span>
|
<span>Sofort-Bonus</span>
|
||||||
|
<span className="font-mono">- {formatNumber(costs.instantBonus)} €</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{costs.newCustomerBonus != null && costs.newCustomerBonus > 0 && (
|
||||||
|
<div className="flex justify-between text-green-600">
|
||||||
|
<span>Neukunden-Bonus</span>
|
||||||
|
<span className="font-mono">- {formatNumber(costs.newCustomerBonus)} €</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between text-green-700 font-medium">
|
||||||
|
<span>Gesamtbonus</span>
|
||||||
|
<span className="font-mono">- {formatNumber(costs.totalBonus)} €</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t border-gray-300 pt-2">
|
<div className="border-t border-gray-300 pt-2">
|
||||||
<div className="flex justify-between font-semibold">
|
<div className="flex justify-between font-semibold">
|
||||||
@@ -1478,6 +1532,9 @@ export default function ContractDetail() {
|
|||||||
() => new Date().toISOString().split('T')[0]
|
() => new Date().toISOString().split('T')[0]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Kunden-Schnellansicht-Modal
|
||||||
|
const [showCustomerInfo, setShowCustomerInfo] = useState(false);
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['contract', id],
|
queryKey: ['contract', id],
|
||||||
queryFn: () => contractApi.getById(contractId),
|
queryFn: () => contractApi.getById(contractId),
|
||||||
@@ -1640,7 +1697,9 @@ export default function ContractDetail() {
|
|||||||
const contract = data?.data;
|
const contract = data?.data;
|
||||||
// Get username from stressfreiEmail or portalUsername
|
// Get username from stressfreiEmail or portalUsername
|
||||||
const username = contract?.stressfreiEmail?.email || contract?.portalUsername;
|
const username = contract?.stressfreiEmail?.email || contract?.portalUsername;
|
||||||
if (!contract?.provider?.portalUrl || !username) {
|
// Defense-in-depth: nur http(s) – verhindert `javascript:`-URLs aus Alt-Daten.
|
||||||
|
const safeUrl = safeHttpUrl(contract?.provider?.portalUrl);
|
||||||
|
if (!safeUrl || !username) {
|
||||||
alert('Portal-URL oder Benutzername fehlt');
|
alert('Portal-URL oder Benutzername fehlt');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1654,8 +1713,8 @@ export default function ContractDetail() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const provider = contract.provider;
|
const provider = contract!.provider!;
|
||||||
const baseUrl = provider.portalUrl!; // Already validated above
|
const baseUrl = safeUrl;
|
||||||
const usernameField = provider.usernameFieldName || 'username';
|
const usernameField = provider.usernameFieldName || 'username';
|
||||||
const passwordField = provider.passwordFieldName || 'password';
|
const passwordField = provider.passwordFieldName || 'password';
|
||||||
|
|
||||||
@@ -1682,6 +1741,23 @@ export default function ContractDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const c = data.data;
|
const c = data.data;
|
||||||
|
// Verbrauch aus Vorvertrag als Hinweis, wenn der Jahresverbrauch im aktuellen
|
||||||
|
// Vertrag noch leer ist. Greift nur bei Strom/Gas mit einem berechenbaren
|
||||||
|
// Vorvertrag.
|
||||||
|
const previousConsumption = (() => {
|
||||||
|
const pc = c.previousContract;
|
||||||
|
if (!pc?.energyDetails || !pc.startDate || !pc.endDate) return null;
|
||||||
|
if (c.type !== 'ELECTRICITY' && c.type !== 'GAS') return null;
|
||||||
|
const cms = pc.energyDetails.contractMeters || [];
|
||||||
|
if (cms.length > 0) {
|
||||||
|
return calculateMultiMeterConsumption(cms, pc.startDate, pc.endDate, c.type);
|
||||||
|
}
|
||||||
|
const readings = pc.energyDetails.meter?.readings || [];
|
||||||
|
if (readings.length === 0) return null;
|
||||||
|
return calculateConsumption(readings, pc.startDate, pc.endDate, c.type);
|
||||||
|
})();
|
||||||
|
const previousConsumptionUsable = previousConsumption
|
||||||
|
&& (previousConsumption.type === 'exact' || previousConsumption.type === 'projected');
|
||||||
const fallbackBack = isCustomerPortal ? '/contracts' : (c.customer ? `/customers/${c.customer.id}?tab=contracts` : '/contracts');
|
const fallbackBack = isCustomerPortal ? '/contracts' : (c.customer ? `/customers/${c.customer.id}?tab=contracts` : '/contracts');
|
||||||
const back = popHistory(location.state, fallbackBack);
|
const back = popHistory(location.state, fallbackBack);
|
||||||
|
|
||||||
@@ -1757,11 +1833,30 @@ export default function ContractDetail() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{c.customer && (
|
{c.customer && (
|
||||||
<p className="text-gray-500 ml-10">
|
<p className="text-gray-500 ml-10 flex items-center gap-1">
|
||||||
Kunde:{' '}
|
Kunde:{' '}
|
||||||
<Link to={`/customers/${c.customer.id}`} state={pushHistory(currentPath, location.state)} className="text-blue-600 hover:underline">
|
<Link to={`/customers/${c.customer.id}`} state={pushHistory(currentPath, location.state)} className="text-blue-600 hover:underline">
|
||||||
{c.customer.companyName || `${c.customer.firstName} ${c.customer.lastName}`}
|
{c.customer.companyName || `${c.customer.firstName} ${c.customer.lastName}`}
|
||||||
</Link>
|
</Link>
|
||||||
|
<a
|
||||||
|
href={`/customers/${c.customer.id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title="Kundenakte in neuem Tab öffnen"
|
||||||
|
aria-label="Kundenakte in neuem Tab öffnen"
|
||||||
|
className="text-gray-400 hover:text-blue-600 p-1 rounded"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCustomerInfo(true)}
|
||||||
|
title="Wichtige Kundendaten anzeigen (Schnellansicht mit Copy-Buttons)"
|
||||||
|
className="text-gray-400 hover:text-blue-600 p-1 rounded"
|
||||||
|
aria-label="Kundendaten anzeigen"
|
||||||
|
>
|
||||||
|
<Info className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1995,6 +2090,33 @@ export default function ContractDetail() {
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{c.orderNumberAtSalesPlatform && (
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-gray-500">Auftragsnr. Vertriebsplattform</dt>
|
||||||
|
<dd className="font-mono flex items-center gap-1">
|
||||||
|
{c.orderNumberAtSalesPlatform}
|
||||||
|
<CopyButton value={c.orderNumberAtSalesPlatform} />
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{c.customerNumberAtSalesPlatform && (
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-gray-500">Kundennr. Vertriebsplattform</dt>
|
||||||
|
<dd className="font-mono flex items-center gap-1">
|
||||||
|
{c.customerNumberAtSalesPlatform}
|
||||||
|
<CopyButton value={c.customerNumberAtSalesPlatform} />
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{c.contractNumberAtSalesPlatform && (
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-gray-500">Vertragsnr. Vertriebsplattform</dt>
|
||||||
|
<dd className="font-mono flex items-center gap-1">
|
||||||
|
{c.contractNumberAtSalesPlatform}
|
||||||
|
<CopyButton value={c.contractNumberAtSalesPlatform} />
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{c.salesPlatform && (
|
{c.salesPlatform && (
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-gray-500">Vertriebsplattform</dt>
|
<dt className="text-sm text-gray-500">Vertriebsplattform</dt>
|
||||||
@@ -2091,7 +2213,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={viewUrl(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"
|
||||||
@@ -2148,7 +2270,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={viewUrl(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"
|
||||||
@@ -2234,7 +2356,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={viewUrl(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"
|
||||||
@@ -2291,7 +2413,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={viewUrl(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"
|
||||||
@@ -2372,7 +2494,7 @@ export default function ContractDetail() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Portal Credentials */}
|
{/* Portal Credentials */}
|
||||||
{(c.portalUsername || c.stressfreiEmail || c.portalPasswordEncrypted) && (
|
{(c.portalUsername || c.stressfreiEmail || c.hasPortalPassword) && (
|
||||||
<Card className="mb-6" title="Zugangsdaten">
|
<Card className="mb-6" title="Zugangsdaten">
|
||||||
<dl className="grid grid-cols-2 gap-4">
|
<dl className="grid grid-cols-2 gap-4">
|
||||||
{(c.portalUsername || c.stressfreiEmail) && (
|
{(c.portalUsername || c.stressfreiEmail) && (
|
||||||
@@ -2389,7 +2511,31 @@ export default function ContractDetail() {
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{c.portalPasswordEncrypted && (
|
{(() => {
|
||||||
|
// Defense-in-depth: nur http(s) als href erlauben,
|
||||||
|
// damit Alt-Daten mit `javascript:` nicht klickbar werden.
|
||||||
|
const safePortalUrl = safeHttpUrl(c.provider?.portalUrl);
|
||||||
|
if (!safePortalUrl) return null;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-gray-500">Portal-Link</dt>
|
||||||
|
<dd className="flex items-center gap-1">
|
||||||
|
<a
|
||||||
|
href={safePortalUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600 hover:underline truncate"
|
||||||
|
title={safePortalUrl}
|
||||||
|
>
|
||||||
|
{safePortalUrl.replace(/^https?:\/\//, '').replace(/\/$/, '')}
|
||||||
|
<ExternalLink className="w-3 h-3 inline ml-1 -mt-0.5" />
|
||||||
|
</a>
|
||||||
|
<CopyButton value={safePortalUrl} />
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
{c.hasPortalPassword && (
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-gray-500">Passwort</dt>
|
<dt className="text-sm text-gray-500">Passwort</dt>
|
||||||
<dd className="flex items-center gap-2">
|
<dd className="flex items-center gap-2">
|
||||||
@@ -2408,7 +2554,7 @@ export default function ContractDetail() {
|
|||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
{/* Auto-Login Button */}
|
{/* Auto-Login Button */}
|
||||||
{c.provider?.portalUrl && (c.portalUsername || c.stressfreiEmail) && c.portalPasswordEncrypted && (
|
{c.provider?.portalUrl && (c.portalUsername || c.stressfreiEmail) && c.hasPortalPassword && (
|
||||||
<div className="mt-4 pt-4 border-t">
|
<div className="mt-4 pt-4 border-t">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleAutoLogin}
|
onClick={handleAutoLogin}
|
||||||
@@ -2499,20 +2645,45 @@ export default function ContractDetail() {
|
|||||||
|
|
||||||
{/* Type-specific details */}
|
{/* Type-specific details */}
|
||||||
{c.energyDetails && (
|
{c.energyDetails && (
|
||||||
<Card className="mb-6" title={c.type === 'ELECTRICITY' ? 'Strom-Details' : 'Gas-Details'}>
|
<Card
|
||||||
|
className="mb-6"
|
||||||
|
title={
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span>{c.type === 'ELECTRICITY' ? 'Strom-Details' : 'Gas-Details'}</span>
|
||||||
|
<Link
|
||||||
|
to={`/customers/${c.customerId}?tab=meters`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800 hover:underline font-normal"
|
||||||
|
title="Zähler-Übersicht des Kunden in neuem Tab öffnen"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
Zähler verwalten
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
<dl className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<dl className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
{c.energyDetails.meter && (
|
{c.energyDetails.meter && (
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-gray-500 flex items-center gap-1">
|
<dt className="text-sm text-gray-500 flex items-center gap-1 flex-wrap">
|
||||||
Zählernummer
|
Zählernummer
|
||||||
{c.energyDetails.meter.tariffModel === 'DUAL' && (
|
{c.energyDetails.meter.tariffModel === 'DUAL' && (
|
||||||
<Badge variant="default">HT/NT</Badge>
|
<Badge variant="default">HT/NT</Badge>
|
||||||
)}
|
)}
|
||||||
|
{c.energyDetails.meter.isActive === false && (
|
||||||
|
<Badge variant="danger">Inaktiv</Badge>
|
||||||
|
)}
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="font-mono flex items-center gap-1">
|
<dd className="font-mono flex items-center gap-1">
|
||||||
{c.energyDetails.meter.meterNumber}
|
{c.energyDetails.meter.meterNumber}
|
||||||
<CopyButton value={c.energyDetails.meter.meterNumber} />
|
<CopyButton value={c.energyDetails.meter.meterNumber} />
|
||||||
</dd>
|
</dd>
|
||||||
|
{c.energyDetails.meter.location && (
|
||||||
|
<dd className="text-xs text-gray-500 mt-1">
|
||||||
|
Standort: {c.energyDetails.meter.location}
|
||||||
|
</dd>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{c.energyDetails.maloId && (
|
{c.energyDetails.maloId && (
|
||||||
@@ -2524,7 +2695,7 @@ export default function ContractDetail() {
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{c.energyDetails.annualConsumption && (
|
{c.energyDetails.annualConsumption ? (
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-gray-500">
|
<dt className="text-sm text-gray-500">
|
||||||
Jahresverbrauch {c.type === 'ELECTRICITY' ? '' : '(m³)'}
|
Jahresverbrauch {c.type === 'ELECTRICITY' ? '' : '(m³)'}
|
||||||
@@ -2534,13 +2705,39 @@ export default function ContractDetail() {
|
|||||||
{c.type === 'ELECTRICITY' ? 'kWh' : 'm³'}
|
{c.type === 'ELECTRICITY' ? 'kWh' : 'm³'}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
) : previousConsumptionUsable && (
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-gray-500">
|
||||||
|
Jahresverbrauch {c.type === 'ELECTRICITY' ? '' : '(m³)'}
|
||||||
|
</dt>
|
||||||
|
<dd className="text-blue-700">
|
||||||
|
~{(c.type === 'GAS'
|
||||||
|
? previousConsumption!.consumptionM3 ?? previousConsumption!.consumptionKwh
|
||||||
|
: previousConsumption!.consumptionKwh
|
||||||
|
).toLocaleString('de-DE', { maximumFractionDigits: 0 })}{' '}
|
||||||
|
{c.type === 'GAS' ? 'm³' : 'kWh'}
|
||||||
|
<div className="text-xs text-gray-500 mt-0.5">
|
||||||
|
geschätzt aus Vorvertrag{previousConsumption!.type === 'projected' ? ' (hochgerechnet)' : ''}
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{c.type === 'GAS' && c.energyDetails.annualConsumptionKwh && (
|
{c.type === 'GAS' && c.energyDetails.annualConsumptionKwh ? (
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-gray-500">Jahresverbrauch (kWh)</dt>
|
<dt className="text-sm text-gray-500">Jahresverbrauch (kWh)</dt>
|
||||||
<dd>{c.energyDetails.annualConsumptionKwh.toLocaleString('de-DE')} kWh</dd>
|
<dd>{c.energyDetails.annualConsumptionKwh.toLocaleString('de-DE')} kWh</dd>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : (c.type === 'GAS' && previousConsumptionUsable) ? (
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-gray-500">Jahresverbrauch (kWh)</dt>
|
||||||
|
<dd className="text-blue-700">
|
||||||
|
~{previousConsumption!.consumptionKwh.toLocaleString('de-DE', { maximumFractionDigits: 0 })} kWh
|
||||||
|
<div className="text-xs text-gray-500 mt-0.5">
|
||||||
|
geschätzt aus Vorvertrag{previousConsumption!.type === 'projected' ? ' (hochgerechnet)' : ''}
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{c.energyDetails.basePrice != null && (
|
{c.energyDetails.basePrice != null && (
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-gray-500">Grundpreis</dt>
|
<dt className="text-sm text-gray-500">Grundpreis</dt>
|
||||||
@@ -2565,10 +2762,25 @@ export default function ContractDetail() {
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{c.energyDetails.bonus && (
|
{/* Bonus: Sofort + Neukunden einzeln + Gesamtbonus */}
|
||||||
|
{c.energyDetails.instantBonus != null && c.energyDetails.instantBonus > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-gray-500">Bonus</dt>
|
<dt className="text-sm text-gray-500">Sofort-Bonus</dt>
|
||||||
<dd>{c.energyDetails.bonus.toLocaleString('de-DE')} €</dd>
|
<dd>{c.energyDetails.instantBonus.toLocaleString('de-DE')} €</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{c.energyDetails.newCustomerBonus != null && c.energyDetails.newCustomerBonus > 0 && (
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-gray-500">Neukunden-Bonus</dt>
|
||||||
|
<dd>{c.energyDetails.newCustomerBonus.toLocaleString('de-DE')} €</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{((c.energyDetails.instantBonus ?? 0) + (c.energyDetails.newCustomerBonus ?? 0)) > 0 && (
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-gray-500">Gesamtbonus</dt>
|
||||||
|
<dd className="font-medium">
|
||||||
|
{((c.energyDetails.instantBonus ?? 0) + (c.energyDetails.newCustomerBonus ?? 0)).toLocaleString('de-DE')} €
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{c.energyDetails.previousProviderName && (
|
{c.energyDetails.previousProviderName && (
|
||||||
@@ -2607,15 +2819,6 @@ export default function ContractDetail() {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
))}
|
))}
|
||||||
{/* Folgezähler hinzufügen */}
|
|
||||||
{hasPermission('contracts:update') && !isCustomer && (
|
|
||||||
<SuccessorMeterButton
|
|
||||||
contractId={contractId}
|
|
||||||
customerId={c.customerId}
|
|
||||||
meterType={c.type as 'ELECTRICITY' | 'GAS'}
|
|
||||||
existingMeterIds={c.energyDetails.contractMeters.map((cm: ContractMeter) => cm.meterId)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
) : c.energyDetails.meter ? (
|
) : c.energyDetails.meter ? (
|
||||||
<MeterReadingsSection
|
<MeterReadingsSection
|
||||||
@@ -2627,6 +2830,21 @@ export default function ContractDetail() {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* Folgezähler hinzufügen – auch bei Verträgen ohne ContractMeter-Eintrag
|
||||||
|
(z. B. Folgeverträge oder Bestandsverträge im Single-Meter-Modell). */}
|
||||||
|
{hasPermission('contracts:update') && !isCustomer && (c.energyDetails.meter || (c.energyDetails.contractMeters && c.energyDetails.contractMeters.length > 0)) && (
|
||||||
|
<SuccessorMeterButton
|
||||||
|
contractId={contractId}
|
||||||
|
customerId={c.customerId}
|
||||||
|
meterType={c.type as 'ELECTRICITY' | 'GAS'}
|
||||||
|
existingMeterIds={
|
||||||
|
c.energyDetails.contractMeters && c.energyDetails.contractMeters.length > 0
|
||||||
|
? c.energyDetails.contractMeters.map((cm: ContractMeter) => cm.meterId)
|
||||||
|
: c.energyDetails.meter ? [c.energyDetails.meter.id] : []
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Verbrauchsberechnung & Kostenvorschau */}
|
{/* Verbrauchsberechnung & Kostenvorschau */}
|
||||||
<EnergyConsumptionCalculation
|
<EnergyConsumptionCalculation
|
||||||
contractType={c.type as 'ELECTRICITY' | 'GAS'}
|
contractType={c.type as 'ELECTRICITY' | 'GAS'}
|
||||||
@@ -2636,7 +2854,8 @@ export default function ContractDetail() {
|
|||||||
basePrice={c.energyDetails.basePrice}
|
basePrice={c.energyDetails.basePrice}
|
||||||
unitPrice={c.energyDetails.unitPrice}
|
unitPrice={c.energyDetails.unitPrice}
|
||||||
unitPriceNt={c.energyDetails.unitPriceNt}
|
unitPriceNt={c.energyDetails.unitPriceNt}
|
||||||
bonus={c.energyDetails.bonus}
|
instantBonus={c.energyDetails.instantBonus}
|
||||||
|
newCustomerBonus={c.energyDetails.newCustomerBonus}
|
||||||
hasMeter={!!c.energyDetails.meter}
|
hasMeter={!!c.energyDetails.meter}
|
||||||
contractMeters={c.energyDetails.contractMeters}
|
contractMeters={c.energyDetails.contractMeters}
|
||||||
/>
|
/>
|
||||||
@@ -3088,6 +3307,14 @@ export default function ContractDetail() {
|
|||||||
<ContractHistorySection
|
<ContractHistorySection
|
||||||
contractId={contractId}
|
contractId={contractId}
|
||||||
canEdit={hasPermission('contracts:update')}
|
canEdit={hasPermission('contracts:update')}
|
||||||
|
knownContracts={{
|
||||||
|
...(c.previousContract?.contractNumber
|
||||||
|
? { [c.previousContract.contractNumber]: c.previousContract.id }
|
||||||
|
: {}),
|
||||||
|
...(c.followUpContract?.contractNumber
|
||||||
|
? { [c.followUpContract.contractNumber]: c.followUpContract.id }
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -3100,6 +3327,15 @@ export default function ContractDetail() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Kunden-Schnellansicht */}
|
||||||
|
{c.customer && (
|
||||||
|
<CustomerInfoModal
|
||||||
|
customerId={c.customer.id}
|
||||||
|
open={showCustomerInfo}
|
||||||
|
onClose={() => setShowCustomerInfo(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Folgevertrag Bestätigung */}
|
{/* Folgevertrag Bestätigung */}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={showFollowUpConfirm}
|
isOpen={showFollowUpConfirm}
|
||||||
@@ -3287,6 +3523,7 @@ function ContractDocumentsSection({
|
|||||||
const [uploadDeliveryDate, setUploadDeliveryDate] = useState<string>(
|
const [uploadDeliveryDate, setUploadDeliveryDate] = useState<string>(
|
||||||
() => new Date().toISOString().split('T')[0],
|
() => new Date().toISOString().split('T')[0],
|
||||||
);
|
);
|
||||||
|
const [isJpgModalOpen, setIsJpgModalOpen] = useState(false);
|
||||||
|
|
||||||
const { data: docsData } = useQuery({
|
const { data: docsData } = useQuery({
|
||||||
queryKey: ['contract-documents', contractId],
|
queryKey: ['contract-documents', contractId],
|
||||||
@@ -3384,15 +3621,31 @@ function ContractDocumentsSection({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<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" />
|
||||||
Datei wählen (PDF, JPG, PNG)
|
Datei wählen (PDF, JPG, PNG)
|
||||||
<input type="file" accept=".pdf,.jpg,.jpeg,.png" className="hidden" onChange={handleFileSelect} />
|
<input type="file" accept=".pdf,.jpg,.jpeg,.png" className="hidden" onChange={handleFileSelect} />
|
||||||
</label>
|
</label>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setIsJpgModalOpen(true)} title="Mehrere JPGs zu einer PDF kombinieren">
|
||||||
|
<Images className="w-4 h-4 mr-1" /> JPGs → PDF
|
||||||
|
</Button>
|
||||||
<Button variant="secondary" size="sm" onClick={() => setShowUpload(false)}>Abbrechen</Button>
|
<Button variant="secondary" size="sm" onClick={() => setShowUpload(false)}>Abbrechen</Button>
|
||||||
{uploadMutation.isPending && <span className="text-sm text-gray-500">Hochladen...</span>}
|
{uploadMutation.isPending && <span className="text-sm text-gray-500">Hochladen...</span>}
|
||||||
</div>
|
</div>
|
||||||
|
<JpgToPdfModal
|
||||||
|
isOpen={isJpgModalOpen}
|
||||||
|
onClose={() => setIsJpgModalOpen(false)}
|
||||||
|
onComplete={(file) => {
|
||||||
|
uploadMutation.mutate({
|
||||||
|
file,
|
||||||
|
documentType: uploadType,
|
||||||
|
notes: uploadNotes || undefined,
|
||||||
|
deliveryDate: isDelivery ? uploadDeliveryDate : undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
fileNameHint={uploadType}
|
||||||
|
/>
|
||||||
{uploadMutation.isError && (
|
{uploadMutation.isError && (
|
||||||
<p className="text-xs text-red-600 mt-2">Fehler beim Hochladen</p>
|
<p className="text-xs text-red-600 mt-2">Fehler beim Hochladen</p>
|
||||||
)}
|
)}
|
||||||
@@ -3414,7 +3667,7 @@ function ContractDocumentsSection({
|
|||||||
{doc.documentType}
|
{doc.documentType}
|
||||||
</span>
|
</span>
|
||||||
<a
|
<a
|
||||||
href={fileUrl(doc.documentPath)}
|
href={viewUrl(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"
|
||||||
|
|||||||
@@ -1,17 +1,43 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useNavigate, useParams, useSearchParams, useLocation } from 'react-router-dom';
|
import { useNavigate, useParams, useSearchParams, useLocation, Link } from 'react-router-dom';
|
||||||
import { popHistory } from '../../utils/navigation';
|
import { popHistory } from '../../utils/navigation';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
import { contractApi, customerApi, platformApi, cancellationPeriodApi, contractDurationApi, providerApi, contractCategoryApi } from '../../services/api';
|
import { contractApi, customerApi, platformApi, cancellationPeriodApi, contractDurationApi, providerApi, contractCategoryApi } from '../../services/api';
|
||||||
import Card from '../../components/ui/Card';
|
import Card from '../../components/ui/Card';
|
||||||
import Button from '../../components/ui/Button';
|
import Button from '../../components/ui/Button';
|
||||||
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 CopyButton from '../../components/ui/CopyButton';
|
||||||
|
import CustomerInfoModal from '../../components/contracts/CustomerInfoModal';
|
||||||
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 { useProviderSettings } from '../../hooks/useProviderSettings';
|
||||||
import { Plus, Trash2, Eye, EyeOff, Info, X, ArrowLeft } from 'lucide-react';
|
import { Plus, Trash2, Eye, EyeOff, Info, X, ArrowLeft, ExternalLink } from 'lucide-react';
|
||||||
|
|
||||||
|
// Kleine Helper-Komponente: Label + Mini-Link im neuen Tab. Wird neben
|
||||||
|
// Select-Feldern eingesetzt, deren Stammdaten an anderer Stelle gepflegt
|
||||||
|
// werden (Kundenakte, Settings) – damit der User direkt von dort in die
|
||||||
|
// passende Verwaltungsseite springen kann.
|
||||||
|
function LabelWithLink({ children, to, title }: { children: React.ReactNode; to: string; title: string }) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<span>{children}</span>
|
||||||
|
<Link
|
||||||
|
to={to}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center text-blue-600 hover:text-blue-800"
|
||||||
|
title={title}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
import { calculateConsumption, calculateMultiMeterConsumption } from '../../utils/energyCalculations';
|
||||||
|
|
||||||
// Contract types are now loaded dynamically from the database
|
// Contract types are now loaded dynamically from the database
|
||||||
|
|
||||||
@@ -113,6 +139,14 @@ export default function ContractForm() {
|
|||||||
enabled: !!customerId,
|
enabled: !!customerId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Vorvertrag (für Verbrauchs-Übernahme bei Energieverträgen). Wird nur geladen,
|
||||||
|
// wenn ein previousContractId gesetzt ist und der Typ Strom/Gas ist.
|
||||||
|
const { data: previousContractData } = useQuery({
|
||||||
|
queryKey: ['contract', previousContractId],
|
||||||
|
queryFn: () => contractApi.getById(parseInt(previousContractId)),
|
||||||
|
enabled: !!previousContractId && ['ELECTRICITY', 'GAS'].includes(contractType),
|
||||||
|
});
|
||||||
|
|
||||||
// Fetch platforms
|
// Fetch platforms
|
||||||
const { data: platformsData } = useQuery({
|
const { data: platformsData } = useQuery({
|
||||||
queryKey: ['platforms'],
|
queryKey: ['platforms'],
|
||||||
@@ -160,13 +194,19 @@ export default function ContractForm() {
|
|||||||
hasExistingPuk?: boolean; // Zeigt an ob PUK bereits in DB vorhanden
|
hasExistingPuk?: boolean; // Zeigt an ob PUK bereits in DB vorhanden
|
||||||
isMultisim: boolean;
|
isMultisim: boolean;
|
||||||
isMain: boolean;
|
isMain: boolean;
|
||||||
|
isEsim: boolean;
|
||||||
|
cardUser: string;
|
||||||
}
|
}
|
||||||
const [simCards, setSimCards] = useState<SimCardInput[]>([]);
|
const [simCards, setSimCards] = useState<SimCardInput[]>([]);
|
||||||
|
|
||||||
// Rufnummern State für Internet-Verträge
|
// Rufnummern State für Internet-Verträge
|
||||||
interface PhoneNumberInput {
|
interface PhoneNumberInput {
|
||||||
id?: number;
|
id?: number;
|
||||||
phoneNumber: string;
|
// areaCode + subscriber sind die getrennten Eingabefelder.
|
||||||
|
// phoneNumber-Feld in der DB ist immer die zusammengesetzte
|
||||||
|
// "Vorwahl Anschluss"-Form, wird im Submit kombiniert.
|
||||||
|
areaCode: string;
|
||||||
|
subscriber: string;
|
||||||
sipUsername: string;
|
sipUsername: string;
|
||||||
sipPassword: string;
|
sipPassword: string;
|
||||||
hasExistingSipPassword?: boolean;
|
hasExistingSipPassword?: boolean;
|
||||||
@@ -182,6 +222,7 @@ export default function ContractForm() {
|
|||||||
|
|
||||||
// Passwort-Sichtbarkeit
|
// Passwort-Sichtbarkeit
|
||||||
const [showPortalPassword, setShowPortalPassword] = useState(false);
|
const [showPortalPassword, setShowPortalPassword] = useState(false);
|
||||||
|
const [showCustomerInfo, setShowCustomerInfo] = useState(false);
|
||||||
const [showInternetPassword, setShowInternetPassword] = useState(false);
|
const [showInternetPassword, setShowInternetPassword] = useState(false);
|
||||||
const [showSipPasswords, setShowSipPasswords] = useState<Record<number, boolean>>({});
|
const [showSipPasswords, setShowSipPasswords] = useState<Record<number, boolean>>({});
|
||||||
const [showSimPins, setShowSimPins] = useState<Record<number, boolean>>({});
|
const [showSimPins, setShowSimPins] = useState<Record<number, boolean>>({});
|
||||||
@@ -264,6 +305,9 @@ export default function ContractForm() {
|
|||||||
tariffName: c.tariffName || '',
|
tariffName: c.tariffName || '',
|
||||||
customerNumberAtProvider: c.customerNumberAtProvider || '',
|
customerNumberAtProvider: c.customerNumberAtProvider || '',
|
||||||
contractNumberAtProvider: c.contractNumberAtProvider || '',
|
contractNumberAtProvider: c.contractNumberAtProvider || '',
|
||||||
|
orderNumberAtSalesPlatform: c.orderNumberAtSalesPlatform || '',
|
||||||
|
customerNumberAtSalesPlatform: c.customerNumberAtSalesPlatform || '',
|
||||||
|
contractNumberAtSalesPlatform: c.contractNumberAtSalesPlatform || '',
|
||||||
priceFirst12Months: c.priceFirst12Months || '',
|
priceFirst12Months: c.priceFirst12Months || '',
|
||||||
priceFrom13Months: c.priceFrom13Months || '',
|
priceFrom13Months: c.priceFrom13Months || '',
|
||||||
priceAfter24Months: c.priceAfter24Months || '',
|
priceAfter24Months: c.priceAfter24Months || '',
|
||||||
@@ -282,7 +326,8 @@ export default function ContractForm() {
|
|||||||
basePrice: c.energyDetails?.basePrice || '',
|
basePrice: c.energyDetails?.basePrice || '',
|
||||||
unitPrice: c.energyDetails?.unitPrice || '',
|
unitPrice: c.energyDetails?.unitPrice || '',
|
||||||
unitPriceNt: c.energyDetails?.unitPriceNt || '',
|
unitPriceNt: c.energyDetails?.unitPriceNt || '',
|
||||||
bonus: c.energyDetails?.bonus || '',
|
instantBonus: c.energyDetails?.instantBonus || '',
|
||||||
|
newCustomerBonus: c.energyDetails?.newCustomerBonus || '',
|
||||||
// Internet details
|
// Internet details
|
||||||
downloadSpeed: c.internetDetails?.downloadSpeed || '',
|
downloadSpeed: c.internetDetails?.downloadSpeed || '',
|
||||||
uploadSpeed: c.internetDetails?.uploadSpeed || '',
|
uploadSpeed: c.internetDetails?.uploadSpeed || '',
|
||||||
@@ -346,6 +391,8 @@ export default function ContractForm() {
|
|||||||
hasExistingPuk: !!sc.puk, // true wenn verschlüsselter Wert vorhanden
|
hasExistingPuk: !!sc.puk, // true wenn verschlüsselter Wert vorhanden
|
||||||
isMultisim: sc.isMultisim,
|
isMultisim: sc.isMultisim,
|
||||||
isMain: sc.isMain,
|
isMain: sc.isMain,
|
||||||
|
isEsim: sc.isEsim ?? false,
|
||||||
|
cardUser: sc.cardUser || '',
|
||||||
})));
|
})));
|
||||||
} else {
|
} else {
|
||||||
setSimCards([]);
|
setSimCards([]);
|
||||||
@@ -353,15 +400,41 @@ export default function ContractForm() {
|
|||||||
|
|
||||||
// Load phoneNumbers if available (Internet contracts)
|
// Load phoneNumbers if available (Internet contracts)
|
||||||
if (c.internetDetails?.phoneNumbers && c.internetDetails.phoneNumbers.length > 0) {
|
if (c.internetDetails?.phoneNumbers && c.internetDetails.phoneNumbers.length > 0) {
|
||||||
setPhoneNumbers(c.internetDetails.phoneNumbers.map(pn => ({
|
setPhoneNumbers(c.internetDetails.phoneNumbers.map(pn => {
|
||||||
id: pn.id,
|
// Wenn areaCode gepflegt ist → 1:1 nutzen, sonst aus phoneNumber
|
||||||
phoneNumber: pn.phoneNumber || '',
|
// heuristisch splitten (dann sollte der User das Ergebnis prüfen
|
||||||
sipUsername: pn.sipUsername || '',
|
// und beim nächsten Speichern fixen).
|
||||||
sipPassword: '', // SIP-Passwort wird nicht im Klartext geladen
|
const splitLegacy = (s: string) => {
|
||||||
hasExistingSipPassword: !!pn.sipPasswordEncrypted,
|
const cleaned = s.replace(/[()]/g, '').trim();
|
||||||
sipServer: pn.sipServer || '',
|
const sep = cleaned.match(/^(\d{2,5})[\/\s\-](.+)$/);
|
||||||
isMain: pn.isMain,
|
if (sep) return { ac: sep[1], rest: sep[2].replace(/[\s\-/]/g, '') };
|
||||||
})));
|
const noSep = cleaned.match(/^(0\d{1,4})(\d{3,})$/);
|
||||||
|
if (noSep) return { ac: noSep[1], rest: noSep[2] };
|
||||||
|
return { ac: '', rest: cleaned };
|
||||||
|
};
|
||||||
|
const full = pn.phoneNumber || '';
|
||||||
|
let ac = pn.areaCode || '';
|
||||||
|
let rest = '';
|
||||||
|
if (ac && full.startsWith(ac)) {
|
||||||
|
rest = full.slice(ac.length).replace(/^[\s\-/]+/, '').trim();
|
||||||
|
} else if (ac) {
|
||||||
|
rest = full.replace(ac, '').replace(/^[\s\-/]+/, '').trim();
|
||||||
|
} else {
|
||||||
|
const s = splitLegacy(full);
|
||||||
|
ac = s.ac;
|
||||||
|
rest = s.rest;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: pn.id,
|
||||||
|
areaCode: ac,
|
||||||
|
subscriber: rest,
|
||||||
|
sipUsername: pn.sipUsername || '',
|
||||||
|
sipPassword: '', // SIP-Passwort wird nicht im Klartext geladen
|
||||||
|
hasExistingSipPassword: !!pn.sipPasswordEncrypted,
|
||||||
|
sipServer: pn.sipServer || '',
|
||||||
|
isMain: pn.isMain,
|
||||||
|
};
|
||||||
|
}));
|
||||||
} else {
|
} else {
|
||||||
setPhoneNumbers([]);
|
setPhoneNumbers([]);
|
||||||
}
|
}
|
||||||
@@ -489,6 +562,9 @@ export default function ContractForm() {
|
|||||||
tariffName: emptyToNull(data.tariffName),
|
tariffName: emptyToNull(data.tariffName),
|
||||||
customerNumberAtProvider: emptyToNull(data.customerNumberAtProvider),
|
customerNumberAtProvider: emptyToNull(data.customerNumberAtProvider),
|
||||||
contractNumberAtProvider: emptyToNull(data.contractNumberAtProvider),
|
contractNumberAtProvider: emptyToNull(data.contractNumberAtProvider),
|
||||||
|
orderNumberAtSalesPlatform: emptyToNull(data.orderNumberAtSalesPlatform),
|
||||||
|
customerNumberAtSalesPlatform: emptyToNull(data.customerNumberAtSalesPlatform),
|
||||||
|
contractNumberAtSalesPlatform: emptyToNull(data.contractNumberAtSalesPlatform),
|
||||||
priceFirst12Months: emptyToNull(data.priceFirst12Months),
|
priceFirst12Months: emptyToNull(data.priceFirst12Months),
|
||||||
priceFrom13Months: emptyToNull(data.priceFrom13Months),
|
priceFrom13Months: emptyToNull(data.priceFrom13Months),
|
||||||
priceAfter24Months: emptyToNull(data.priceAfter24Months),
|
priceAfter24Months: emptyToNull(data.priceAfter24Months),
|
||||||
@@ -522,7 +598,8 @@ export default function ContractForm() {
|
|||||||
basePrice: data.basePrice ? parseFloat(data.basePrice) : null,
|
basePrice: data.basePrice ? parseFloat(data.basePrice) : null,
|
||||||
unitPrice: data.unitPrice ? parseFloat(data.unitPrice) : null,
|
unitPrice: data.unitPrice ? parseFloat(data.unitPrice) : null,
|
||||||
unitPriceNt: data.unitPriceNt ? parseFloat(data.unitPriceNt) : null,
|
unitPriceNt: data.unitPriceNt ? parseFloat(data.unitPriceNt) : null,
|
||||||
bonus: data.bonus ? parseFloat(data.bonus) : null,
|
instantBonus: data.instantBonus ? parseFloat(data.instantBonus) : null,
|
||||||
|
newCustomerBonus: data.newCustomerBonus ? parseFloat(data.newCustomerBonus) : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,14 +622,22 @@ export default function ContractForm() {
|
|||||||
// Vodafone DSL/Kabel spezifisch
|
// Vodafone DSL/Kabel spezifisch
|
||||||
activationCode: emptyToNull(data.activationCode),
|
activationCode: emptyToNull(data.activationCode),
|
||||||
// Rufnummern mit SIP-Daten
|
// Rufnummern mit SIP-Daten
|
||||||
phoneNumbers: phoneNumbers.length > 0 ? phoneNumbers.map(pn => ({
|
phoneNumbers: phoneNumbers.length > 0 ? phoneNumbers.map(pn => {
|
||||||
id: pn.id,
|
const ac = (pn.areaCode || '').trim();
|
||||||
phoneNumber: pn.phoneNumber || '',
|
const sub = (pn.subscriber || '').trim();
|
||||||
isMain: pn.isMain ?? false,
|
// phoneNumber bleibt das kombinierte "Vorwahl Anschluss"-Feld
|
||||||
sipUsername: emptyToNull(pn.sipUsername),
|
// für Listen/Suchen, areaCode/subscriber sind die getrennten Werte.
|
||||||
sipPassword: pn.sipPassword || undefined, // Passwort: undefined = nicht ändern
|
const combined = ac && sub ? `${ac} ${sub}` : (sub || ac);
|
||||||
sipServer: emptyToNull(pn.sipServer),
|
return {
|
||||||
})) : undefined,
|
id: pn.id,
|
||||||
|
phoneNumber: combined,
|
||||||
|
areaCode: ac || undefined,
|
||||||
|
isMain: pn.isMain ?? false,
|
||||||
|
sipUsername: emptyToNull(pn.sipUsername),
|
||||||
|
sipPassword: pn.sipPassword || undefined, // Passwort: undefined = nicht ändern
|
||||||
|
sipServer: emptyToNull(pn.sipServer),
|
||||||
|
};
|
||||||
|
}) : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,6 +661,8 @@ export default function ContractForm() {
|
|||||||
puk: sc.puk || undefined, // Passwort: undefined = nicht ändern
|
puk: sc.puk || undefined, // Passwort: undefined = nicht ändern
|
||||||
isMultisim: sc.isMultisim,
|
isMultisim: sc.isMultisim,
|
||||||
isMain: sc.isMain,
|
isMain: sc.isMain,
|
||||||
|
isEsim: sc.isEsim,
|
||||||
|
cardUser: emptyToNull(sc.cardUser),
|
||||||
})) : undefined,
|
})) : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -621,6 +708,24 @@ export default function ContractForm() {
|
|||||||
const documents = customer?.identityDocuments?.filter((d) => d.isActive) || [];
|
const documents = customer?.identityDocuments?.filter((d) => d.isActive) || [];
|
||||||
const meters = customer?.meters || [];
|
const meters = customer?.meters || [];
|
||||||
const selectedMeter = meters.find(m => m.id.toString() === selectedMeterId);
|
const selectedMeter = meters.find(m => m.id.toString() === selectedMeterId);
|
||||||
|
|
||||||
|
// Verbrauch aus Vorvertrag berechnen (für Übernehmen-Hinweis am Jahresverbrauch).
|
||||||
|
// Nutzt die clientseitige Verbrauchsberechnung – egal ob Single- oder Multi-Meter,
|
||||||
|
// egal ob exakt oder hochgerechnet.
|
||||||
|
const previousConsumption = useMemo(() => {
|
||||||
|
const pc = previousContractData?.data;
|
||||||
|
if (!pc?.energyDetails || !pc.startDate || !pc.endDate) return null;
|
||||||
|
if (!['ELECTRICITY', 'GAS'].includes(pc.type)) return null;
|
||||||
|
const cms = pc.energyDetails.contractMeters || [];
|
||||||
|
if (cms.length > 0) {
|
||||||
|
return calculateMultiMeterConsumption(cms, pc.startDate, pc.endDate, pc.type as 'ELECTRICITY' | 'GAS');
|
||||||
|
}
|
||||||
|
const readings = pc.energyDetails.meter?.readings || [];
|
||||||
|
if (readings.length === 0) return null;
|
||||||
|
return calculateConsumption(readings, pc.startDate, pc.endDate, pc.type as 'ELECTRICITY' | 'GAS');
|
||||||
|
}, [previousContractData]);
|
||||||
|
const previousConsumptionUsable = previousConsumption
|
||||||
|
&& (previousConsumption.type === 'exact' || previousConsumption.type === 'projected');
|
||||||
const stressfreiEmails = customer?.stressfreiEmails?.filter((e: { isActive: boolean }) => e.isActive) || [];
|
const stressfreiEmails = customer?.stressfreiEmails?.filter((e: { isActive: boolean }) => e.isActive) || [];
|
||||||
const platforms = platformsData?.data || [];
|
const platforms = platformsData?.data || [];
|
||||||
const cancellationPeriods = cancellationPeriodsData?.data || [];
|
const cancellationPeriods = cancellationPeriodsData?.data || [];
|
||||||
@@ -672,7 +777,7 @@ export default function ContractForm() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-4 mb-6">
|
<div className="flex items-center gap-4 mb-2">
|
||||||
<Button variant="ghost" size="sm" onClick={() => navigate(back.to, { state: back.state })}>
|
<Button variant="ghost" size="sm" onClick={() => navigate(back.to, { state: back.state })}>
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -680,6 +785,36 @@ export default function ContractForm() {
|
|||||||
{isEdit ? 'Vertrag bearbeiten' : 'Neuer Vertrag'}
|
{isEdit ? 'Vertrag bearbeiten' : 'Neuer Vertrag'}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
{customer && (
|
||||||
|
<p className="text-gray-500 ml-12 mb-6 flex items-center gap-1">
|
||||||
|
Kunde:{' '}
|
||||||
|
<Link
|
||||||
|
to={`/customers/${customer.id}`}
|
||||||
|
className="text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
{customer.companyName || `${customer.firstName} ${customer.lastName}`}
|
||||||
|
</Link>
|
||||||
|
<a
|
||||||
|
href={`/customers/${customer.id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title="Kundenakte in neuem Tab öffnen"
|
||||||
|
aria-label="Kundenakte in neuem Tab öffnen"
|
||||||
|
className="text-gray-400 hover:text-blue-600 p-1 rounded"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCustomerInfo(true)}
|
||||||
|
title="Wichtige Kundendaten anzeigen (Schnellansicht mit Copy-Buttons)"
|
||||||
|
aria-label="Kundendaten anzeigen"
|
||||||
|
className="text-gray-400 hover:text-blue-600 p-1 rounded"
|
||||||
|
>
|
||||||
|
<Info className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg">
|
<div className="mb-4 p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg">
|
||||||
@@ -722,7 +857,11 @@ export default function ContractForm() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
label="Vertriebsplattform"
|
label={
|
||||||
|
<LabelWithLink to="/settings/platforms" title="Vertriebsplattformen verwalten (neuer Tab)">
|
||||||
|
Vertriebsplattform
|
||||||
|
</LabelWithLink>
|
||||||
|
}
|
||||||
{...register('salesPlatformId')}
|
{...register('salesPlatformId')}
|
||||||
options={platforms.map((p) => ({ value: p.id, label: p.name }))}
|
options={platforms.map((p) => ({ value: p.id, label: p.name }))}
|
||||||
/>
|
/>
|
||||||
@@ -769,7 +908,11 @@ export default function ContractForm() {
|
|||||||
<Card className="mb-6" title="Kundendaten verknüpfen">
|
<Card className="mb-6" title="Kundendaten verknüpfen">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||||
<Select
|
<Select
|
||||||
label="Lieferadresse"
|
label={
|
||||||
|
<LabelWithLink to={`/customers/${customerId}?tab=addresses`} title="Adressen des Kunden in neuem Tab öffnen">
|
||||||
|
Lieferadresse
|
||||||
|
</LabelWithLink>
|
||||||
|
}
|
||||||
{...register('addressId')}
|
{...register('addressId')}
|
||||||
options={addresses
|
options={addresses
|
||||||
.filter((a) => a.type === 'DELIVERY_RESIDENCE')
|
.filter((a) => a.type === 'DELIVERY_RESIDENCE')
|
||||||
@@ -780,7 +923,11 @@ export default function ContractForm() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
label="Rechnungsadresse"
|
label={
|
||||||
|
<LabelWithLink to={`/customers/${customerId}?tab=addresses`} title="Adressen des Kunden in neuem Tab öffnen">
|
||||||
|
Rechnungsadresse
|
||||||
|
</LabelWithLink>
|
||||||
|
}
|
||||||
{...register('billingAddressId')}
|
{...register('billingAddressId')}
|
||||||
options={addresses
|
options={addresses
|
||||||
.filter((a) => a.type === 'BILLING')
|
.filter((a) => a.type === 'BILLING')
|
||||||
@@ -793,7 +940,11 @@ export default function ContractForm() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<Select
|
<Select
|
||||||
label="Bankkarte"
|
label={
|
||||||
|
<LabelWithLink to={`/customers/${customerId}?tab=bankcards`} title="Bankkarten des Kunden in neuem Tab öffnen">
|
||||||
|
Bankkarte
|
||||||
|
</LabelWithLink>
|
||||||
|
}
|
||||||
{...register('bankCardId')}
|
{...register('bankCardId')}
|
||||||
options={bankCards.map((c) => ({
|
options={bankCards.map((c) => ({
|
||||||
value: c.id,
|
value: c.id,
|
||||||
@@ -802,7 +953,11 @@ export default function ContractForm() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
label="Ausweis"
|
label={
|
||||||
|
<LabelWithLink to={`/customers/${customerId}?tab=documents`} title="Ausweise des Kunden in neuem Tab öffnen">
|
||||||
|
Ausweis
|
||||||
|
</LabelWithLink>
|
||||||
|
}
|
||||||
{...register('identityDocumentId')}
|
{...register('identityDocumentId')}
|
||||||
options={documents.map((d) => ({
|
options={documents.map((d) => ({
|
||||||
value: d.id,
|
value: d.id,
|
||||||
@@ -816,18 +971,29 @@ export default function ContractForm() {
|
|||||||
<Card className="mb-6" title="Anbieter & Tarif">
|
<Card className="mb-6" title="Anbieter & Tarif">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<Select
|
<Select
|
||||||
label="Anbieter"
|
label={
|
||||||
|
<LabelWithLink to="/settings/providers" title="Anbieter & Tarife verwalten (neuer Tab)">
|
||||||
|
Anbieter
|
||||||
|
</LabelWithLink>
|
||||||
|
}
|
||||||
{...register('providerId')}
|
{...register('providerId')}
|
||||||
options={providers.map((p) => ({ value: p.id, label: p.name }))}
|
options={providers.map((p) => ({ value: p.id, label: p.name }))}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
label="Tarif"
|
label={
|
||||||
|
<LabelWithLink to="/settings/providers" title="Anbieter & Tarife verwalten (neuer Tab)">
|
||||||
|
Tarif
|
||||||
|
</LabelWithLink>
|
||||||
|
}
|
||||||
{...register('tariffId')}
|
{...register('tariffId')}
|
||||||
options={availableTariffs.map((t) => ({ value: t.id, label: t.name }))}
|
options={availableTariffs.map((t) => ({ value: t.id, label: t.name }))}
|
||||||
disabled={!selectedProviderId}
|
disabled={!selectedProviderId}
|
||||||
/>
|
/>
|
||||||
<Input label="Kundennummer beim Anbieter" {...register('customerNumberAtProvider')} />
|
<Input label="Kundennummer beim Anbieter" maxLength={100} {...register('customerNumberAtProvider')} />
|
||||||
<Input label="Vertragsnummer beim Anbieter" {...register('contractNumberAtProvider')} />
|
<Input label="Vertragsnummer beim Anbieter" maxLength={100} {...register('contractNumberAtProvider')} />
|
||||||
|
<Input label="Auftragsnummer bei Vertriebsplattform" maxLength={100} {...register('orderNumberAtSalesPlatform')} />
|
||||||
|
<Input label="Kundennummer bei Vertriebsplattform" maxLength={100} {...register('customerNumberAtSalesPlatform')} />
|
||||||
|
<Input label="Vertragsnummer bei Vertriebsplattform" maxLength={100} {...register('contractNumberAtSalesPlatform')} />
|
||||||
<Input label="Provision (€)" type="number" step="0.01" {...register('commission')} />
|
<Input label="Provision (€)" type="number" step="0.01" {...register('commission')} />
|
||||||
<Input label="Preis erste 12 Monate" {...register('priceFirst12Months')} placeholder="z.B. 29,99 €/Monat" />
|
<Input label="Preis erste 12 Monate" {...register('priceFirst12Months')} placeholder="z.B. 29,99 €/Monat" />
|
||||||
<Input label="Preis ab 13. Monat" {...register('priceFrom13Months')} placeholder="z.B. 39,99 €/Monat" />
|
<Input label="Preis ab 13. Monat" {...register('priceFrom13Months')} placeholder="z.B. 39,99 €/Monat" />
|
||||||
@@ -873,7 +1039,7 @@ export default function ContractForm() {
|
|||||||
onClear={() => setValue('cancellationConfirmationOptionsDate', '')}
|
onClear={() => setValue('cancellationConfirmationOptionsDate', '')}
|
||||||
/>
|
/>
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
<label className="inline-flex items-center gap-2 cursor-pointer">
|
||||||
<input type="checkbox" {...register('wasSpecialCancellation')} className="rounded border-gray-300" />
|
<input type="checkbox" {...register('wasSpecialCancellation')} className="rounded border-gray-300" />
|
||||||
<span>Wurde sondergekündigt?</span>
|
<span>Wurde sondergekündigt?</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -884,7 +1050,24 @@ export default function ContractForm() {
|
|||||||
<Card className="mb-6" title="Zugangsdaten (verschlüsselt gespeichert)">
|
<Card className="mb-6" title="Zugangsdaten (verschlüsselt gespeichert)">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Portal Benutzername</label>
|
{(() => {
|
||||||
|
// Aktiv kopierbaren Wert je nach Modus ermitteln:
|
||||||
|
// - Manuell: aktueller Eingabewert von portalUsername
|
||||||
|
// - Stressfrei: E-Mail der ausgewählten Stressfrei-Adresse
|
||||||
|
const manualUsername = (watch('portalUsername') as string) || '';
|
||||||
|
const selectedStressfreiEmail = selectedStressfreiEmailId
|
||||||
|
? stressfreiEmails.find((e: { id: number; email: string }) => e.id.toString() === selectedStressfreiEmailId)?.email
|
||||||
|
: '';
|
||||||
|
const copyValue = usernameType === 'manual'
|
||||||
|
? manualUsername.trim()
|
||||||
|
: (selectedStressfreiEmail || '');
|
||||||
|
return (
|
||||||
|
<label className="flex items-center gap-2 mb-2 text-sm font-medium text-gray-700">
|
||||||
|
<span>Portal Benutzername</span>
|
||||||
|
{copyValue && <CopyButton value={copyValue} title={`Benutzername "${copyValue}" in Zwischenablage kopieren`} />}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
@@ -902,6 +1085,7 @@ export default function ContractForm() {
|
|||||||
{usernameType === 'manual' && (
|
{usernameType === 'manual' && (
|
||||||
<Input
|
<Input
|
||||||
{...register('portalUsername')}
|
{...register('portalUsername')}
|
||||||
|
maxLength={100}
|
||||||
placeholder="Benutzername eingeben..."
|
placeholder="Benutzername eingeben..."
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -962,48 +1146,120 @@ export default function ContractForm() {
|
|||||||
{['ELECTRICITY', 'GAS'].includes(contractType) && (
|
{['ELECTRICITY', 'GAS'].includes(contractType) && (
|
||||||
<Card className="mb-6" title={contractType === 'ELECTRICITY' ? 'Strom-Details' : 'Gas-Details'}>
|
<Card className="mb-6" title={contractType === 'ELECTRICITY' ? 'Strom-Details' : 'Gas-Details'}>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<Select
|
{(() => {
|
||||||
label="Zähler"
|
const selectedAddressId = watch('addressId');
|
||||||
{...register('meterId')}
|
const meterRegister = register('meterId');
|
||||||
options={meters
|
// Zähler werden auf die Lieferadresse des Vertrags gefiltert. Zähler ohne
|
||||||
.filter((m) => m.type === contractType && (m.isActive || m.id.toString() === watch('meterId')))
|
// Lieferadresse (Bestand) bleiben für den aktuell gewählten Eintrag sichtbar,
|
||||||
.map((m) => ({
|
// damit die Auswahl nicht verschwindet, bis sie nachgepflegt sind.
|
||||||
value: m.id,
|
const filteredMeters = meters.filter((m) => {
|
||||||
label: `${m.meterNumber}${m.location ? ` (${m.location})` : ''}${!m.isActive ? ' (deaktiviert)' : ''}`,
|
if (m.type !== contractType) return false;
|
||||||
}))}
|
const isCurrentlySelected = m.id.toString() === watch('meterId');
|
||||||
/>
|
if (isCurrentlySelected) return true;
|
||||||
|
if (!selectedAddressId) return false;
|
||||||
|
return m.addressId != null && m.addressId.toString() === selectedAddressId;
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
label="Zähler"
|
||||||
|
{...meterRegister}
|
||||||
|
onChange={(e) => {
|
||||||
|
meterRegister.onChange(e);
|
||||||
|
const m = meters.find((x) => x.id.toString() === e.target.value);
|
||||||
|
if (m && !m.isActive) {
|
||||||
|
toast(
|
||||||
|
'Deaktivierter Zähler ausgewählt. Ist das gewollt? Handelt es sich um einen Altvertrag?',
|
||||||
|
{ icon: '⚠️', duration: 6000 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
options={filteredMeters.map((m) => ({
|
||||||
|
value: m.id,
|
||||||
|
label: `${m.meterNumber}${m.location ? ` (${m.location})` : ''}${!m.isActive ? ' (deaktiviert)' : ''}`,
|
||||||
|
}))}
|
||||||
|
placeholder={
|
||||||
|
!selectedAddressId
|
||||||
|
? 'Erst Lieferadresse wählen...'
|
||||||
|
: filteredMeters.length === 0
|
||||||
|
? 'Kein Zähler für diese Adresse vorhanden'
|
||||||
|
: 'Zähler wählen...'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
<Input
|
<Input
|
||||||
label="MaLo-ID (Marktlokations-ID)"
|
label="MaLo-ID (Marktlokations-ID)"
|
||||||
{...register('maloId')}
|
{...register('maloId')}
|
||||||
/>
|
/>
|
||||||
<Input
|
<div>
|
||||||
label={`Jahresverbrauch (${contractType === 'ELECTRICITY' ? 'kWh' : 'm³'})`}
|
|
||||||
type="number"
|
|
||||||
{...register('annualConsumption')}
|
|
||||||
/>
|
|
||||||
{contractType === 'GAS' && (
|
|
||||||
<Input
|
<Input
|
||||||
label="Jahresverbrauch (kWh)"
|
label={`Jahresverbrauch (${contractType === 'ELECTRICITY' ? 'kWh' : 'm³'})`}
|
||||||
type="number"
|
type="number"
|
||||||
{...register('annualConsumptionKwh')}
|
{...register('annualConsumption')}
|
||||||
/>
|
/>
|
||||||
|
{previousConsumptionUsable && (
|
||||||
|
<p className="mt-1 text-xs text-blue-600 flex items-center gap-2">
|
||||||
|
<span>
|
||||||
|
Vorvertrag: {(contractType === 'GAS'
|
||||||
|
? previousConsumption!.consumptionM3 ?? previousConsumption!.consumptionKwh
|
||||||
|
: previousConsumption!.consumptionKwh
|
||||||
|
).toLocaleString('de-DE', { maximumFractionDigits: 0 })} {contractType === 'GAS' ? 'm³' : 'kWh'}
|
||||||
|
{previousConsumption!.type === 'projected' && ' (hochgerechnet)'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-blue-700 hover:underline"
|
||||||
|
onClick={() => {
|
||||||
|
const v = contractType === 'GAS'
|
||||||
|
? previousConsumption!.consumptionM3 ?? previousConsumption!.consumptionKwh
|
||||||
|
: previousConsumption!.consumptionKwh;
|
||||||
|
setValue('annualConsumption', Math.round(v) as any, { shouldDirty: true });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Übernehmen
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{contractType === 'GAS' && (
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
label="Jahresverbrauch (kWh)"
|
||||||
|
type="number"
|
||||||
|
{...register('annualConsumptionKwh')}
|
||||||
|
/>
|
||||||
|
{previousConsumptionUsable && (
|
||||||
|
<p className="mt-1 text-xs text-blue-600 flex items-center gap-2">
|
||||||
|
<span>
|
||||||
|
Vorvertrag: {previousConsumption!.consumptionKwh.toLocaleString('de-DE', { maximumFractionDigits: 0 })} kWh
|
||||||
|
{previousConsumption!.type === 'projected' && ' (hochgerechnet)'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-blue-700 hover:underline"
|
||||||
|
onClick={() => setValue('annualConsumptionKwh', Math.round(previousConsumption!.consumptionKwh) as any, { shouldDirty: true })}
|
||||||
|
>
|
||||||
|
Übernehmen
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<Input label="Grundpreis (€/Monat)" type="number" step="any" {...register('basePrice')} />
|
<Input label="Grundpreis (€/Monat)" type="number" step="any" {...register('basePrice')} />
|
||||||
<Input
|
<EuroCentInput
|
||||||
label={selectedMeter?.tariffModel === 'DUAL' ? 'HT-Arbeitspreis (€/kWh)' : 'Arbeitspreis (€/kWh)'}
|
label={selectedMeter?.tariffModel === 'DUAL' ? 'HT-Arbeitspreis (€/kWh)' : 'Arbeitspreis (€/kWh)'}
|
||||||
type="number"
|
value={watch('unitPrice')}
|
||||||
step="any"
|
onChange={(v) => setValue('unitPrice', v, { shouldDirty: true })}
|
||||||
{...register('unitPrice')}
|
|
||||||
/>
|
/>
|
||||||
{selectedMeter?.tariffModel === 'DUAL' && (
|
{selectedMeter?.tariffModel === 'DUAL' && (
|
||||||
<Input
|
<EuroCentInput
|
||||||
label="NT-Arbeitspreis (€/kWh)"
|
label="NT-Arbeitspreis (€/kWh)"
|
||||||
type="number"
|
value={watch('unitPriceNt')}
|
||||||
step="any"
|
onChange={(v) => setValue('unitPriceNt', v, { shouldDirty: true })}
|
||||||
{...register('unitPriceNt')}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Input label="Bonus (€)" type="number" step="0.01" {...register('bonus')} />
|
<Input label="Sofort-Bonus (€)" type="number" step="0.01" {...register('instantBonus')} />
|
||||||
|
<Input label="Neukunden-Bonus (€)" type="number" step="0.01" {...register('newCustomerBonus')} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hinweis für Zählerstände und Rechnungen */}
|
{/* Hinweis für Zählerstände und Rechnungen */}
|
||||||
@@ -1172,16 +1428,26 @@ export default function ContractForm() {
|
|||||||
<Trash2 className="w-4 h-4 text-red-500" />
|
<Trash2 className="w-4 h-4 text-red-500" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-3">
|
||||||
<Input
|
<Input
|
||||||
label="Rufnummer"
|
label="Vorwahl"
|
||||||
value={pn.phoneNumber}
|
value={pn.areaCode}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const updated = [...phoneNumbers];
|
const updated = [...phoneNumbers];
|
||||||
updated[index].phoneNumber = e.target.value;
|
updated[index].areaCode = e.target.value;
|
||||||
setPhoneNumbers(updated);
|
setPhoneNumbers(updated);
|
||||||
}}
|
}}
|
||||||
placeholder="z.B. 030 123456"
|
placeholder="z.B. 030"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Rufnummer"
|
||||||
|
value={pn.subscriber}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updated = [...phoneNumbers];
|
||||||
|
updated[index].subscriber = e.target.value;
|
||||||
|
setPhoneNumbers(updated);
|
||||||
|
}}
|
||||||
|
placeholder="z.B. 123456"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="SIP-Benutzername"
|
label="SIP-Benutzername"
|
||||||
@@ -1238,7 +1504,8 @@ export default function ContractForm() {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPhoneNumbers([...phoneNumbers, {
|
setPhoneNumbers([...phoneNumbers, {
|
||||||
phoneNumber: '',
|
areaCode: '',
|
||||||
|
subscriber: '',
|
||||||
sipUsername: '',
|
sipUsername: '',
|
||||||
sipPassword: '',
|
sipPassword: '',
|
||||||
sipServer: '',
|
sipServer: '',
|
||||||
@@ -1313,6 +1580,19 @@ export default function ContractForm() {
|
|||||||
/>
|
/>
|
||||||
Hauptkarte
|
Hauptkarte
|
||||||
</label>
|
</label>
|
||||||
|
<label className="flex items-center gap-1 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={card.isEsim}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updated = [...simCards];
|
||||||
|
updated[index].isEsim = e.target.checked;
|
||||||
|
setSimCards(updated);
|
||||||
|
}}
|
||||||
|
className="rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
eSIM
|
||||||
|
</label>
|
||||||
<label className="flex items-center gap-1 text-sm">
|
<label className="flex items-center gap-1 text-sm">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -1338,6 +1618,18 @@ export default function ContractForm() {
|
|||||||
<Trash2 className="w-4 h-4 text-red-500" />
|
<Trash2 className="w-4 h-4 text-red-500" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<Input
|
||||||
|
label="Kartennutzer"
|
||||||
|
value={card.cardUser}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updated = [...simCards];
|
||||||
|
updated[index].cardUser = e.target.value;
|
||||||
|
setSimCards(updated);
|
||||||
|
}}
|
||||||
|
placeholder="z.B. Mitarbeiter-/Kind-Name (optional, kann vom Vertragsinhaber abweichen)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
<Input
|
<Input
|
||||||
label="Rufnummer"
|
label="Rufnummer"
|
||||||
@@ -1426,6 +1718,8 @@ export default function ContractForm() {
|
|||||||
puk: '',
|
puk: '',
|
||||||
isMultisim: false,
|
isMultisim: false,
|
||||||
isMain: simCards.length === 0, // Erste Karte ist Hauptkarte
|
isMain: simCards.length === 0, // Erste Karte ist Hauptkarte
|
||||||
|
isEsim: false,
|
||||||
|
cardUser: '',
|
||||||
}]);
|
}]);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1502,6 +1796,93 @@ export default function ContractForm() {
|
|||||||
|
|
||||||
{/* Status-Info Modal */}
|
{/* Status-Info Modal */}
|
||||||
<StatusInfoModal isOpen={showStatusInfo} onClose={() => setShowStatusInfo(false)} />
|
<StatusInfoModal isOpen={showStatusInfo} onClose={() => setShowStatusInfo(false)} />
|
||||||
|
|
||||||
|
{/* Kunden-Schnellansicht */}
|
||||||
|
{customer && (
|
||||||
|
<CustomerInfoModal
|
||||||
|
customerId={customer.id}
|
||||||
|
open={showCustomerInfo}
|
||||||
|
onClose={() => setShowCustomerInfo(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Doppel-Input für Arbeitspreise: links €/kWh, rechts ct/kWh.
|
||||||
|
* Beide bidirektional verkoppelt – Tippen im Cent-Feld setzt das
|
||||||
|
* darunterliegende Euro-Feld (÷ 100), und umgekehrt. Persistiert
|
||||||
|
* wird ausschließlich der Euro-Wert (Backend-Format unverändert).
|
||||||
|
* Cent-Anzeige wird live aus dem Euro-Wert abgeleitet.
|
||||||
|
*/
|
||||||
|
function EuroCentInput({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string | number | undefined | null;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
}) {
|
||||||
|
// Euro-Wert als String wie die anderen Inputs (react-hook-form merged
|
||||||
|
// numeric / string-Werte). Wir trimmen trailing zeros bei der
|
||||||
|
// Cent-Anzeige, damit "0.25 €" sauber als "25 ct" angezeigt wird –
|
||||||
|
// ohne "25.000000".
|
||||||
|
const euroStr = value == null ? '' : String(value);
|
||||||
|
const centDisplay = (() => {
|
||||||
|
if (euroStr === '') return '';
|
||||||
|
const n = parseFloat(euroStr);
|
||||||
|
if (!Number.isFinite(n)) return '';
|
||||||
|
// × 100 mit Float-Rauschen-Schutz (0.25 * 100 = 25.000000000000004)
|
||||||
|
const c = Math.round(n * 100 * 1_000_000) / 1_000_000;
|
||||||
|
return String(c);
|
||||||
|
})();
|
||||||
|
|
||||||
|
const handleCentChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const raw = e.target.value;
|
||||||
|
if (raw === '') {
|
||||||
|
onChange('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const num = parseFloat(raw);
|
||||||
|
if (!Number.isFinite(num)) {
|
||||||
|
onChange('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// cent / 100, Float-Rauschen-Schutz analog.
|
||||||
|
const euro = Math.round((num / 100) * 1_000_000) / 1_000_000;
|
||||||
|
onChange(String(euro));
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputCls =
|
||||||
|
'block w-full px-3 py-2 pr-8 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">{label}</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
value={euroStr}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
<span className="absolute right-3 top-2 text-gray-400 text-sm pointer-events-none">€</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
value={centDisplay}
|
||||||
|
onChange={handleCentChange}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
<span className="absolute right-3 top-2 text-gray-400 text-sm pointer-events-none">ct</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,11 @@ interface ProviderFormData {
|
|||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
domain: string;
|
domain: string;
|
||||||
|
// Optional: Bezeichnung im UI. Wenn leer, wird sie aus der Domain abgeleitet
|
||||||
|
// (z.B. "stressfrei-wechseln.de" → "Stressfrei-Wechseln"). Hier kann ein
|
||||||
|
// freier Override gesetzt werden (z.B. "interne Kunden Email Adressen"),
|
||||||
|
// ohne die echte Domain zu ändern.
|
||||||
|
customerEmailLabel: string;
|
||||||
defaultForwardEmail: string;
|
defaultForwardEmail: string;
|
||||||
// Verschlüsselungs-Einstellungen
|
// Verschlüsselungs-Einstellungen
|
||||||
imapEncryption: MailEncryption;
|
imapEncryption: MailEncryption;
|
||||||
@@ -51,6 +56,7 @@ const emptyForm: ProviderFormData = {
|
|||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
domain: 'stressfrei-wechseln.de',
|
domain: 'stressfrei-wechseln.de',
|
||||||
|
customerEmailLabel: '',
|
||||||
defaultForwardEmail: '',
|
defaultForwardEmail: '',
|
||||||
imapEncryption: 'SSL',
|
imapEncryption: 'SSL',
|
||||||
smtpEncryption: 'SSL',
|
smtpEncryption: 'SSL',
|
||||||
@@ -140,6 +146,7 @@ export default function EmailProviders() {
|
|||||||
username: config.username || '',
|
username: config.username || '',
|
||||||
password: '', // Passwort wird nicht geladen
|
password: '', // Passwort wird nicht geladen
|
||||||
domain: config.domain,
|
domain: config.domain,
|
||||||
|
customerEmailLabel: config.customerEmailLabel || '',
|
||||||
defaultForwardEmail: config.defaultForwardEmail || '',
|
defaultForwardEmail: config.defaultForwardEmail || '',
|
||||||
imapEncryption: config.imapEncryption ?? 'SSL',
|
imapEncryption: config.imapEncryption ?? 'SSL',
|
||||||
smtpEncryption: config.smtpEncryption ?? 'SSL',
|
smtpEncryption: config.smtpEncryption ?? 'SSL',
|
||||||
@@ -288,6 +295,7 @@ export default function EmailProviders() {
|
|||||||
apiKey: formData.apiKey, // Leerer String wird im Backend zu null
|
apiKey: formData.apiKey, // Leerer String wird im Backend zu null
|
||||||
username: formData.username,
|
username: formData.username,
|
||||||
domain: formData.domain,
|
domain: formData.domain,
|
||||||
|
customerEmailLabel: formData.customerEmailLabel.trim() || null,
|
||||||
defaultForwardEmail: formData.defaultForwardEmail,
|
defaultForwardEmail: formData.defaultForwardEmail,
|
||||||
imapEncryption: formData.imapEncryption,
|
imapEncryption: formData.imapEncryption,
|
||||||
smtpEncryption: formData.smtpEncryption,
|
smtpEncryption: formData.smtpEncryption,
|
||||||
@@ -572,6 +580,20 @@ export default function EmailProviders() {
|
|||||||
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'}").
|
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>
|
</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
label="Bezeichnung im UI (optional)"
|
||||||
|
value={formData.customerEmailLabel}
|
||||||
|
onChange={(e) => setFormData({ ...formData, customerEmailLabel: e.target.value })}
|
||||||
|
placeholder={formData.domain ? formData.domain.split('.')[0].split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join('-') : 'aus Domain ableiten'}
|
||||||
|
maxLength={60}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Überschreibt die aus der Domain abgeleitete Bezeichnung – z.B.
|
||||||
|
"interne Kunden Email Adressen". Leer lassen für Auto-Ableitung.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="Standard-Weiterleitungsadresse"
|
label="Standard-Weiterleitungsadresse"
|
||||||
value={formData.defaultForwardEmail}
|
value={formData.defaultForwardEmail}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ 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';
|
import { viewUrl } from '../../utils/fileUrl';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
|
||||||
const STATUS_OPTIONS = [
|
const STATUS_OPTIONS = [
|
||||||
@@ -364,7 +364,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(viewUrl(`/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" />
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ 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';
|
import { viewUrl } from '../../utils/fileUrl';
|
||||||
|
|
||||||
export default function PdfTemplates() {
|
export default function PdfTemplates() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -96,7 +96,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={viewUrl(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>
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
import { appSettingsApi } from '../../services/api';
|
import { appSettingsApi } from '../../services/api';
|
||||||
import Card from '../../components/ui/Card';
|
import Card from '../../components/ui/Card';
|
||||||
import { ArrowLeft, Globe, MessageSquare } from 'lucide-react';
|
import Input from '../../components/ui/Input';
|
||||||
|
import Button from '../../components/ui/Button';
|
||||||
|
import { ArrowLeft, Globe, MessageSquare, Link as LinkIcon } from 'lucide-react';
|
||||||
|
|
||||||
export default function PortalSettings() {
|
export default function PortalSettings() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -14,10 +17,12 @@ export default function PortalSettings() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [customerSupportEnabled, setCustomerSupportEnabled] = useState(false);
|
const [customerSupportEnabled, setCustomerSupportEnabled] = useState(false);
|
||||||
|
const [portalLoginUrl, setPortalLoginUrl] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settingsData?.data) {
|
if (settingsData?.data) {
|
||||||
setCustomerSupportEnabled(settingsData.data.customerSupportTicketsEnabled === 'true');
|
setCustomerSupportEnabled(settingsData.data.customerSupportTicketsEnabled === 'true');
|
||||||
|
setPortalLoginUrl(settingsData.data.portalLoginUrl || '');
|
||||||
}
|
}
|
||||||
}, [settingsData]);
|
}, [settingsData]);
|
||||||
|
|
||||||
@@ -27,6 +32,9 @@ export default function PortalSettings() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['app-settings'] });
|
queryClient.invalidateQueries({ queryKey: ['app-settings'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['app-settings-public'] });
|
queryClient.invalidateQueries({ queryKey: ['app-settings-public'] });
|
||||||
},
|
},
|
||||||
|
onError: (err: Error) => {
|
||||||
|
toast.error(err.message || 'Speichern fehlgeschlagen');
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSupportToggle = (enabled: boolean) => {
|
const handleSupportToggle = (enabled: boolean) => {
|
||||||
@@ -34,6 +42,18 @@ export default function PortalSettings() {
|
|||||||
updateMutation.mutate({ customerSupportTicketsEnabled: enabled ? 'true' : 'false' });
|
updateMutation.mutate({ customerSupportTicketsEnabled: enabled ? 'true' : 'false' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSavePortalLoginUrl = () => {
|
||||||
|
const trimmed = portalLoginUrl.trim().replace(/\/+$/, '');
|
||||||
|
if (trimmed && !/^https?:\/\//i.test(trimmed)) {
|
||||||
|
toast.error('URL muss mit http:// oder https:// beginnen');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateMutation.mutate(
|
||||||
|
{ portalLoginUrl: trimmed },
|
||||||
|
{ onSuccess: () => toast.success('Portal-Login-URL gespeichert') },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
@@ -54,6 +74,40 @@ export default function PortalSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Card title="Portal-Login-URL" className="mb-6">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<LinkIcon className="w-5 h-5 text-gray-500 mt-2" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm text-gray-600 mb-3">
|
||||||
|
Diese Basis-URL wird in Mails an Kunden eingesetzt, die ihre
|
||||||
|
Portal-Zugangsdaten zugeschickt bekommen, sowie bei
|
||||||
|
Passwort-Reset-Links. Ohne Eintrag wird auf den Server-
|
||||||
|
Standard zurückgegriffen, was lokal in <code>localhost:5173</code>
|
||||||
|
endet – Kunden klicken dann auf einen toten Link.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={portalLoginUrl}
|
||||||
|
onChange={(e) => setPortalLoginUrl(e.target.value)}
|
||||||
|
placeholder="https://crm.deine-domain.de"
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleSavePortalLoginUrl}
|
||||||
|
disabled={updateMutation.isPending || portalLoginUrl === (settingsData?.data?.portalLoginUrl || '')}
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-2">
|
||||||
|
Beispiel: <code>https://crm.deine-domain.de</code> →
|
||||||
|
Login-Link in der Mail wird <code>https://crm.deine-domain.de/portal/login</code>.
|
||||||
|
Trailing-Slash wird automatisch entfernt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card title="Support-Anfragen">
|
<Card title="Support-Anfragen">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
@@ -292,8 +292,28 @@ function ProviderModal({
|
|||||||
portalUrl: '',
|
portalUrl: '',
|
||||||
usernameFieldName: '',
|
usernameFieldName: '',
|
||||||
passwordFieldName: '',
|
passwordFieldName: '',
|
||||||
|
contactEmail: '',
|
||||||
|
contactPhone: '',
|
||||||
|
contactFax: '',
|
||||||
|
contactAddress: '',
|
||||||
|
cancellationEmail: '',
|
||||||
|
cancellationFax: '',
|
||||||
|
cancellationAddress: '',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
// Pentest 47.1: bei Portal-URL-Domain-Wechsel muss der aufrufende
|
||||||
|
// Admin sein eigenes Passwort mitsenden – Schutz gegen kompromittierten
|
||||||
|
// JWT, der sonst Phishing-URLs auf existierende Anbieter setzen könnte.
|
||||||
|
currentPassword: '',
|
||||||
});
|
});
|
||||||
|
const originalPortalUrl = provider?.portalUrl ?? '';
|
||||||
|
// Pentest 49.1: Jede URL-Änderung (inkl. Pfad/Query) braucht Re-Auth –
|
||||||
|
// nicht nur Host-Wechsel. Normalisierung (Trailing-Slash, Whitespace,
|
||||||
|
// Case) passend zum Backend, damit der Banner mit der Backend-Prüfung
|
||||||
|
// übereinstimmt.
|
||||||
|
const normalizeUrl = (u: string) => u.trim().replace(/\/+$/, '').toLowerCase();
|
||||||
|
const portalUrlChanged = normalizeUrl(formData.portalUrl) !== normalizeUrl(originalPortalUrl);
|
||||||
|
const portalUrlSetOnCreate = !provider && !!formData.portalUrl.trim();
|
||||||
|
const needsReAuth = portalUrlChanged || portalUrlSetOnCreate;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
@@ -303,7 +323,15 @@ function ProviderModal({
|
|||||||
portalUrl: provider.portalUrl || '',
|
portalUrl: provider.portalUrl || '',
|
||||||
usernameFieldName: provider.usernameFieldName || '',
|
usernameFieldName: provider.usernameFieldName || '',
|
||||||
passwordFieldName: provider.passwordFieldName || '',
|
passwordFieldName: provider.passwordFieldName || '',
|
||||||
|
contactEmail: provider.contactEmail || '',
|
||||||
|
contactPhone: provider.contactPhone || '',
|
||||||
|
contactFax: provider.contactFax || '',
|
||||||
|
contactAddress: provider.contactAddress || '',
|
||||||
|
cancellationEmail: provider.cancellationEmail || '',
|
||||||
|
cancellationFax: provider.cancellationFax || '',
|
||||||
|
cancellationAddress: provider.cancellationAddress || '',
|
||||||
isActive: provider.isActive,
|
isActive: provider.isActive,
|
||||||
|
currentPassword: '',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setFormData({
|
setFormData({
|
||||||
@@ -311,7 +339,15 @@ function ProviderModal({
|
|||||||
portalUrl: '',
|
portalUrl: '',
|
||||||
usernameFieldName: '',
|
usernameFieldName: '',
|
||||||
passwordFieldName: '',
|
passwordFieldName: '',
|
||||||
|
contactEmail: '',
|
||||||
|
contactPhone: '',
|
||||||
|
contactFax: '',
|
||||||
|
contactAddress: '',
|
||||||
|
cancellationEmail: '',
|
||||||
|
cancellationFax: '',
|
||||||
|
cancellationAddress: '',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
currentPassword: '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -342,10 +378,17 @@ function ProviderModal({
|
|||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (needsReAuth && !formData.currentPassword) {
|
||||||
|
alert('Bitte das eigene Passwort zur Bestätigung der Portal-URL eingeben.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// currentPassword wird nur mitgesendet wenn überhaupt nötig
|
||||||
|
const payload: any = { ...formData };
|
||||||
|
if (!needsReAuth) delete payload.currentPassword;
|
||||||
if (provider) {
|
if (provider) {
|
||||||
updateMutation.mutate(formData);
|
updateMutation.mutate(payload);
|
||||||
} else {
|
} else {
|
||||||
createMutation.mutate(formData);
|
createMutation.mutate(payload);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -373,6 +416,26 @@ function ProviderModal({
|
|||||||
placeholder="https://kundenportal.anbieter.de/login"
|
placeholder="https://kundenportal.anbieter.de/login"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{needsReAuth && (
|
||||||
|
<div className="p-3 bg-amber-50 border border-amber-200 rounded-lg space-y-2">
|
||||||
|
<p className="text-sm text-amber-800">
|
||||||
|
<strong>Bestätigung erforderlich:</strong>{' '}
|
||||||
|
{provider
|
||||||
|
? 'Die Portal-URL wurde geändert. Diese URL ist anschließend für alle Portal-Kunden dieses Anbieters klickbar.'
|
||||||
|
: 'Mit dem Speichern wird die Portal-URL für alle Portal-Kunden dieses Anbieters klickbar.'}
|
||||||
|
{' '}Zur Sicherheit ist eine Bestätigung mit dem eigenen Passwort nötig.
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
label="Eigenes Passwort zur Bestätigung *"
|
||||||
|
type="password"
|
||||||
|
value={formData.currentPassword}
|
||||||
|
onChange={(e) => setFormData({ ...formData, currentPassword: e.target.value })}
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="p-3 bg-gray-50 rounded-lg space-y-3">
|
<div className="p-3 bg-gray-50 rounded-lg space-y-3">
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
<strong>Auto-Login Felder</strong> (optional)<br />
|
<strong>Auto-Login Felder</strong> (optional)<br />
|
||||||
@@ -392,6 +455,75 @@ function ProviderModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 bg-gray-50 rounded-lg space-y-3">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
<strong>Kontakt & Kündigung</strong> (optional)<br />
|
||||||
|
Erreichbarkeit des Anbieters – wird im CRM zum Nachschlagen
|
||||||
|
angezeigt, nicht an Portal-Kunden ausgespielt.
|
||||||
|
</p>
|
||||||
|
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Kontakt</div>
|
||||||
|
<Input
|
||||||
|
label="Kontakt-Emailadresse"
|
||||||
|
type="email"
|
||||||
|
value={formData.contactEmail}
|
||||||
|
onChange={(e) => setFormData({ ...formData, contactEmail: e.target.value })}
|
||||||
|
placeholder="z.B. service@anbieter.de"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Kontakt-Telefonnummer"
|
||||||
|
value={formData.contactPhone}
|
||||||
|
onChange={(e) => setFormData({ ...formData, contactPhone: e.target.value })}
|
||||||
|
placeholder="z.B. +49 30 1234567"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Kontakt-Faxnummer"
|
||||||
|
value={formData.contactFax}
|
||||||
|
onChange={(e) => setFormData({ ...formData, contactFax: e.target.value })}
|
||||||
|
placeholder="z.B. +49 30 7654321"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Kontakt-Postadresse
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.contactAddress}
|
||||||
|
onChange={(e) => setFormData({ ...formData, contactAddress: e.target.value })}
|
||||||
|
rows={3}
|
||||||
|
maxLength={500}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="z.B. Musteranbieter GmbH Musterstraße 1 12345 Berlin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2 text-xs font-semibold text-gray-500 uppercase tracking-wide">Kündigung</div>
|
||||||
|
<Input
|
||||||
|
label="Kündigungs-Emailadresse"
|
||||||
|
type="email"
|
||||||
|
value={formData.cancellationEmail}
|
||||||
|
onChange={(e) => setFormData({ ...formData, cancellationEmail: e.target.value })}
|
||||||
|
placeholder="z.B. kuendigung@anbieter.de"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Kündigungs-Faxnummer"
|
||||||
|
value={formData.cancellationFax}
|
||||||
|
onChange={(e) => setFormData({ ...formData, cancellationFax: e.target.value })}
|
||||||
|
placeholder="z.B. +49 30 9876543"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Kündigungs-Postadresse
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.cancellationAddress}
|
||||||
|
onChange={(e) => setFormData({ ...formData, cancellationAddress: e.target.value })}
|
||||||
|
rows={3}
|
||||||
|
maxLength={500}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="z.B. Musteranbieter GmbH Abteilung Kündigung Musterstraße 1 12345 Berlin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{provider && (
|
{provider && (
|
||||||
<label className="flex items-center gap-2">
|
<label className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -660,21 +660,33 @@ function CreateSupportTicketModal({
|
|||||||
/>
|
/>
|
||||||
<div className="max-h-48 overflow-y-auto border rounded-lg">
|
<div className="max-h-48 overflow-y-auto border rounded-lg">
|
||||||
{filteredContracts.length > 0 ? (
|
{filteredContracts.length > 0 ? (
|
||||||
filteredContracts.map((contract) => (
|
filteredContracts.map((contract) => {
|
||||||
<div
|
const isSelected = selectedContractId === contract.id;
|
||||||
key={contract.id}
|
return (
|
||||||
onClick={() => setSelectedContractId(contract.id)}
|
<div
|
||||||
className={`p-3 cursor-pointer border-b last:border-b-0 hover:bg-gray-50 ${
|
key={contract.id}
|
||||||
selectedContractId === contract.id ? 'bg-blue-50 border-blue-200' : ''
|
onClick={() => setSelectedContractId(contract.id)}
|
||||||
}`}
|
className={`p-3 cursor-pointer border-b last:border-b-0 transition-colors flex items-center gap-3 ${
|
||||||
>
|
isSelected
|
||||||
<div className="font-medium">{contract.contractNumber}</div>
|
? 'bg-blue-100 border-l-4 border-l-blue-600 pl-2'
|
||||||
<div className="text-sm text-gray-500">
|
: 'hover:bg-gray-50'
|
||||||
{contract.providerName || 'Kein Anbieter'}
|
}`}
|
||||||
{contract.tariffName && ` - ${contract.tariffName}`}
|
>
|
||||||
|
{isSelected && (
|
||||||
|
<CheckCircle className="w-5 h-5 text-blue-600 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className={`font-medium ${isSelected ? 'text-blue-900' : ''}`}>
|
||||||
|
{contract.contractNumber}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{contract.providerName || 'Kein Anbieter'}
|
||||||
|
{contract.tariffName && ` - ${contract.tariffName}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))
|
})
|
||||||
) : (
|
) : (
|
||||||
<div className="p-3 text-gray-500 text-center">
|
<div className="p-3 text-gray-500 text-center">
|
||||||
Keine Verträge gefunden.
|
Keine Verträge gefunden.
|
||||||
|
|||||||
@@ -238,6 +238,9 @@ function UserModal({
|
|||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
|
// Pentest 47.3: bei Passwort-Änderung muss der aufrufende Admin sein
|
||||||
|
// eigenes Passwort zur Bestätigung mitsenden (Re-Auth gegen Token-Klau).
|
||||||
|
currentPassword: '',
|
||||||
firstName: '',
|
firstName: '',
|
||||||
lastName: '',
|
lastName: '',
|
||||||
roleIds: [] as number[],
|
roleIds: [] as number[],
|
||||||
@@ -257,6 +260,7 @@ function UserModal({
|
|||||||
setFormData({
|
setFormData({
|
||||||
email: user.email,
|
email: user.email,
|
||||||
password: '',
|
password: '',
|
||||||
|
currentPassword: '',
|
||||||
firstName: user.firstName,
|
firstName: user.firstName,
|
||||||
lastName: user.lastName,
|
lastName: user.lastName,
|
||||||
roleIds: user.roles?.filter((r: any) => !['Developer', 'Kunde', 'DSGVO'].includes(r.name)).map((r: any) => r.id) || [],
|
roleIds: user.roles?.filter((r: any) => !['Developer', 'Kunde', 'DSGVO'].includes(r.name)).map((r: any) => r.id) || [],
|
||||||
@@ -271,6 +275,7 @@ function UserModal({
|
|||||||
setFormData({
|
setFormData({
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
|
currentPassword: '',
|
||||||
firstName: '',
|
firstName: '',
|
||||||
lastName: '',
|
lastName: '',
|
||||||
roleIds: [],
|
roleIds: [],
|
||||||
@@ -326,9 +331,13 @@ function UserModal({
|
|||||||
// Passwort-Setzen ist serverseitig ein eigener Endpoint (separater
|
// Passwort-Setzen ist serverseitig ein eigener Endpoint (separater
|
||||||
// Audit-Eintrag). Wenn beides gefragt: erst Daten, dann PW.
|
// Audit-Eintrag). Wenn beides gefragt: erst Daten, dann PW.
|
||||||
if (formData.password) {
|
if (formData.password) {
|
||||||
|
if (!formData.currentPassword) {
|
||||||
|
setError('Bitte das eigene Passwort zur Bestätigung eingeben.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
updateMutation.mutate(updateData, {
|
updateMutation.mutate(updateData, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
userApi.setPassword(user.id, formData.password).catch((err) => {
|
userApi.setPassword(user.id, formData.password, formData.currentPassword).catch((err) => {
|
||||||
alert(err?.response?.data?.error || 'Passwort konnte nicht gesetzt werden');
|
alert(err?.response?.data?.error || 'Passwort konnte nicht gesetzt werden');
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -412,6 +421,23 @@ function UserModal({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{user && formData.password && (
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
label="Eigenes Passwort zur Bestätigung *"
|
||||||
|
type="password"
|
||||||
|
value={formData.currentPassword}
|
||||||
|
onChange={(e) => setFormData({ ...formData, currentPassword: e.target.value })}
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Sicherheitsmaßnahme: bestätige mit deinem eigenen Login-Passwort,
|
||||||
|
dass diese Änderung wirklich von dir kommt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Messaging-Kanäle (für Datenschutz-Versand)</label>
|
<label className="block text-sm font-medium text-gray-700 mb-2">Messaging-Kanäle (für Datenschutz-Versand)</label>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
@@ -369,6 +369,8 @@ export interface StressfreiEmail {
|
|||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
isProvisioned?: boolean;
|
isProvisioned?: boolean;
|
||||||
hasMailbox: boolean;
|
hasMailbox: boolean;
|
||||||
|
/** Zusätzliche Weiterleitungs-E-Mails als JSON-Array-String. */
|
||||||
|
additionalForwardingEmails?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -537,6 +539,14 @@ export const stressfreiEmailApi = {
|
|||||||
}>>(`/stressfrei-emails/${id}/sync-forwarding`);
|
}>>(`/stressfrei-emails/${id}/sync-forwarding`);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
|
// Zusätzliche Weiterleitungs-Adressen ersetzen + sofort am Provider syncen.
|
||||||
|
updateAdditionalForwards: async (id: number, emails: string[]) => {
|
||||||
|
const res = await api.put<ApiResponse<{ forwardTargets: string[] }>>(
|
||||||
|
`/stressfrei-emails/${id}/additional-forwards`,
|
||||||
|
{ emails },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
// E-Mails synchronisieren
|
// E-Mails synchronisieren
|
||||||
syncEmails: async (id: number, fullSync = false) => {
|
syncEmails: async (id: number, fullSync = false) => {
|
||||||
const res = await api.post<ApiResponse<SyncResult>>(`/stressfrei-emails/${id}/sync`, {}, { params: { full: fullSync } });
|
const res = await api.post<ApiResponse<SyncResult>>(`/stressfrei-emails/${id}/sync`, {}, { params: { full: fullSync } });
|
||||||
@@ -587,19 +597,24 @@ export const cachedEmailApi = {
|
|||||||
const res = await api.get<ApiResponse<CachedEmail[]>>(`/customers/${customerId}/emails`, { params: options });
|
const res = await api.get<ApiResponse<CachedEmail[]>>(`/customers/${customerId}/emails`, { params: options });
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
// E-Mails für Vertrag abrufen
|
// E-Mails für Vertrag abrufen (optional pro Postfach gefiltert)
|
||||||
getForContract: async (contractId: number, options?: { folder?: 'INBOX' | 'SENT'; limit?: number; offset?: number }) => {
|
getForContract: async (
|
||||||
|
contractId: number,
|
||||||
|
options?: { folder?: 'INBOX' | 'SENT'; accountId?: number; limit?: number; offset?: number },
|
||||||
|
) => {
|
||||||
const res = await api.get<ApiResponse<CachedEmail[]>>(`/contracts/${contractId}/emails`, { params: options });
|
const res = await api.get<ApiResponse<CachedEmail[]>>(`/contracts/${contractId}/emails`, { params: options });
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
// Ordner-Anzahlen für Vertrag abrufen (zugeordnete E-Mails)
|
// Ordner-Anzahlen für Vertrag abrufen (zugeordnete E-Mails, optional pro Postfach)
|
||||||
getContractFolderCounts: async (contractId: number) => {
|
getContractFolderCounts: async (contractId: number, accountId?: number) => {
|
||||||
const res = await api.get<ApiResponse<{
|
const res = await api.get<ApiResponse<{
|
||||||
inbox: number;
|
inbox: number;
|
||||||
inboxUnread: number;
|
inboxUnread: number;
|
||||||
sent: number;
|
sent: number;
|
||||||
sentUnread: number;
|
sentUnread: number;
|
||||||
}>>(`/contracts/${contractId}/emails/folder-counts`);
|
trash: number;
|
||||||
|
trashUnread: number;
|
||||||
|
}>>(`/contracts/${contractId}/emails/folder-counts`, { params: accountId ? { accountId } : undefined });
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
// Mailbox-Konten eines Kunden abrufen
|
// Mailbox-Konten eines Kunden abrufen
|
||||||
@@ -659,14 +674,14 @@ export const cachedEmailApi = {
|
|||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
// ==================== PAPIERKORB ====================
|
// ==================== PAPIERKORB ====================
|
||||||
// Papierkorb-E-Mails für Kunden abrufen
|
// Papierkorb-E-Mails für Kunden abrufen (optional pro Postfach/Vertrag gefiltert)
|
||||||
getTrash: async (customerId: number) => {
|
getTrash: async (customerId: number, options?: { accountId?: number; contractId?: number }) => {
|
||||||
const res = await api.get<ApiResponse<CachedEmail[]>>(`/customers/${customerId}/emails/trash`);
|
const res = await api.get<ApiResponse<CachedEmail[]>>(`/customers/${customerId}/emails/trash`, { params: options });
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
// Papierkorb-Anzahl für Kunden
|
// Papierkorb-Anzahl für Kunden (gleiche Filter wie getTrash)
|
||||||
getTrashCount: async (customerId: number) => {
|
getTrashCount: async (customerId: number, options?: { accountId?: number; contractId?: number }) => {
|
||||||
const res = await api.get<ApiResponse<{ count: number }>>(`/customers/${customerId}/emails/trash/count`);
|
const res = await api.get<ApiResponse<{ count: number }>>(`/customers/${customerId}/emails/trash/count`, { params: options });
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
// E-Mail aus Papierkorb wiederherstellen
|
// E-Mail aus Papierkorb wiederherstellen
|
||||||
@@ -710,6 +725,18 @@ export const cachedEmailApi = {
|
|||||||
);
|
);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
|
// E-Mail als Vertragsdokument speichern (PDF aus der Mail wird als
|
||||||
|
// ContractDocument hinterlegt – parallel zur Anhang-Variante)
|
||||||
|
saveEmailAsContractDocument: async (
|
||||||
|
emailId: number,
|
||||||
|
params: { documentType: string; notes?: string; deliveryDate?: string },
|
||||||
|
) => {
|
||||||
|
const res = await api.post<ApiResponse<{ id: number; documentType: string; documentPath: string }>>(
|
||||||
|
`/emails/${emailId}/save-as-contract-document`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
// Anhang als Rechnung speichern (für Energieverträge)
|
// Anhang als Rechnung speichern (für Energieverträge)
|
||||||
saveAttachmentAsInvoice: async (emailId: number, filename: string, params: { invoiceDate: string; invoiceType: string; notes?: string }) => {
|
saveAttachmentAsInvoice: async (emailId: number, filename: string, params: { invoiceDate: string; invoiceType: string; notes?: string }) => {
|
||||||
const encodedFilename = encodeURIComponent(filename);
|
const encodedFilename = encodeURIComponent(filename);
|
||||||
@@ -837,7 +864,7 @@ export const contractApi = {
|
|||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
// Folgezähler
|
// Folgezähler
|
||||||
addSuccessorMeter: async (contractId: number, data: { meterId: number; installedAt?: string; finalReadingPrevious?: number }) => {
|
addSuccessorMeter: async (contractId: number, data: { meterId: number; installedAt?: string; finalReadingPrevious?: number; deactivatePredecessor?: boolean }) => {
|
||||||
const res = await api.post<ApiResponse<any>>(`/contracts/${contractId}/successor-meter`, data);
|
const res = await api.post<ApiResponse<any>>(`/contracts/${contractId}/successor-meter`, data);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
@@ -1344,8 +1371,9 @@ export const userApi = {
|
|||||||
},
|
},
|
||||||
// Passwort eines Users zurücksetzen (Admin-Funktion). Separat vom generischen
|
// Passwort eines Users zurücksetzen (Admin-Funktion). Separat vom generischen
|
||||||
// Update, damit der Vorgang einen eigenen Audit-Eintrag bekommt.
|
// Update, damit der Vorgang einen eigenen Audit-Eintrag bekommt.
|
||||||
setPassword: async (id: number, password: string) => {
|
// Pentest 47.3: braucht currentPassword (eigenes Admin-Passwort) als Re-Auth.
|
||||||
const res = await api.post<ApiResponse<void>>(`/users/${id}/password`, { password });
|
setPassword: async (id: number, password: string, currentPassword: string) => {
|
||||||
|
const res = await api.post<ApiResponse<void>>(`/users/${id}/password`, { password, currentPassword });
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
delete: async (id: number) => {
|
delete: async (id: number) => {
|
||||||
@@ -1812,6 +1840,14 @@ export const gdprApi = {
|
|||||||
const res = await api.post<ApiResponse<{ url: string; channel: string; hash: string }>>(`/gdpr/customer/${customerId}/send-consent-link`, { channel });
|
const res = await api.post<ApiResponse<{ url: string; channel: string; hash: string }>>(`/gdpr/customer/${customerId}/send-consent-link`, { channel });
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
|
// Unterschreibbare Datenschutzerklärung als PDF (Papierform).
|
||||||
|
// Liefert die URL inkl. Auth-Token, damit window.open/<a download> klappt
|
||||||
|
// (Browser senden bei plain links keinen Authorization-Header).
|
||||||
|
getSignablePrivacyPdfUrl: (customerId: number): string => {
|
||||||
|
const token = getAccessToken();
|
||||||
|
const base = `/api/gdpr/customer/${customerId}/privacy-pdf`;
|
||||||
|
return token ? `${base}?token=${encodeURIComponent(token)}` : base;
|
||||||
|
},
|
||||||
// Portal: Eigene Datenschutzseite
|
// Portal: Eigene Datenschutzseite
|
||||||
getMyPrivacy: async () => {
|
getMyPrivacy: async () => {
|
||||||
const res = await api.get<ApiResponse<{ privacyPolicyHtml: string; consents: CustomerConsent[] }>>('/gdpr/my-privacy');
|
const res = await api.get<ApiResponse<{ privacyPolicyHtml: string; consents: CustomerConsent[] }>>('/gdpr/my-privacy');
|
||||||
|
|||||||
@@ -216,7 +216,43 @@ export interface Meter {
|
|||||||
tariffModel: MeterTariffModel;
|
tariffModel: MeterTariffModel;
|
||||||
location?: string;
|
location?: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
// Lieferadresse, an der der Zähler hängt. Optional, weil Bestandszähler
|
||||||
|
// (vor Einführung dieses Feldes) noch ohne Adresse existieren können.
|
||||||
|
addressId?: number | null;
|
||||||
|
address?: Address;
|
||||||
readings?: MeterReading[];
|
readings?: MeterReading[];
|
||||||
|
// Verträge, die diesen Zähler aktuell als Hauptzähler nutzen
|
||||||
|
energyDetails?: Array<{
|
||||||
|
id: number;
|
||||||
|
contractId: number;
|
||||||
|
contract?: {
|
||||||
|
id: number;
|
||||||
|
contractNumber: string;
|
||||||
|
status: ContractStatus;
|
||||||
|
type: string;
|
||||||
|
providerName?: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
// Verträge, in denen der Zähler in der ContractMeter-Kette steht
|
||||||
|
// (Vorgänger oder Nachfolger über Zählerwechsel)
|
||||||
|
contractMeters?: Array<{
|
||||||
|
id: number;
|
||||||
|
energyContractDetailsId: number;
|
||||||
|
position: number;
|
||||||
|
installedAt?: string;
|
||||||
|
removedAt?: string;
|
||||||
|
energyContractDetails?: {
|
||||||
|
id: number;
|
||||||
|
contractId: number;
|
||||||
|
contract?: {
|
||||||
|
id: number;
|
||||||
|
contractNumber: string;
|
||||||
|
status: ContractStatus;
|
||||||
|
type: string;
|
||||||
|
providerName?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContractMeter {
|
export interface ContractMeter {
|
||||||
@@ -360,6 +396,13 @@ export interface Provider {
|
|||||||
portalUrl?: string;
|
portalUrl?: string;
|
||||||
usernameFieldName?: string;
|
usernameFieldName?: string;
|
||||||
passwordFieldName?: string;
|
passwordFieldName?: string;
|
||||||
|
contactEmail?: string;
|
||||||
|
contactPhone?: string;
|
||||||
|
contactFax?: string;
|
||||||
|
contactAddress?: string;
|
||||||
|
cancellationEmail?: string;
|
||||||
|
cancellationFax?: string;
|
||||||
|
cancellationAddress?: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
tariffs?: Tariff[];
|
tariffs?: Tariff[];
|
||||||
_count?: {
|
_count?: {
|
||||||
@@ -418,6 +461,9 @@ export interface Contract {
|
|||||||
tariffName?: string;
|
tariffName?: string;
|
||||||
customerNumberAtProvider?: string;
|
customerNumberAtProvider?: string;
|
||||||
contractNumberAtProvider?: string;
|
contractNumberAtProvider?: string;
|
||||||
|
orderNumberAtSalesPlatform?: string;
|
||||||
|
customerNumberAtSalesPlatform?: string;
|
||||||
|
contractNumberAtSalesPlatform?: string;
|
||||||
priceFirst12Months?: string;
|
priceFirst12Months?: string;
|
||||||
priceFrom13Months?: string;
|
priceFrom13Months?: string;
|
||||||
priceAfter24Months?: string;
|
priceAfter24Months?: string;
|
||||||
@@ -429,7 +475,10 @@ export interface Contract {
|
|||||||
contractDuration?: ContractDuration;
|
contractDuration?: ContractDuration;
|
||||||
commission?: number;
|
commission?: number;
|
||||||
portalUsername?: string;
|
portalUsername?: string;
|
||||||
portalPasswordEncrypted?: string;
|
/** Backend liefert nur ein Bool-Flag – der verschlüsselte Wert selbst
|
||||||
|
* bleibt server-seitig (sanitizeContract strippt `portalPasswordEncrypted`).
|
||||||
|
* Entschlüsselter Wert kommt über `GET /contracts/:id/password`. */
|
||||||
|
hasPortalPassword?: boolean;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
// Kündigungsdokumente
|
// Kündigungsdokumente
|
||||||
cancellationLetterPath?: string;
|
cancellationLetterPath?: string;
|
||||||
@@ -469,7 +518,8 @@ export interface EnergyContractDetails {
|
|||||||
basePrice?: number; // €/Monat
|
basePrice?: number; // €/Monat
|
||||||
unitPrice?: number; // €/kWh (Arbeitspreis) - bei HT/NT: HT-Preis
|
unitPrice?: number; // €/kWh (Arbeitspreis) - bei HT/NT: HT-Preis
|
||||||
unitPriceNt?: number; // €/kWh NT-Preis (nur bei Zweitarifzähler)
|
unitPriceNt?: number; // €/kWh NT-Preis (nur bei Zweitarifzähler)
|
||||||
bonus?: number;
|
instantBonus?: number; // Sofort-Bonus
|
||||||
|
newCustomerBonus?: number; // Neukunden-Bonus
|
||||||
previousProviderName?: string;
|
previousProviderName?: string;
|
||||||
previousCustomerNumber?: string;
|
previousCustomerNumber?: string;
|
||||||
invoices?: Invoice[]; // Rechnungen
|
invoices?: Invoice[]; // Rechnungen
|
||||||
@@ -500,7 +550,11 @@ export interface InternetContractDetails {
|
|||||||
|
|
||||||
export interface PhoneNumber {
|
export interface PhoneNumber {
|
||||||
id: number;
|
id: number;
|
||||||
|
// Komplette Nummer (Vorwahl + Anschluss). Bleibt für bestehende
|
||||||
|
// Listen/Suchen die Quelle der Wahrheit. Vorwahl zusätzlich separat
|
||||||
|
// gepflegt, damit Wechselauftrag-PDFs sie verlässlich befüllen.
|
||||||
phoneNumber: string;
|
phoneNumber: string;
|
||||||
|
areaCode?: string;
|
||||||
isMain: boolean;
|
isMain: boolean;
|
||||||
// SIP-Zugangsdaten
|
// SIP-Zugangsdaten
|
||||||
sipUsername?: string;
|
sipUsername?: string;
|
||||||
@@ -517,6 +571,10 @@ export interface SimCard {
|
|||||||
puk?: string; // verschlüsselt
|
puk?: string; // verschlüsselt
|
||||||
isMultisim: boolean;
|
isMultisim: boolean;
|
||||||
isMain: boolean;
|
isMain: boolean;
|
||||||
|
isEsim: boolean;
|
||||||
|
// Tatsächlicher Nutzer der Karte (z.B. Mitarbeiter bei Firmenvertrag,
|
||||||
|
// Kind bei Eltern-Vertrag) – optional, kann vom Vertragsinhaber abweichen.
|
||||||
|
cardUser?: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,8 +23,10 @@ export interface CostCalculation {
|
|||||||
annualConsumptionCostNt?: number; // NT-Verbrauch × unitPriceNt
|
annualConsumptionCostNt?: number; // NT-Verbrauch × unitPriceNt
|
||||||
annualTotalCost: number; // Summe
|
annualTotalCost: number; // Summe
|
||||||
monthlyPayment: number; // annualTotalCost / 12
|
monthlyPayment: number; // annualTotalCost / 12
|
||||||
bonus?: number;
|
instantBonus?: number; // Sofort-Bonus
|
||||||
effectiveAnnualCost: number; // annualTotalCost - bonus
|
newCustomerBonus?: number; // Neukunden-Bonus
|
||||||
|
totalBonus?: number; // Summe = instantBonus + newCustomerBonus
|
||||||
|
effectiveAnnualCost: number; // annualTotalCost - totalBonus
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -207,9 +209,10 @@ export function calculateCosts(
|
|||||||
consumptionKwh: number,
|
consumptionKwh: number,
|
||||||
basePrice?: number,
|
basePrice?: number,
|
||||||
unitPrice?: number,
|
unitPrice?: number,
|
||||||
bonus?: number,
|
instantBonus?: number,
|
||||||
consumptionNtKwh?: number,
|
consumptionNtKwh?: number,
|
||||||
unitPriceNt?: number
|
unitPriceNt?: number,
|
||||||
|
newCustomerBonus?: number,
|
||||||
): CostCalculation | null {
|
): CostCalculation | null {
|
||||||
// Mindestens ein Preis muss vorhanden sein
|
// Mindestens ein Preis muss vorhanden sein
|
||||||
if (basePrice == null && unitPrice == null) {
|
if (basePrice == null && unitPrice == null) {
|
||||||
@@ -221,7 +224,8 @@ export function calculateCosts(
|
|||||||
const annualConsumptionCost = consumptionKwh * (unitPrice ?? 0);
|
const annualConsumptionCost = consumptionKwh * (unitPrice ?? 0);
|
||||||
const annualConsumptionCostNt = (consumptionNtKwh ?? 0) * (unitPriceNt ?? 0);
|
const annualConsumptionCostNt = (consumptionNtKwh ?? 0) * (unitPriceNt ?? 0);
|
||||||
const annualTotalCost = annualBaseCost + annualConsumptionCost + annualConsumptionCostNt;
|
const annualTotalCost = annualBaseCost + annualConsumptionCost + annualConsumptionCostNt;
|
||||||
const effectiveAnnualCost = annualTotalCost - (bonus ?? 0);
|
const totalBonus = (instantBonus ?? 0) + (newCustomerBonus ?? 0);
|
||||||
|
const effectiveAnnualCost = annualTotalCost - totalBonus;
|
||||||
const monthlyPayment = effectiveAnnualCost / 12;
|
const monthlyPayment = effectiveAnnualCost / 12;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -230,7 +234,9 @@ export function calculateCosts(
|
|||||||
annualConsumptionCostNt: annualConsumptionCostNt > 0 ? annualConsumptionCostNt : undefined,
|
annualConsumptionCostNt: annualConsumptionCostNt > 0 ? annualConsumptionCostNt : undefined,
|
||||||
annualTotalCost,
|
annualTotalCost,
|
||||||
monthlyPayment,
|
monthlyPayment,
|
||||||
bonus: bonus ?? undefined,
|
instantBonus: instantBonus && instantBonus > 0 ? instantBonus : undefined,
|
||||||
|
newCustomerBonus: newCustomerBonus && newCustomerBonus > 0 ? newCustomerBonus : undefined,
|
||||||
|
totalBonus: totalBonus > 0 ? totalBonus : undefined,
|
||||||
effectiveAnnualCost,
|
effectiveAnnualCost,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -256,13 +262,24 @@ export function calculateMultiMeterConsumption(
|
|||||||
let firstStart: MeterReading | undefined;
|
let firstStart: MeterReading | undefined;
|
||||||
let lastEnd: MeterReading | undefined;
|
let lastEnd: MeterReading | undefined;
|
||||||
|
|
||||||
|
const contractStartMs = new Date(startDate).getTime();
|
||||||
|
const contractEndMs = new Date(endDate).getTime();
|
||||||
|
|
||||||
for (const cm of contractMeters) {
|
for (const cm of contractMeters) {
|
||||||
const readings = cm.meter?.readings || [];
|
const readings = cm.meter?.readings || [];
|
||||||
if (readings.length === 0) continue;
|
if (readings.length === 0) continue;
|
||||||
|
|
||||||
// Zeitraum für diesen Zähler bestimmen
|
// Zeitraum für diesen Zähler bestimmen, GE-CLAMPED auf die Vertragslaufzeit.
|
||||||
const meterStart = cm.installedAt || startDate;
|
// Ohne Clamp würden Folgezähler, die nach Vertragsende installiert wurden
|
||||||
const meterEnd = cm.removedAt || endDate;
|
// (typisch bei Vorgängerverträgen einer Folgevertrags-Kette), zukünftige
|
||||||
|
// Zählerstände in den Verbrauch dieses Vertrags einrechnen.
|
||||||
|
const installedMs = cm.installedAt ? new Date(cm.installedAt).getTime() : contractStartMs;
|
||||||
|
const removedMs = cm.removedAt ? new Date(cm.removedAt).getTime() : contractEndMs;
|
||||||
|
const meterStartMs = Math.max(installedMs, contractStartMs);
|
||||||
|
const meterEndMs = Math.min(removedMs, contractEndMs);
|
||||||
|
if (meterStartMs > meterEndMs) continue; // Zähler liegt komplett außerhalb der Laufzeit
|
||||||
|
const meterStart = new Date(meterStartMs).toISOString();
|
||||||
|
const meterEnd = new Date(meterEndMs).toISOString();
|
||||||
|
|
||||||
const result = calculateConsumption(readings, meterStart, meterEnd, contractType);
|
const result = calculateConsumption(readings, meterStart, meterEnd, contractType);
|
||||||
|
|
||||||
|
|||||||
@@ -15,11 +15,25 @@
|
|||||||
*/
|
*/
|
||||||
import { getAccessToken } from '../services/api';
|
import { getAccessToken } from '../services/api';
|
||||||
|
|
||||||
export function fileUrl(path: string | null | undefined): string {
|
/**
|
||||||
|
* Kurzform für Inline-Vorschau: identisch zu `fileUrl(path, { inline: true })`.
|
||||||
|
* Verwenden für „Anzeigen"-Links / target="_blank"-Vorschauen. Default-
|
||||||
|
* `fileUrl(path)` bleibt für Downloads (Content-Disposition: attachment).
|
||||||
|
*/
|
||||||
|
export function viewUrl(path: string | null | undefined): string {
|
||||||
|
return fileUrl(path, { inline: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fileUrl(path: string | null | undefined, opts?: { inline?: boolean }): string {
|
||||||
if (!path) return '';
|
if (!path) return '';
|
||||||
const token = getAccessToken();
|
const token = getAccessToken();
|
||||||
const normalizedPath = path.startsWith('/') ? path : '/' + path;
|
const normalizedPath = path.startsWith('/') ? path : '/' + path;
|
||||||
const base = `/api/files/download?path=${encodeURIComponent(normalizedPath)}`;
|
// `?disposition=inline` schaltet die Anzeige im Browser-Tab ein,
|
||||||
|
// der Backend-Controller bleibt aber nur dann inline, wenn die
|
||||||
|
// Datei tatsächlich ein safe Type (PDF/PNG/JPEG/GIF/WebP) ist –
|
||||||
|
// sonst fällt's auf attachment zurück. Default attachment.
|
||||||
|
const dispParam = opts?.inline ? '&disposition=inline' : '';
|
||||||
|
const base = `/api/files/download?path=${encodeURIComponent(normalizedPath)}${dispParam}`;
|
||||||
if (!token) return base;
|
if (!token) return base;
|
||||||
return `${base}&token=${encodeURIComponent(token)}`;
|
return `${base}&token=${encodeURIComponent(token)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Defense-in-depth: liefert einen URL-String nur zurück, wenn er ein
|
||||||
|
* sicheres http(s)-Schema hat. Sonst undefined.
|
||||||
|
*
|
||||||
|
* Hintergrund: das Backend validiert beim Speichern (Pentest 46.1),
|
||||||
|
* aber Alt-Daten in der DB können noch `javascript:alert(...)` o.ä.
|
||||||
|
* enthalten. React eskapt URLs in `href` NICHT automatisch – ein Klick
|
||||||
|
* auf einen `javascript:`-Link triggert die XSS im User-Browser.
|
||||||
|
*
|
||||||
|
* Diese Funktion wird überall dort eingesetzt, wo wir User-Input
|
||||||
|
* als `<a href>` rendern oder per `window.open` öffnen.
|
||||||
|
*/
|
||||||
|
export function safeHttpUrl(value: string | null | undefined): string | undefined {
|
||||||
|
if (!value) return undefined;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed === '') return undefined;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(trimmed);
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return undefined;
|
||||||
|
return trimmed;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user