# đ OpenCRM â Todo-Liste
---
## đ Offen
### Manuelle Tests (vor Release durchklicken)
Checklisten fĂŒr Security + Email-Log-System stehen in **[TESTING.md](./TESTING.md)**.
Einmal komplett durchlaufen vor v1.0.0-Release.
### đ SaaS-Ausbau: Instance-per-Customer + Admin-Portal + GoCardless
**Vision:** OpenCRM als SaaS anbieten. Jeder Kunde bekommt seine eigene
isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
ĂŒber ein zentrales Admin-Portal.
**Architektur-Entscheidung:** Weg C (Instance-per-Customer)
- Pro Kunde eine eigene Docker-Instanz mit eigener DB
- Keine `tenantId` im CRM-Code â keine Security-Risiken durch vergessene Filter
- Komplette Datenisolation (DSGVO-freundlich)
- Updates können gestaffelt ausgerollt werden (erst 10% testen)
- Bei KĂŒndigung: Docker-Image + DB-Export als "Mitnehm-Paket"
**Bewusst NICHT dabei:** eigener Mailserver. Stattdessen Plesk-Integration
(die wir schon haben) â Kunde bekommt Mail-Zugang ĂŒber unseren Plesk bei Bedarf.
---
**Admin-Portal (separate App, neben den CRM-Instanzen):**
- Kundenverwaltung: wer hat welchen Plan, Status (Trial/Active/Suspended/Cancelled)
- "Neuen Kunden anlegen" â Provisioning-Script
- DB anlegen (Master-DB kennt die Mapping)
- Docker-Container starten
- Subdomain konfigurieren (`kundenname.deincrm.de` via Caddy/Traefik)
- Initial-Admin-Account erstellen + Einladungs-Email senden
- Optional: Factory-Defaults fĂŒr Stammdaten einspielen
- GoCardless-Integration (Webhook + Dashboard)
- Instanz-Management: Pause/Resume bei Zahlungsproblemen
- Logs & Metriken pro Instanz (optional)
- Support-Bereich (Tickets? oder einfach E-Mail)
---
**Abrechnung mit GoCardless (gocardless.com):**
- Zahlungsmethoden: SEPA-Lastschrift (Hauptfokus) + Kreditkarte (ĂŒber GoCardless Embedded/Success)
- 30 Tage kostenlose Testphase ohne Zahlungsmittel
- Nach Trial: Mandats-Erfassung â regelmĂ€Ăige Abbuchung
- Mehrere PlÀne (z.B. Basic / Pro / Enterprise) mit unterschiedlichen Features
- Webhook-Endpoint im Admin-Portal:
- `payment_confirmed` â Instanz aktiv lassen
- `payment_failed` â Banner im CRM, nach X Tagen pausieren
- `mandate_cancelled` â KĂŒndigungs-Flow
- Rechnungsstellung: GoCardless liefert Zahlungsbelege, aber **echte Rechnungen**
(mit USt-ID, Rechnungsnummer etc.) mĂŒssen wir selbst generieren
(evtl. ĂŒber das existierende PDF-Template-System aus dem CRM nutzen)
---
**Provisioning-Flow (grober Entwurf):**
1. Kunde registriert sich auf Landing Page (Name, Firma, E-Mail, Wunsch-Subdomain)
2. Admin-Portal: Trial-Instanz starten
- DB erstellen, Docker-Container hochfahren, Caddy-Config fĂŒr Subdomain
- Einladungs-Email mit Admin-Login + Passwort-Reset-Link
3. Tag 25: Erinnerungs-Email "Deine Trial lÀuft bald ab"
4. Tag 30: Banner im CRM "Jetzt bezahlen oder pausieren"
5. Kunde erfasst GoCardless-Mandat im Admin-Portal-Login
6. Bei erfolgreicher Zahlung: Instanz bleibt aktiv
7. Bei fehlender Zahlung nach 7 Tagen: Instanz pausiert (DB bleibt, UI zeigt Hinweis)
---
**Technische Bausteine fĂŒr spĂ€ter:**
- Master-DB mit Tenant-Tabelle (Name, Subdomain, DB-Name, Plan, Status, GoCardlessIDs)
- Caddy oder Traefik als Reverse-Proxy mit Auto-SSL (Let's Encrypt)
- Docker-Orchestrierung: einzelne `docker-compose.yml` pro Kunde oder Docker-Swarm/K8s
- Backup-Strategie: pro Tenant separate Backups + zentrale Master-DB-Backups
- Monitoring: ein Fail macht nicht alle down, aber wir mĂŒssen es mitbekommen
- Logs zentral: z.B. Loki + Grafana fĂŒr aggregierte Logs aller Instanzen
---
**Grobe ZeitschÀtzung:**
- Admin-Portal (MVP): ~1 Woche
- GoCardless-Integration + Webhooks: ~3-5 Tage
- Provisioning-Automatisierung (Docker + Caddy): ~1 Woche
- Landing Page + Checkout: ~3-5 Tage
- Tests + Polishing: ~1 Woche
- **Gesamt: ~3-4 Wochen**
**Vorbereitung JETZT (einfach, macht spÀter Arbeit leichter):**
- â
Factory-Defaults System (schon erledigt, hilft beim Provisioning)
- â
Domain/Label dynamisch per Provider (schon erledigt)
- Docker-Compose aufrÀumen, Env-Variablen dokumentieren (klein, ein Tag)
- Backup-Script robust + wiederherstellbar (haben wir schon weitgehend)
---
## â
Erledigt
- [x] **đ DSGVO-Rolle: MenĂŒpunkte in den Einstellungen unsichtbar**
- Symptom: User mit ausschlieĂlich DSGVO-Rolle sah keinerlei
Karten unter Einstellungen â System (DSGVO-Dashboard,
DatenschutzerklÀrung, Vollmacht-Vorlage, Impressum,
Website-Datenschutz, E-Mail-Versandlog, Audit-Protokoll).
- Ursache: Der gesamte System-Block in `frontend/src/pages/Settings.tsx`
war in `hasPermission('settings:update')` eingewickelt. DSGVO
hat aber nur `audit:*` und `gdpr:*` Perms â kein `settings:update`.
- Fix: Outer-Check erweitert auf
`settings:update || audit:read || gdpr:admin`. Jede Karte hat
weiterhin ihren eigenen feingranularen Check; fĂŒr DSGVO-User
erscheinen nur die DSGVO-/Audit-Karten.
- Backend-API mit reiner DSGVO-Rolle (kein Admin) live durchgetestet:
`/api/gdpr/dashboard`, `/api/audit-logs`, `/api/email-logs`,
`/api/gdpr/privacy-policy`, `/api/gdpr/authorization-template`,
`/api/gdpr/imprint`, `/api/gdpr/website-privacy-policy`,
`/api/gdpr/consents/overview`, `/api/gdpr/deletions` â alle 200.
Backend war nicht das Problem.
- [x] **đĄïž Login-Rate-Limit jetzt pro (IP + Email)-Tupel**
- Vorher reine IP-basierte Sperre, was zwei SchwÀchen hatte:
a) Familie hinter NAT: Max vertippt sich â Nina kommt nicht rein
b) Angreifer wechselt Proxy â wieder 10 freie Versuche pro
Account, dieselbe IP-only-Sperre umgangen.
- Eine reine Email-Sperre wurde verworfen wegen Account-Lockout-
DoS (jeder kann fremde Accounts sperren) + denselben Shared-IP-
Problem.
- **Lösung**: Bucket-Key ist `${ip}|${email-lowercase}`. Damit:
* Max von IP-A 10x vergeigt â (IP-A, max) gesperrt
* Nina von IP-A â eigenes Bucket (IP-A, nina), unbetroffen
* Admin von IP-A mit richtigem PW â erfolgreicher Login
* Max von IP-B â eigenes Bucket (IP-B, max), darf wieder
- Implementation: `loginRateLimiter.keyGenerator = ${ip}|${email}`
in `middleware/rateLimit.ts`; nur ein Limiter, kein zusÀtzlicher
Email-only.
- Admin-UI: Listing zeigt Tupel (IP, Email), Reset schickt
beides mit, Audit-Log resourceId = `${ip}|${email}`.
- **Live-verifiziert** (4 Schritte):
11x falsch max â 429, Nina/Admin von gleicher IP â durch,
max bleibt gesperrt, Reset â max wieder 401.
- [x] **đš PUT /customers/:id/portal mit `password` im Body â 400**
- Endpoint nahm `password` silent entgegen, ignorierte es, gab
aber HTTP 200 zurĂŒck â Client glaubte fĂ€lschlich, das Passwort
sei gesetzt. Fix: explizite Body-Validierung â `password`,
`portalPassword`, `portalPasswordHash`, `portalPasswordEncrypted`
sind verbotene Felder, HTTP 400 mit Hinweis auf den dedizierten
`POST /portal/password`-Endpoint.
- [x] **đš Pentest Runde 17 â JWT-TTL + Pentest-Marker-Detection**
- **21.1 Access-Token 7 Tage**: Bug-Quelle waren die `.env`-Files,
die noch die alte Konvention vor der Refresh-Token-Trennung
hatten (`JWT_EXPIRES_IN=7d`). docker-compose.yml und
`.env.example` standen schon richtig auf 15m als Default.
Alle `.env`-Files (Root, backend/, docker/.env.example,
backend/.env.example) jetzt auf `JWT_EXPIRES_IN=15m` mit
explizitem `JWT_REFRESH_EXPIRES_IN=7d`. Auf prod kann der
Container mit dem neuen Default neu hochgezogen werden.
- **17.5 Alte Pentest-Daten in DB**: das Cleanup-Script lÀuft
schon bei jedem Container-Start, strippt HTML aus Customer/
User-Strings und entfernt nicht-whitelisted AppSettings. Es
erkannte aber keine Test-Records ohne HTML (z.B. Customer mit
`email: hacker@evil.de`). Erweiterung:
* Neue Marker-Pattern-Liste: `^hacker@`, `^attacker@`,
`^pentest@`, `@evil\.`, `` und Àhnliche Payloads landeten
ungefiltert in der DB. Fix: neuer `stripHtml()`-Helper, von
`pickCustomerUpdate/Create` und `pickUserUpdate/Create` auf
allen String-Werten angewandt (Defense-in-Depth â React
auto-escaped schon, aber PDF-Generator/E-Mail-Templates
könnten exec-Vektoren sein).
- **Live-verifiziert (alle vier)**:
* `/factory-reset` mit `{}`, `{confirm:true}`, `{confirm:false}`
â HTTP 400, DB unangetastet
* `PUT /settings {superAdminEmail,debugMode,allowedOrigins}` â
400 + Keys aufgezĂ€hlt; gĂŒltige Keys â 200
* `PUT /users/99999` â `"Operation fehlgeschlagen"` statt
Prisma-Stack; Server-Log behÀlt Original
* `PUT /customers/3 {companyName:"EvilCorp"}`
â gespeichert als `"EvilCorp"`; `
` weg
- [x] **đš Pentest Runde 10 â Live-Vollmacht-Konsistenz + DTO-Leaks in embedded Objekten**
- **MEDIUM â Stale Token nach Vollmacht-Widerruf**:
Selbst ein FRISCHER Portal-Login lieferte JWT mit
`representedCustomerIds: [7]` und `representedCustomers: [{Nina,âŠ}]`,
obwohl die Vollmacht widerrufen war. Live-Check beim Datenzugriff
funktionierte (403), aber die UI zeigte dem Vertreter weiter, dass
er Nina vertreten könne.
* **Fix**: `customerLogin` und `getCustomerPortalUser` (= /me +
Refresh-Pfad) filtern `representingFor` jetzt zusĂ€tzlich ĂŒber
`getAuthorizedCustomerIds()` â nur Beziehungen mit
`isGranted: true` landen im Token und in /me.
* Verifiziert: Customer 1 (vertritt 2,3 aber alle Vollmachten
widerrufen) â JWT.representedCustomerIds = `[]`, /me ebenfalls.
- **MEDIUM â DTO-Leak in embedded Objekten**:
`GET /customers/:id` lieferte zwar Customer-Top-Level sanitisiert,
aber `contracts[]` darin enthielt weiterhin `commission`, `notes`,
`portalPasswordEncrypted`, `nextReviewDate`. Analog `notes` auf
embedded customer in `/contracts/:id`.
* **Fix**: `sanitizeCustomer(Strict)` ruft jetzt
`sanitizeContract(Strict)` fĂŒr jedes Element in `contracts[]`
auf. `notes` zu `PORTAL_HIDDEN_CUSTOMER_FIELDS` ergÀnzt
(interne CRM-Vermerke).
* Verifiziert: Portal-User sieht in `customers/1.contracts[*]`
keine commission/notes/PW-Encrypted/nextReviewDate mehr;
Admin sieht sie weiterhin (Workflow-Bedarf);
`portalPasswordEncrypted` ist generell entfernt (Klartext nur
via `/contracts/:id/password` mit Audit-Log).
- **LOW â `/tasks?customerId=X` 200 statt 403 fĂŒr fremde IDs**:
Konsistenz-Issue: nach Vollmacht-Widerruf gab der Endpoint
leeres Array statt einen klaren 403-Fehler. Jetzt: wenn der
Portal-User explizit nach einer customerId filtert, die er nicht
(mehr) vertreten darf â 403 mit "Kein Zugriff auf diese
Kundendaten". Verifiziert.
- [x] **đš Pentest Runde 7 (Anschlussrunde) â Information-Disclosure + Input-Validation**
- **MEDIUM â Interne Felder in Portal-Responses**:
* `sanitizeCustomerStrict` strippt jetzt zusÀtzlich
`portalTokenInvalidatedAt`, `portalLastLogin`,
`portalPasswordMustChange`, `lastBirthdayGreetingYear`,
`privacyPolicyPath`, `businessRegistrationPath`,
`commercialRegisterPath`.
* Neue `sanitizeContract` / `sanitizeContractStrict` /
`sanitizeContracts(Strict)`: entfernt
`portalPasswordEncrypted` (immer; ist nur ĂŒber den dedizierten
`/password`-Endpoint mit Audit-Log abrufbar) und fĂŒr Portal-
User zusÀtzlich `commission`, `notes`, `nextReviewDate`.
* `getContract` + `getContracts` rufen jetzt die passende
Sanitize-Variante je nach `req.user.isCustomerPortal` auf;
Mitarbeiter sehen weiterhin commission/notes (Admin-Workflow),
nur `portalPasswordEncrypted` ist generell entfernt (Klartext
nur ĂŒber dedicated Endpoint).
* Live-verifiziert: Portal sieht 0 Leaks, Admin sieht
commission/notes weiterhin.
- **LOW â Integer-Truncation bei IDs**:
`parseInt('6abc')` â `6` hat alle Endpoints durchgewunken.
Neuer middleware in `index.ts`: jedes URL-Pfad-Segment unter
`/api`, das mit Ziffer beginnt aber nicht aus reinen Ziffern
besteht, wird mit HTTP 400 abgelehnt. Heuristik trifft alle
`/resource/(\D+)`-Patterns ohne dass jeder einzelne
Sub-Router angefasst werden muss.
* Live-verifiziert: `/customers/6abc` â 400 mit klarer Meldung,
`/customers/3` weiterhin 200, `/contracts/1abc/history`
â 400, normaler Pfade `/audit-logs/customer/3` â 200.
- **INFO â Login-Rate-Limit ânach 6 nicht aktiv"**:
Code-Stand `limit: 10` fĂŒr `loginRateLimiter`, lokal verifiziert:
11. Versuch = 429. Pentester sah vermutlich noch alten Build
oder eine andere Lokation (PW-Reset hat `limit: 5`). Kein
Code-Change.
- [x] **đ Rate-Limit-Sperren: Admin-UI zum Freigeben**
- Bei einer Pentest-Runde hat der Tester sich selbst durch zu viele
Login-Versuche ausgesperrt â ohne Container-Restart kein Weg zurĂŒck.
Jetzt: Admin sieht die Sperren und kann sie einzeln aufheben.
- **Datenquelle fĂŒr die Liste**: `SecurityEvent`-Tabelle filtert nach
`type = RATE_LIMIT_HIT` im 15-Min-Fenster (= Login-Window), gruppiert
nach IP. Pro Eintrag: IP, zuletzt versuchte E-Mail, Limiter-Typ
(Login / Passwort-Reset), Hit-Anzahl, Zeit seit letztem Hit.
- **Reset**: ruft `loginRateLimiter.resetKey(ip)` und
`passwordResetRateLimiter.resetKey(ip)` auf â exposiert von
`express-rate-limit` v7. Idempotent, audited.
- **Backend**:
* `GET /api/settings/rate-limits/active` (`settings:read`)
* `POST /api/settings/rate-limits/reset` (`settings:update`) mit
Body `{ ipAddress }`
* neuer Controller `rateLimitAdmin.controller.ts`
- **Frontend**: neue Seite `/settings/rate-limits` mit Tabelle +
Freigeben-Button, 15s Auto-Refresh; Kachel in Settings-Ăbersicht
(orange, neben âSicherheits-Monitoring").
- **Live-verifiziert (4 Schritte)**: 11 falsche Logins von
127.0.0.1 â 11. â 429; Liste zeigt IP + Email + Hits;
POST Reset â 200; nĂ€chster Login mit falschem PW â 401 statt
429 (Sperre weg); Audit-Log enthÀlt Eintrag.
- [x] **đš Pentest Runde 7 â Hit-List durchgegangen + kurzlebige Download-Tokens**
- **Credential-Endpoints** (Contracts password/internet/sip/simcard +
Stressfrei mailbox/send/reset-password): ALLE bereits durch
`canAccessContract`/`canAccessStressfreiEmail` gesichert â keine
LĂŒcke gefunden.
- **`GET /customers/:id/portal/password`** (Klartext-Portal-Passwort-
Abruf): hatte KEINEN `canAccessCustomer`-Check. Fix: eingefĂŒgt.
Defense in depth gegen versehentlich falsch vergebene
`customers:update`-Permission.
- **Admin-Funktionen** (factory-reset, developer/*, audit-logs/rehash,
audit-logs/customer): alle durch admin-level Permissions
(`settings:update`, `developer:access`, `audit:admin`, `audit:read`)
geschĂŒtzt â Portal-User haben diese nicht.
- **Token-in-URL (NIEDRIG)**: Langlebige Access-JWTs landeten als
`?token=` in URLs fĂŒr PDF-iframe, Audit-Log-Export, PDF-Generate
und Portal-Privacy-PDF â nginx-Access-Logs, Browser-History,
Referer-Header.
* **Neuer Mechanismus**: `POST /api/auth/download-token` liefert
ein kurzlebiges JWT mit `type: 'download'` und `exp: 60s`.
* Auth-Middleware akzeptiert `type: 'download'` AUSSCHLIESSLICH
via `?token=` Query, niemals als Bearer-Header. So kann ein in
Logs geleaktes Download-Token nicht fĂŒr regulĂ€re API-Aufrufe
missbraucht werden.
* Frontend-Migration: 4 Stellen umgestellt (Audit-Log-Export,
PDF-Template-Preview, PDF-Generate von ContractDetail + Modal,
Portal-Privacy-PDF). `fileUrl` und `getAttachmentUrl` sind
synchron und in vielen Components verstreut â Migration dieser
bleibt als Folge-Aufgabe.
* Live-verifiziert: Download-Token = 1773 Zeichen, type=download,
exp-iat=60s, als Header â 401, als ?token= â 200.
- [x] **đš Pentest Runde 6 â Sammelfix + Strukturelles Audit (8 Findings + Audit-Sweep)**
- **KRITISCH-01 `GET /emails/:id/thread`**: kein Owner-Check â
Portal-Kunde konnte alle Mail-Threads durchsuchen. Fix:
`canAccessCachedEmail` im Controller.
- **KRITISCH-02 `GET /customers/:customerId/representatives/search`**:
kein `canAccessCustomer` auf den Pfad â DSGVO-GAU, Portal-Kunde
konnte mit Buchstaben-Brute-Force die komplette Kunden-DB
auslesen. Fix eingefĂŒgt.
- **HOCH-01 `GET /birthdays/upcoming`**: kein Portal-Filter â Name,
E-Mail, Telefon, Geburtsdatum aller Kunden lesbar. Fix:
`isCustomerPortal` â 403.
- **HOCH-02 `*/contracts/:contractId/history`**: kein Owner-Check
auf GET/POST/PUT/DELETE. Fix: `canAccessContract` in allen vier
History-Handlern.
- **HOCH-03 Mailbox-Endpoints**: `mailbox-accounts`, `unread-count`,
`contracts/:id/emails/folder-counts` ohne Check. Fix:
`canAccessCustomer` bzw. `canAccessContract` in allen drei.
- **HOCH-04 Live-Vollmacht-Check in Tasks**: `getTasks`,
`createSupportTicket`, `createCustomerReply`, `getAllTasks`,
`getTaskStats` prĂŒften nur `representedCustomerIds.includes(...)`
aus dem JWT â widerrufene Vollmachten hatten weiter Zugriff
(JWT lebt bis zu 15min nach Widerruf). Neuer Helper
`getPortalAllowedCustomerIds()` in `accessControl.ts` ruft
`hasAuthorization()` live ab. Auch `updateCustomerConsent`
(GDPR) auf diesen Pfad umgestellt.
- **MITTEL-01 `confirmPasswordReset` Klartext-Speicherung**:
Self-Service-Reset speicherte `portalPasswordEncrypted = encrypt(pw)`.
Klartext-Speicherung ist nur fĂŒr Admin-OTPs sinnvoll. Fix:
Field auf null, zusÀtzlich `portalPasswordMustChange = false`.
- **MITTEL-02 Pagination-Total leakt globale Kunden-Anzahl**:
`GET /customers` gab `total: 4271` auch wenn Portal-User nur
1 Kunde sah. Fix: `customer.service.ts` erweitert um
`allowedIds`-Filter, der direkt in der DB-Query landet â die
pagination zĂ€hlt nur ĂŒber erlaubte IDs.
- **Strukturelles Audit-Sweep** (Sub-CRUD + Email-Operationen):
Folgende Handler bekamen jetzt erstmals einen `canAccess*`-
Check, defense in depth gegen falsch vergebene Rollen:
`markAsRead`, `toggleStar`, `assignToContract`,
`unassignFromContract`, `deleteEmail`, `getTrashEmails`,
`getTrashCount`, `restoreEmail`, `permanentDeleteEmail`,
`getAttachmentTargets`, `saveAttachmentTo`, `saveEmailAsPdf`,
`saveEmailAsInvoice`, `saveAttachmentAsInvoice`,
`saveAttachmentAsContractDocument`, `createFollowUp`,
`createRenewal`, `snoozeContract`, `removeContractMeter`,
`updateAddress`, `deleteAddress`, `updateBankCard`,
`deleteBankCard`, `updateDocument`, `deleteDocument`,
`updateMeter`, `deleteMeter`, `addMeterReading`,
`updateMeterReading`, `deleteMeterReading`,
`markReadingTransferred`, `addRepresentative`,
`removeRepresentative`.
- **Live-verifiziert** (Portal-User Customer 3 auf fremde IDs):
`customers/1/representatives/search` â 403,
`birthdays/upcoming` â 403 (Admin â 200),
`emails/21/thread` â 403,
`customers/2/mailbox-accounts` â 403,
`emails/unread-count?customerId=2` â 403,
`contracts/8/{history,folder-counts,follow-up,renewal,snooze}` â 403,
eigene `customers/3` â 200,
pagination.total fĂŒr Portal = 1 (statt 3),
Customer 1 mit widerrufener Vollmacht â 0 fremde VertrĂ€ge.
- [x] **đš Pentest Runde 5 â KRITISCH: change-initial-portal-password ohne Pflicht-Check**
- **Realer Angriff**: Jeder Portal-User konnte jederzeit mit
seinem eingeloggten Token `POST /api/auth/change-initial-portal-
password` aufrufen und das eigene Passwort ohne Kenntnis des
alten ersetzen. Der OTP-Flow-Endpoint hatte den Check
`portalPasswordMustChange === true` nicht.
- **Konsequenz**: Bei XSS oder kurzlebigem Token-Diebstahl konnte
ein Angreifer das Passwort dauerhaft ĂŒbernehmen.
- **Fix**: Eine Zeile in `auth.controller.ts` â
`prisma.customer.findUnique` auf `portalPasswordMustChange`,
bei `false` â 403 "Nicht erlaubt".
- **Live-verifiziert**: ohne Flag â 403; mit Flag (nach
send-credentials) â 200, danach Flag automatisch zurĂŒck auf
`false` â erneuter Aufruf â 403.
- [x] **Pentest Runde 5 â NIEDRIG: consentHash + Public-Grant-Response**
- `consentHash` wurde ĂŒber `GET /api/customers/:id` zurĂŒckgegeben.
Der Hash ist Pseudo-Credential fĂŒr den öffentlichen Consent-Link
(wer ihn hat, sieht Customer-Name + Kundennummer ohne Auth und
kann Einwilligungen erteilen). **Fix**: in
`SENSITIVE_CUSTOMER_FIELDS` aufgenommen. Wer ihn legitim braucht,
holt ihn ĂŒber `/gdpr/customer/:id/consent-status` (eigener Check).
- `POST /api/public/consent/:hash/grant` gab den vollen
`CustomerConsent[]`-Array inkl. IP-Adressen und `createdBy`
(Kunden-Name) zurĂŒck. **Fix**: Response auf
`{ granted: }` reduziert. Frontend nutzt eh nur
`success`-Flag.
- **Live-verifiziert**: `consentHash: null` in customer-Response,
`consentHash` weiterhin in `/gdpr/.../consent-status`,
Grant-Response liefert nur `{granted: 4}` ohne Extra-Keys.
- [x] **đš Pentest Runde 4 â HOCH: Cockpit-IDOR (Portal-User sah ALLE Kunden)**
- **Realer Angriff**: Portal-User Max bekam mit seinem Token
`GET /api/contracts/cockpit` â komplette Vertragsliste ALLER
Kunden (Customer-Namen, Vertragsnummern, Statƫs).
- **Root Cause**: `contractCockpitService.getCockpitData()` filterte
nicht nach Customer, weil das Cockpit ursprĂŒnglich nur fĂŒr Admins
gedacht war. Die `contracts:read`-Permission haben aber auch
Portal-User â Endpoint war erreichbar.
- **Fix**: Service-Signatur erweitert auf
`getCockpitData({ customerIds? })`. Wenn `customerIds` gesetzt
sind, werden Haupt-Vertrags-Query, Consent-Maps, Ausweis-
Warnungen und gemeldete ZÀhlerstÀnde allesamt auf diese IDs
eingeschrÀnkt. Controller bestimmt `customerIds` analog zu
`getContracts`: bei `isCustomerPortal` â eigene + vertretene
Kunden (nur mit Vollmacht); sonst undefined (= alle).
- **Live-verifiziert**: Admin sieht 17 VertrÀge (3 Kunden);
Portal-User Customer 1 sieht 12 (nur seine); Portal-User
Customer 3 sieht 3 (nur seine); 0 Leaks.
- [x] **đš Pentest Runde 3 â drei Findings gefixt**
- **KRITISCH â `POST /api/developer/setup` ohne Auth (Privilege
Escalation)**: Endpoint war komplett ohne Authentifizierung
erreichbar und konnte der Admin-Rolle die `developer:access`-
Permission verleihen â kompletter DB-Zugriff ĂŒber `/developer/*`.
**Fix**: Endpoint ersatzlos gelöscht. Manuelles Setzen geht
weiterhin ĂŒber `prisma/add-developer-permission.ts` (CLI).
Live-verifiziert: `POST /api/developer/setup` â HTTP 404.
- **HOCH â Customer-Login DoS auf Prod (fehlende Migration)**:
`portalPasswordMustChange` war im Code, aber prod-DB kannte die
Spalte nicht â Prisma warf bei jedem Kunden-Login. Root Cause:
in dieser Session wurde `prisma db push` benutzt (kein Migration-
File). **Fix**: handgenerierte Migration
`20260516173552_portal_password_must_change/migration.sql` (via
`prisma migrate diff` + `migrate resolve --applied`). Verifiziert
durch shadow-DB-Reset + `migrate deploy`: Spalte landet korrekt
in einer frischen DB. `entrypoint.sh` fĂŒhrt `migrate deploy`
beim Container-Start bereits aus â Prod-Restart applied jetzt
automatisch.
- **MITTEL â Prisma-Internals-Leak im Login-Error-Body**: Bei
unerwarteten Fehlern (Schema-Bruch, DB-Down) wurde
`error.message` direkt zurĂŒckgegeben â Tabellen-/Spaltennamen
leakten. **Fix**: Whitelist-Filter `safeLoginError()` in
`auth.controller.ts`: nur bekannte Messages
(`'UngĂŒltige Anmeldedaten'`, `'E-Mail und Passwort
erforderlich'`) werden durchgereicht, alles andere wird zu
generischem `'Anmeldung fehlgeschlagen'` und das Original
landet im Server-Log. Greift fĂŒr Mitarbeiter- UND Portal-
Login. Live-verifiziert: Spalte testweise gedropped â Client
sieht generisch, Server-Log enthÀlt Original.
- [x] **đ Einmalpasswort-Flow fĂŒr Portal-Credentials**
- **Intention**: Wenn wir Zugangsdaten per E-Mail an den Kunden
schicken, kennen wir das Passwort als Admin â das ist solange OK,
bis er sich einmal eingeloggt hat. Danach soll er gezwungen sein,
sich ein eigenes zu vergeben, und das per-Mail-Passwort ist tot.
- **Datenmodell**: neues Feld `portalPasswordMustChange: Boolean
@default(false)` am Customer.
- **Flow**:
1. Admin klickt **Zugangsdaten versenden** â Flag wird gesetzt,
Mail-Template weist explizit auf âEinmalpasswort" hin.
2. Kunde loggt sich mit dem OTP ein â Backend gibt
`mustChangePassword: true` im Login-Response zurĂŒck UND
**konsumiert das OTP sofort**: setzt `portalPasswordHash =
null` und `portalPasswordEncrypted = null`. Ein zweiter
Login mit demselben Passwort schlÀgt fehl (401).
3. Frontend (`ProtectedRoute`) sieht `mustChangePassword=true`
und leitet auf `/change-initial-password` um â egal welche
Route der Kunde aufrufen will, er kommt nicht weiter.
4. Auf der Seite gibt er ein neues, komplexes Passwort vor
(Live-Hint mit â/â, dieselben Regeln wie Backend).
5. `POST /api/auth/change-initial-portal-password` speichert
neuen Hash, **löscht das Encrypted-Feld** (Admin kann das
eigene Passwort des Kunden nicht mehr im Klartext lesen),
setzt `portalTokenInvalidatedAt = now()` und
`portalPasswordMustChange = false`.
6. Frontend loggt aus, leitet zu `/login?changed=1`,
Erfolgs-Banner: âPasswort wurde geĂ€ndert. Bitte mit dem
neuen Passwort anmelden."
- **Edge case**: Tab geschlossen ohne Setzen â Kunde ist
ausgesperrt (OTP weg, eigenes Passwort nicht gesetzt). Lösung
aus seiner Sicht: Passwort-vergessen-Funktion oder Admin
versendet neue Zugangsdaten.
- **Edge case**: Admin macht zwischendurch nochmal manuelles
âSetzen" â `mustChange` wird automatisch wieder `false`. So
kann ein versehentlich versendetes OTP problemlos durch ein
direkt-gesetztes Passwort ersetzt werden.
- **Live-verifiziert (10 Schritte)**: Setzen â Send â Flag in
DB=true â Login mit OTP gibt mustChange=true zurĂŒck + Hash
in DB ist null â Re-Login mit OTP â 401 â Change-Endpoint
schwach â 400 â komplex â 200 â Login mit neuem PW â
mustChange=false + tokenInvalidatedAt gesetzt.
- [x] **đ Passwort-KomplexitĂ€t + Portal-Credentials-UX**
- **Problem**: Bisher reichten 6 Zeichen fĂŒr gesetzte Passwörter
(Portal-Login, User-Reset, Registrierung, User-Anlage). Das hat
der Pentest bemÀngelt, und es entsprach auch nicht dem, was wir
selbst von Endkunden erwarten wĂŒrden.
- **Lösung**:
* `validatePasswordComplexity()` in `passwordGenerator.ts`:
mind. 12 Zeichen + GroĂbuchstaben + Kleinbuchstaben + Ziffer
+ Sonderzeichen, mit detaillierter Fehlerliste auf deutsch.
* Erzwungen in **5 Endpoints**: `setPortalPassword`,
`confirmPasswordReset`, `register`, `createUser`, `updateUser`.
- **Neue UX im Kunden-Portal-Block (CustomerDetail)**:
* **Generate-Button**: erzeugt 16-Zeichen-Zufallspasswort, das
garantiert allen KomplexitĂ€tsregeln entspricht, und fĂŒllt
das Eingabefeld direkt aus.
* **Send-Credentials-Button**: schickt Login-URL + Username +
Klartext-Passwort an die Kunden-E-Mail. Funktioniert nur,
wenn "Portal aktiviert" tatsÀchlich aktiviert ist.
* **Live-KomplexitĂ€ts-Hint** beim Tippen: â/â-Liste zeigt
sofort, welche Regeln noch fehlen.
* `alert()`-Boxen durch Toast-Notifications ersetzt.
- **Live-verifiziert**: schwaches Passwort `hallo123` â HTTP 400
mit Fehlerliste, komplexes Passwort `Hallo123!Test` â HTTP 200,
Generator-Endpoint liefert 16-Zeichen-Passwort, Send-Credentials
versendet Mail nur bei portalEnabled=true.
- [x] **đ Real-IP hinter Nginx-Proxy-Manager**
- **Problem**: Rate-Limiter und Security-Monitor haben statt der
echten Client-IP nur die NPM-IP (`172.0.2.12`) geloggt. Damit
wĂ€ren alle Threshold-basierten Blockings nutzlos â ein Brute-
Force von 100 verschiedenen Clients wĂ€re fĂŒr uns 1 Quelle.
- **Root Cause**: `app.set('trust proxy', 'loopback')` â das passt
nur, wenn der Proxy auf 127.0.0.1 lÀuft. NPM lÀuft aber auf
einem anderen Host, also wurde X-Forwarded-For ignoriert.
- **Fix**: trust-proxy abhÀngig von `HTTPS_ENABLED`:
`HTTPS_ENABLED=true` â `1` (genau 1 Hop, der NPM), sonst
`loopback` (Direkt-Verbindungen lokal).
- **Live-verifiziert**: req.ip zeigt jetzt die echte Browser-IP
statt der NPM-IP, Threshold-Events triggern korrekt.
- [x] **đš KRITISCH: IDOR auf Stressfrei-Email-Sub-Routes (Pentest-Fund)**
- **Realer Angriff erfolgreich durchgespielt**: Portal-User konnte ĂŒber
`/api/stressfrei-emails/{id}/credentials` die kompletten Klartext-
IMAP/SMTP-Zugangsdaten der Mailbox eines anderen Kunden abrufen.
- **Root Cause**: der Haupt-Endpoint `GET /:id` hatte
`canAccessStressfreiEmail`-Check, die **8 Sub-Endpoints** unter
`:id/*` hatten alle KEINEN Ownership-Check (nur `authenticate +
requirePermission('customers:read')`, was Portal-User von Haus aus
haben).
- **Fix**: `canAccessStressfreiEmail(req, res, id)` als erste Zeile in
allen 9 betroffenen Controllern: `getMailboxCredentials`,
`getFolderCounts`, `syncAccount`, `sendEmailFromAccount`,
`enableMailbox`, `syncMailboxStatus`, `resetPassword`, `updateEmail`,
`deleteEmail`.
- **Security-Monitor**: `canAccessResourceByCustomerId` emittiert
bei jedem Fehlversuch automatisch ein `ACCESS_DENIED MEDIUM`-Event
â Threshold-Detection (>5 in 5 min) erzeugt `CRITICAL SUSPICIOUS` +
Sofort-Alert.
- **Live-verifiziert**: Portal-User Kunde A probiert Email-ID von
Kunde B durch alle 8 Sub-Routes â **alle 8Ă HTTP 403**, eigene
Email-ID kommt sauber durch (200/400), 8Ă `ACCESS_DENIED`-Events
im Security-Monitor.
- [x] **đĄïž JWT-Tokens raus aus localStorage â Refresh-Cookie-Pattern**
- Pentest-Finding âJWT in localStorage (MITTEL)": bei XSS könnte JS
den Token klauen + alle Anbieter-Credentials abrufen. Lösung:
Branchenstandard fĂŒr SPAs.
- **Access-Token**: kurzlebig (15 min), lebt nur im
JavaScript-Memory (Modul-State + AuthContext). Kein localStorage
mehr â XSS-Angriff klaut maximal einen 15-min-Token, mit dem er
eh nicht weit kommt.
- **Refresh-Token**: 7 Tage Lifetime, im **httpOnly-Cookie** (`Secure`
bei HTTPS_ENABLED, `SameSite=Strict`, `Path=/api/auth`). JavaScript
hat **keinen Zugriff** â XSS kann ihn nicht klauen.
- Backend:
* `signAccessToken/signRefreshToken` mit `type`-Claim als
Unterscheidung; Auth-Middleware lÀsst nur `type=access` durch
* Login + Customer-Login setzen Cookie + geben Access im Body
* `POST /api/auth/refresh` liest Cookie, gibt neuen Access aus,
rotiert Refresh-Cookie, prĂŒft `tokenInvalidatedAt`
(sofortige Invalidation bei Rolle-Ăndern/Logout)
* Logout löscht Cookie + setzt `tokenInvalidatedAt`
* `cookie-parser` als neue dependency
- Frontend:
* `api.ts`: in-memory `tokenStore` + axios-Interceptor mit
Auto-Refresh-Retry bei 401 (single-flight gegen
Concurrent-Requests)
* `AuthContext`: beim App-Start `/auth/refresh` aufrufen â wenn
Cookie noch gĂŒltig, ist der User automatisch eingeloggt
(kein Re-Login nach Tab-Reload trotz memory-only Access-Token)
* 9 alte `localStorage.getItem('token')`-Stellen migriert auf
`getAccessToken()` (PDF-Vorschau-iframe, Audit-Log-Export,
Backup-Download, File-Download-URL, âŠ)
- Live verifiziert: Login setzt Cookie+Bearer, API-Calls mit
Bearerâ200, ohneâ401, Refresh-Endpoint rotiert Cookie sauber,
Refresh-Token wird als Bearer (Access) abgelehnt (âFalscher
Token-Typ"), Logout löscht Cookie + invalidiert Token.
- [x] **đ Audit-Log fĂŒr alle Klartext-Passwort-Reads**
- Pentest-Finding âKlartext-Passwörter ĂŒber API abrufbar (HIGH,
post-auth)" â reversible VerschlĂŒsselung ist by-design (Feature
âAnbieter-Login anzeigen" braucht es), aber jeder Decrypt-Vorgang
sollte im Audit-Log auftauchen. Bisher: keiner der 6 Endpoints
schrieb ein Log.
- Audit-Logs jetzt fĂŒr: `getPortalPassword`, `getContractPassword`,
`getSimCardCredentials`, `getInternetCredentials`,
`getSipCredentials`, `getMailboxCredentials`.
- `action: 'READ'`, eigene Resource-Types (PortalPassword,
ContractPassword, SimCardCredentials, InternetCredentials,
SipCredentials, MailboxCredentials), alle mit `sensitivity:
CRITICAL` ĂŒber die Sensitivity-Map.
- Label nennt explizit âKlartext ⊠entschlĂŒsselt" + Ressourcen-ID,
damit im Audit-Log-Viewer auf einen Blick erkennbar ist, was
passiert ist (DSGVO-Nachvollziehbarkeit + Insider-Threat-Erkennung).
- [x] **â E-Mail-Postfach: Weiterleiten + Erneut senden**
- **Weiterleiten** (Compose-Modal-Erweiterung): neuer Button im
EmailDetail öffnet das ComposeEmailModal im Forward-Modus â
To-Feld leer (User trÀgt den neuen EmpfÀnger ein), Betreff mit
âFwd:"-Prefix, Body mit zitierten Original-Headern (Von, An,
Datum, Betreff) + Original-Text.
- **Erneut senden** (One-Click): schickt die Mail noch einmal an
die ursprĂŒngliche EmpfĂ€nger-Adresse (= die Stressfrei-Adresse
selbst). Damit lÀuft sie durch die heute hinterlegten Forwards
und landet beim aktuell konfigurierten Kunden-Postfach â Use-Case:
Stressfrei-Adresse wurde nach Empfang umgestellt, Original ist nur
in der alten Inbox. Confirm-Dialog mit Hinweis, dass AnhÀnge nicht
erneut mit gesendet werden (Weiterleiten dafĂŒr nutzen). Toast fĂŒr
Erfolg/Fehler.
- [x] **đ E-Mail-Postfach: Suche + erweiterte Filter (Variante B)**
- Suchleiste ĂŒber der Email-Liste â durchsucht parallel Subject,
From-Address/Name und Body.
- Filter-Button mit Badge (Anzahl aktiver Filter) klappt eine Box mit
Detail-Filtern auf: Von, An, Betreff, Inhalt, Datum von/bis,
Anhang-Dateiname, Mit/Ohne Anhang, Gelesen-Status, Markiert-Status.
Alle Filter werden im Backend mit UND verknĂŒpft.
- âAlle zurĂŒcksetzen"-Button rĂ€umt komplett auf.
- Backend: `GET /api/customers/:id/emails` nimmt die Filter als
Query-Parameter entgegen, `getCachedEmails` ĂŒbersetzt sie in eine
Prisma `where`-Klausel.
- **Bewusst nicht gebaut**: voller AND/OR-Builder mit Plus-Button und
Bool-Verschachtelung â Trade-off-Diskussion mit User: reale
Use-Cases sind quasi immer AND, UI-KomplexitÀt verschachtelter
Bool-Builder bringt mehr Bedienprobleme als Mehrwert.
- [x] **đ Stressfrei-Adressen: Weiterleitungen + Passwort manuell synchronisieren**
- Refresh-Icon-Button in der Action-Reihe jeder Stressfrei-Adresse
(Tooltip erklĂ€rt: âersetzt die Forwards am Provider durch
Kunden-Stamm-E-Mail + Service-Adresse"). Use-Case: nach Ănderung der
Stamm-E-Mail eines Kunden, oder nach Wechsel der
`defaultForwardEmail` in den Provider-Settings.
- **Bei `hasMailbox: true`** wird zusĂ€tzlich das im CRM verschlĂŒsselt
hinterlegte Mailbox-Passwort am Provider neu gesetzt. Self-Healing
fĂŒr den Fall, dass jemand im Plesk-UI manuell ein anderes Passwort
gesetzt hat und IMAP/SMTP im CRM nicht mehr passt.
- Backend nutzt Plesk's `updateForwardTargets` (`set:email1,email2`
â ersetzt komplett, idempotent) + bei Mailbox auch
`updateMailboxPassword` (Plesk-Passwort-Update).
- Endpoint: `POST /api/stressfrei-emails/:id/sync-forwarding`,
`customers:update`-Permission, Audit-Log mit Forward-Targets +
Passwort-Reset-Marker.
- Self-Healing: `isProvisioned`-Flag wird bei erfolgreichem
Provider-Aufruf automatisch auf `true` korrigiert (historischer Bug:
Flag wurde beim `createEmail` mit `provisionAtProvider: true` nie
gesetzt â jetzt behoben + Backfill via Sync).
- Erfolgs-/Fehler-Meldungen via `react-hot-toast` (statt `alert()`)
mit Liste der gesetzten Forward-Targets + Hinweis ob Passwort-Reset
durchgefĂŒhrt wurde.
- In der Kundenakte (Stammdaten â Kontakt â E-Mail) externes
Link-Icon, das in neuem Tab direkt den Stressfrei-Tab des Kunden
öffnet â sichtbar nur wenn Stressfrei-Adressen vorhanden sind.
- [x] **đĄïž Pentest-Hardening-Runde 11: Header-Hygiene**
- **HSTS-Doppel-Header** (18Ă low im Audit): Helmet's
`Strict-Transport-Security` komplett deaktiviert. Der Nginx Proxy Manager
vor der CRM-VM setzt HSTS bereits, doppelter Header verletzte RFC 6797.
- **Cache-Control** (â„10Ă info im Audit):
`/api/*` bekommt `no-store` (sensible JSON-Daten),
SPA-HTML (`/`, `/sitemap.xml`, `/robots.txt`, `/vite.svg`) bekommt
`no-store, must-revalidate` (sonst hÀngt Browser an alter index.html
fest nach Deploy),
`/assets/*` (Vite-Build mit Content-Hash im Filename) bekommt
`public, max-age=31536000, immutable`.
- **CSP No-Fallback-Direktiven** (2Ă medium): `worker-src`, `manifest-src`,
`media-src` explizit auf `'self'` â ZAP markiert sonst âFailure to
Define Directive with No Fallback".
- Bewusst NICHT angefasst: `style-src 'unsafe-inline'` (Tailwind/React-
inline-styles, kompletter Refactor unverhĂ€ltnismĂ€Ăig).
- Live verifiziert: Headers fĂŒr `/`, `/api/*`, `/assets/*.js` und SPA-
Fallback-Pfade alle wie erwartet.
- [x] **đ PDF-Vorschau im PDF-Template-Editor lĂ€dt nicht**
- CSP-Direktive `frame-ancestors 'none'` blockte ALLE iframe-Embeddings
der eigenen Resourcen, auch same-origin â Browser zeigte je nach
Variante "Verbindung abgelehnt" oder CSP-Violation.
- Fix: `frame-ancestors 'self'` (statt `'none'`). App darf eigene
Resourcen embeden (z.B. die annotierte PDF-Vorschau), externe Sites
bleiben weiterhin gesperrt.
- [x] **đ Factory-Defaults Sync-Scripts (dev â prod â Image)**
- `./factory-export.sh` zieht eine ZIP per API in `factory-exports/`
(gitignored Drop-Box).
- `./factory-import.sh [zip]` lÀdt die ZIP per API in eine andere Instanz
â ohne Argument wĂ€hlt es die jĂŒngste ZIP automatisch.
- `./factory-import.sh --save-as-builtin` entpackt die ZIP zusÀtzlich nach
`backend/factory-defaults/` (vorher aufgerÀumt). Damit landet sie beim
nÀchsten `docker-compose up --build` als Werkseinstellung im Image und
seedet frische DBs automatisch.
- Konfigurierbar per Env: `OPENCRM_URL`, `OPENCRM_EMAIL`,
`OPENCRM_PASSWORD` (sonst interaktive Abfrage).
- README-Abschnitt âFactory-Defaults: Stammdaten-Kataloge teilen"
komplett ĂŒberarbeitet (drei Transport-Pfade, Auto-Seed, Whitelist).
- [x] **đ Auto-Seed: Werkseinstellungen beim Erst-Deploy**
- Inhalt von `backend/factory-defaults/` wird via Dockerfile als
`/app/factory-defaults-builtin/` ins Image gebrannt.
- Entrypoint spielt sie nach erfolgreichem Auto-Seed (frische DB) automatisch
via `tsx scripts/seed-factory-defaults.ts` ein â steuerbar ĂŒber
`FACTORY_DEFAULTS_DIR`.
- Damit bringen neue VMs sofort Anbieter, Tarife, PDF-Auftragsvorlagen +
DatenschutzerklÀrung/Impressum mit, ohne manuelles UI-/CLI-Import.
- Bestehende Installs werden NIE ĂŒberschrieben (Trigger nur wenn der
Auto-Seed im selben Start-Lauf gelaufen ist).
- [x] **đŠ Factory-Defaults: HTML-Templates + Import via UI**
- DatenschutzerklÀrung, Impressum, Vollmacht-Vorlage und Website-Datenschutz
werden jetzt mit ins Factory-Defaults-ZIP gepackt (`app-settings/`-Ordner,
Whitelist-geschĂŒtzt â andere AppSetting-Keys werden ignoriert).
- Import lĂ€uft jetzt auch ĂŒber die UI (Einstellungen â Factory-Defaults â
âZIP hochladen"). Der CLI-Weg `npm run seed:defaults` bleibt erhalten und
wurde gleichermaĂen um die HTML-Templates erweitert.
- Zwei-Wege-Roundtrip live verifiziert: Export â AppSetting löschen â
Import â Wert wieder vollstĂ€ndig hergestellt; Counts in Audit-Log.
- [x] **đ Benutzer-Verwaltung: DSGVO- + Entwickler-Zugriff zuweisbar**
- Mass-Assignment-Whitelist (`pickUserUpdate`) hat `hasGdprAccess` /
`hasDeveloperAccess` rausgefiltert â Service erhielt sie nie â Rollen
DSGVO/Developer waren in der UI nicht zuweisbar (Checkbox ohne Wirkung).
- Beide Felder zur Whitelist hinzugefĂŒgt + Audit-Log liest die Pre-Werte
jetzt aus den geladenen Rollen (kein False-Positive-Change mehr).
- [x] **đ HTTPS-only-Header per Flag (`HTTPS_ENABLED`)**
- HSTS + `upgrade-insecure-requests` (CSP) sperrten den Browser bei
direktem `http://ip:port`-Zugriff aus (`ERR_SSL_PROTOCOL_ERROR`).
- Beide Header default OFF, kommen nur mit `HTTPS_ENABLED=true` (sobald
TLS-Reverse-Proxy davor steht).
- [x] **đïž Prisma-Migrations-System (statt `db push`)**
- Initial-Migration `0_init` aus aktuellem Schema generiert
(`prisma migrate diff --from-empty --to-schema-datamodel`).
- 24 alte gedriftete Migrations gelöscht â frischer Start.
- `migration_lock.toml` fĂŒr MySQL hinzugefĂŒgt.
- Container-Entrypoint umgebaut:
- Auto-Baseline-Detection: bestehende DB ohne `_prisma_migrations` â
`migrate resolve --applied 0_init` lÀuft automatisch.
- Statt `db push --accept-data-loss` jetzt `migrate deploy` (idempotent,
datenerhaltend, keine stillen DROPs mehr).
- Neuer npm-Script `schema:sync` (lokal/Dev): legt automatisch eine
versionierte Migration mit Zeitstempel-Namen an
(`prisma migrate dev --name auto_$(date +%Y%m%d_%H%M%S)`).
- Workflow ab jetzt: schema.prisma Ă€ndern â `npm run schema:sync` â
Migration committen â Push â Container-Restart wendet sie automatisch an.
- [x] **đ Automatische Vertrags-Status-ĂbergĂ€nge**
- Nightly-Cron (02:00 + Catch-up 60s nach Start): alle VertrÀge mit
`status=ACTIVE` und `endDate < heute` â `EXPIRED` (mit Audit-Log).
- Beim Upload der KĂŒndigungsbestĂ€tigung (`cancellationConfirmationPath`):
wenn Vertrag aktuell `ACTIVE` â auf `CANCELLED` setzen (Audit-Log).
Frontend fragt per Modal das BestÀtigungs-Datum ab (Default: heute),
wird direkt als `cancellationConfirmationDate` gespeichert.
Der "Optionen"-Upload löst den Status-Wechsel bewusst NICHT aus, da er
fĂŒr VertragsĂ€nderungen (nicht echte KĂŒndigungen) gedacht ist, setzt
aber `cancellationConfirmationOptionsDate` analog.
- Beim Upload einer `LieferbestÀtigung` (ContractDocument via direkt-Upload
oder Email-Anhang-Import): wenn Vertrag aktuell `DRAFT` â auf `ACTIVE`
setzen + `startDate` auf das erfasste Lieferdatum (falls leer).
Frontend zeigt Datums-Input conditional, wenn Typ "LieferbestÀtigung"
ausgewÀhlt ist.
- Keine neuen Status eingefĂŒhrt: `cancellationSentDate` vs.
`cancellationConfirmationDate` genĂŒgen, um "gesendet vs. bestĂ€tigt"
abzubilden. `ACTIVE` bleibt bis zur BestÀtigung.
- [x] **đĄïž Security-Hardening vor Production-Deployment (10 Runden)**
- VollstÀndige Story inkl. aller Live-Test-Tabellen + Trade-offs:
**[SECURITY-HARDENING.md](./SECURITY-HARDENING.md)**
- Erste 2 Runden zusĂ€tzlich ausfĂŒhrlich in
[SECURITY-REVIEW.md](./SECURITY-REVIEW.md)
- Highlights:
- Runde 1â3: CORS, Helmet, JWT-Fallback, IDOR-Welle 1, XSS, Mass
Assignment, Zip-Slip, Path-Traversal, JWT-Algorithm, Rate-Limiter
- Runde 4: 9 Live-IDORs (customer.\*/gdpr.\*) + Error-Handler
- Runde 5: `/api/uploads`-Auth (DSGVO-GAU), Login-Timing,
Privacy-Policy-XSS
- Runde 6: Customer-List-Leak, XFF-Rate-Limit-Bypass,
Self-Grant + Existence-Disclosure
- Runde 7: SSRF-Schutz (Cloud-Metadata-Block), Logout-Endpoint
- Runde 8: DNS-Rebinding-Schutz, Per-File-Ownership-Check
- Runde 9: `npm audit fix` (8 Vulns weg), Audit-Chain-Rehash, keine
neuen Critical-Findings â diminishing returns erreicht
- Runde 10: Security-Monitoring (SecurityEvent-Tabelle + Hooks an
Login/IDOR/SSRF/Reset/Logout/JWT-Reject + Threshold-Detection +
Sofort-Alert fĂŒr CRITICAL + Hourly-Digest + UI in Einstellungen)
- Deployment-Checkliste komplett (in HARDENING.md)
- [x] **đ Version 1.0.0 Feinschliff: Passwort-Reset + Rate-Limiting + Auto-GeburtstagsgrĂŒĂe**
- **Passwort vergessen-Flow** (Login â "Passwort vergessen?" Link)
- Email-Reset-Token mit 2h GĂŒltigkeit (kryptografisch sicher: 32 Byte Random)
- Funktioniert fĂŒr Mitarbeiter UND Portal-Kunden (Typ-Auswahl)
- User-Enumeration-Schutz: immer 200 OK, egal ob Email existiert
- Reset-Link per Email mit schönem HTML-Template
- Nach Reset: alle bestehenden Sessions werden gekickt
- **Rate-Limiting** gegen Brute-Force
- Login: 10 Versuche pro 15 Min pro IP (erfolgreiche zÀhlen nicht)
- Passwort-Reset-Anfrage: 5 Versuche pro Stunde pro IP
- **Cron-Job fĂŒr automatische GeburtstagsgrĂŒĂe**
- TĂ€glich 08:00 Uhr: alle Kunden mit heutigem Geburtstag + autoBirthdayGreeting=true
- Email-Versand ĂŒber System-E-Mail, Du/Sie-abhĂ€ngiger Text
- Catch-up 30s nach Server-Start (falls Server am Geburtstag kurz down war)
- Marker lastBirthdayGreetingYear verhindert Doppel-Versand
- [x] **MandantenfÀhigkeit: Domain + Kunden-E-Mail-Label dynamisch pro Provider**
- Neues Feld `customerEmailLabel` am EmailProviderConfig (z.B. "Stressfrei-Wechseln", "Meine-Firma")
- Wenn leer, wird das Label automatisch aus der Domain abgeleitet ("stressfrei-wechseln.de" â "Stressfrei-Wechseln")
- Neuer Frontend-Hook `useProviderSettings()` liefert Domain + Label
- Alle hardcoded "Stressfrei-Wechseln" und `@stressfrei-wechseln.de` Strings durch dynamische Werte ersetzt
(CustomerDetail, ContractForm, ContractDetail, EmailClientTab, Settings)
- Modal-Eingabefeld "Bezeichnung fĂŒr Kunden-E-Mails" in Provider-Einstellungen
- Notwendig fĂŒr Multi-Mandanten-Betrieb wenn das CRM an Dritte vermietet wird
- [x] **Factory-Defaults: Export + Import von Stammdaten-Katalogen**
- EnthĂ€lt: Anbieter, Tarife, KĂŒndigungsfristen, Laufzeiten, Vertragskategorien, PDF-Auftragsvorlagen (+ PDF-Dateien)
- EnthĂ€lt NICHT: Kundendaten, VertrĂ€ge, Dokumente, Emails, Einstellungen (dafĂŒr gibt es den Datenbank-Backup)
- Neue Einstellungsseite âFactory-Defaults" mit Ăbersicht (Anzahl pro Kategorie) und Export-Button
- Export: ZIP mit manifest.json + Kategorie-JSONs + PDF-Dateien, Download ĂŒber Browser
- Import-Script: `npm run seed:defaults` liest `backend/factory-defaults/`, merged mehrere JSONs pro Kategorie, upsertet idempotent + kopiert PDFs in uploads/
- Ordner `backend/factory-defaults/` gitignoriert (auĂer .gitkeep + README), damit firmen-spezifische Kataloge nicht ins Repo kommen
- [x] **Email-AnhĂ€nge â Vertragsdokumente + Rechnungen fĂŒr alle Vertragstypen**
- Im SaveAttachmentModal (bei einem per Email zugeordneten Vertrag) gibt es jetzt drei Modi:
1. **Als Dokument** (in feste Slots wie KĂŒndigungsschreiben) â wie bisher
2. **Als Vertragsdokument** â neu, mit Typ-Dropdown (Auftragsformular, LieferbestĂ€tigung, Vertragsunterlagen, Vollmacht, Widerrufsbelehrung, Preisblatt, Sonstiges) + Notizen
3. **Als Rechnung** â jetzt fĂŒr **alle** Vertragstypen (vorher nur Strom/Gas)
- Gleiches gilt fĂŒr das Speichern der gesamten Email als PDF-Rechnung
- Neuer Backend-Endpoint `saveAttachmentAsContractDocument` fĂŒr die flexible ContractDocument-Tabelle
- [x] **Geburtstag-Management-Modal in Kundenstammdaten**
- Neuer Button (Cake-Icon) neben Geburtsdatum öffnet Modal
- **GruĂ zurĂŒcksetzen:** setzt `lastBirthdayGreetingYear` auf null zurĂŒck (fĂŒrs Debugging + Fallback)
- **GruĂ jetzt senden:** per Email (direkt), WhatsApp/Telegram/Signal (öffnet vorbefĂŒlltes Fenster)
- Beide Aktionen mit Ja/Nein-BestÀtigungsdialog (kein versehentliches Klicken)
- Text respektiert Du/Sie-Einstellung des Kunden
- Checkbox "Automatisch senden" mit Kanal-Dropdown (neue Felder am Customer)
- Audit-Log fĂŒr Reset + Send
- [x] **Anrede-VerhÀltnis Du/Sie pro Kunde**
- Neues Feld `useInformalAddress` in Stammdaten (auch bei Firmenkunden)
- Default: Sie (formell)
- GeburtstagsgruĂ im Portal nutzt die Anrede: "Du"-Kunden bekommen "Herzlichen GlĂŒckwunsch, Max!", "Sie"-Kunden "Herzlichen GlĂŒckwunsch, Herr MĂŒller!"
- Komplett konsistent auch bei nachtrĂ€glichen GlĂŒckwĂŒnschen ("hattest" vs "hatten")
- [x] **Geburtsdatum + Geburtsort auch bei Firmenkunden**
- Felder werden jetzt unabhÀngig vom Kundentyp angezeigt
- Ermöglicht z.B. Geburtstage fĂŒr Ansprechpartner bei Firmen
- [x] **Geburtstagskalender + GeburtstagsgruĂ-Modal**
- Admin: Section im Vertrags-Cockpit mit Kunden, die in den nÀchsten 30 Tagen oder letzten 7 Tagen Geburtstag haben
- Portal: Modal mit GruĂ am Geburtstag (inkl. nachtrĂ€glichem GlĂŒckwunsch bis 7 Tage danach)
- Wird pro Jahr nur einmal angezeigt
- [x] **Typspezifische Zusatzinfos in Vertragslisten**
- Strom/Gas â "Lieferadresse: ..."
- DSL/Glasfaser/Kabel â "Anschlussadresse: ..."
- Mobilfunk â "Rufnummer: ..."
- KFZ â "Kennzeichen: ..."
- Sichtbar in Admin-Liste, Portal-Liste und Kunden-Tab
- [x] **DatenschutzerklĂ€rung PDF â Online-Einwilligungen synchronisieren**
- PDF hochgeladen â alle 4 Consents auf GRANTED
- Haken entfernt im Portal â PDF löschen + Tabs sperren
- Entsperrung nur durch alle Haken oder neues PDF
- [x] **Zweitarif-ZĂ€hler (HT/NT)** bei Strom + Verbrauchsberechnung
- [x] **Datumsformate vereinheitlichen** (01.01.2026 statt 1.1.2026)
- [x] **Audit-Log aussagekrĂ€ftig** (Vorher/Nachher bei allen Ănderungen)
- [x] **Impressum + Website-DatenschutzerklÀrung** im Kundenportal
- Editor in Einstellungen
- Vorschlagstexte
- [x] **Consent-BestÀtigungs-Flow per Email**
- Alle Hebel mĂŒssen gesetzt sein
- BestÀtigungsbutton + BestÀtigungsemail
- [x] **Vertragsdokumente-Upload** (Auftragsformular, LieferbestÀtigung, Vertragsunterlagen als PDF/PNG)
- [x] **Bug: Stressfrei-Email im Auftragsgenerator** (funktioniert jetzt im Vertrag)
- [x] **PDF-Auftragsvorlagen-System**
- Template-Editor in Einstellungen
- PDF hochladen, Formularfelder automatisch auslesen
- CRM-Felder zuordnen (visuell mit Vorschau)
- Seitenweise Sortierung der Felder
- Dynamische Rufnummern-Felder mit Vorwahl-Extraktion
- Nicht zugeordnete Felder bleiben editierbar
- Auftrag generieren aus Vertragsdaten (Button im Vertrags-Detail)
- [x] **EigentĂŒmer-Verwaltung**
- An Adresse gehÀngt (Firma, Vorname, Nachname, Anschrift, Kontakt)
- Fallback auf Kundendaten wenn leer
- Nur bei Liefer-/Meldeadressen (nicht Rechnung)
- Namens-Kombinationen (Firma + Vorname + Nachname etc.)
- [x] **Gruppenauswahl Liefer-/Rechnungs-/EigentĂŒmer-Adresse** im Auftragsgenerator
- [x] **Objekttyp + Lage + Lage des Anschlusses** bei Festnetz-VertrÀgen (DSL/Glasfaser/Kabel)
- [x] **Bankverbindung-Fallback** im PDF-Generator (neueste aktive Bankverbindung des Kunden)