Commit Graph

122 Commits

Author SHA1 Message Date
duffyduck 523eab30d5 JpgToPdfModal: Bilder auf 2400px runterskalieren
Stage: 2 Handy-JPGs → 23 MB PDF. Smartphone-Fotos haben
4000-6000 px Kante, das macht auch ohne Re-Encode 5-10 MB pro
Bild → PDF wird riesig.

Beim Hinzufügen werden Bilder jetzt auf max 2400 px lange Kante
runterskaliert (~290 DPI auf A4 = Druckqualität) und als JPEG mit
Quality 0.92 (Lightroom-Default) persistiert. Vorschau, Rotation/
Flip und PDF-Embed laufen alle auf dem skalierten Bild.

Erwartete Größe: 2 Handy-Fotos ≈ 1-2 MB PDF.
2026-06-03 18:29:04 +02:00
duffyduck 2fee13d09e EmailDetail: ExternalLink-Icon beim "Zugeordnet zu"-Badge
Klick auf die Vertragsnummer öffnet weiterhin im selben Tab
(via React-Router Link). Neues Icon daneben öffnet den Vertrag in
einem neuen Browser-Tab – analog zum Pro-Tab-Link in CustomerDetail.
2026-06-03 18:20:10 +02:00
duffyduck 84cbf01706 Kunden-Tabs: ExternalLink-Icon neben jedem Reiter
Tabs-Komponente bekommt optionalen tabHrefBuilder(tabId)-Prop.
Wenn gesetzt, erscheint neben jedem Tab-Label ein kleines
ExternalLink-Icon, das den Tab via ?tab=<id> in einem neuen
Browser-Tab öffnet.

CustomerDetail übergibt den Builder. URL-Param wird eh schon
für den Tab-Sync genutzt – Anhängen reicht.

Click-stopPropagation verhindert, dass der Klick auf das Icon
gleichzeitig den Tab-Wechsel auslöst.
2026-06-03 18:15:23 +02:00
duffyduck fcc3b04725 Vertrag: Kunden-/Vertragsnummer bei Vertriebsplattform
Viele Vertriebsplattformen vergeben eigene Nummern, die nicht mit
denen des Endanbieters identisch sind. Zwei neue optionale Felder
unter "Anbieter & Tarif".

- Schema: Contract.customerNumberAtSalesPlatform +
  contractNumberAtSalesPlatform, Migration mit IF NOT EXISTS.
- ContractForm: zwei neue Inputs direkt unter den entsprechenden
  Provider-Feldern.
- ContractDetail: eigene Zeilen mit CopyButton.
- Audit-Log-Mapping + Renewal-Copy + XSS-Strip-Whitelist mitgezogen.
- Bonus: contractNumberAtProvider war im Renewal-Copy und Audit-
  Label-Mapping fehlend – mitkorrigiert.
2026-06-03 18:13:17 +02:00
duffyduck 101369c205 EmailDetail: Links immer im neuen Tab öffnen
Nach DOMPurify-Sanitize alle <a>-Elemente auf target="_blank" +
rel="noopener noreferrer" setzen. Letzteres verhindert
window.opener-Tab-Hijacking. Sanitize + DOM-Walk in useMemo, läuft
nur bei Wechsel der Email neu.
2026-06-03 18:06:19 +02:00
duffyduck 7c18343a95 Bugfixes: Adresse-Modal + Upload-Limit auf 25 MB
1. AddressModal: Straße-Feld ließ sich nicht editieren. setFormData
   wurde im Render-Body aufgerufen, wenn formData.street !==
   address.street → Reset bei jedem Tastendruck. In useEffect mit
   [address?.id]-Dependency umgezogen.

2. Multer-Limit von 10 MB auf 25 MB in upload.routes.ts,
   gdpr.routes.ts, contract.routes.ts. Zwei Handy-Fotos zu PDF
   kratzten am alten Limit. FileUpload-Hinweistext angepasst.
2026-06-03 16:37:09 +02:00
duffyduck 5508d59652 SIM-Karten: Checkbox "eSIM" zwischen Hauptkarte und Multisim
Hardware-Plastikkarte vs. eSIM-Profil ist eigene Eigenschaft – eSIM
kann sowohl Hauptkarte als auch Multisim sein, deshalb dritter
Toggle statt entweder/oder.

- Schema: SimCard.isEsim Boolean default false, Migration mit
  IF NOT EXISTS.
- Backend: vier SimCard-Schreibpfade in contract.service.ts (Create,
  Update, Follow-Up, Renewal).
- UI: dritte Checkbox in ContractForm zwischen Hauptkarte und
  Multisim. ContractDetail zeigt blauen eSIM-Badge.
2026-06-03 16:13:24 +02:00
duffyduck 431792e8d9 JpgToPdfModal: PDF-Größe massiv reduziert
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 % ≠ "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), Original-
DataURL 1:1 in die PDF einbetten – kein Canvas-Roundtrip, keine
Quality-Aufblähung. 2-MB-JPEG bleibt 2 MB. Format-Detection per
data:image/png-Prefix (PNG vs JPEG).

Fix 2: Bei Transformation toDataURL('image/jpeg', 0.95) statt 1.0.
Visuell identisch für Foto-Inhalte, 50-70 % kleiner.

Kombiniert: 2 untransformierte Handy-Fotos ≈ 4 MB PDF (vorher
16-30 MB), 2 gedrehte ≈ 5-8 MB.
2026-06-03 16:06:05 +02:00
duffyduck ec577e6d76 Pentest 68.1 (LOW) + 68.2 (INFO): PDF-Active-Content-Filter + Modal-Limit
68.1: Magic-Byte-Check prüfte nur %PDF-. PDFs mit /JavaScript, /JS,
/Launch, /EmbeddedFile, /RichMedia (Flash) kamen durch und wurden
inline ausgeliefert – Browser-Viewer ignorieren JS, Adobe Acrobat
nicht.

- Neuer Helper assertSafePdf(buf) in utils/sanitize.ts mit
  case-sensitivem String-Scan auf die fünf Action-Patterns
  (\b-Word-Boundary verhindert False-Positives bei /JSXForm etc.).
- Neue Middleware pdfUploadSafety.ts mit zwei Varianten:
  requireSafeUploadedPdf (PDF-only) und scanUploadedPdfIfPresent
  (durchwinkt JPG/PNG, scannt nur PDFs).
- Eingehängt in: upload.routes (Magic-Byte-Validator erweitert),
  gdpr.routes Vollmacht-Upload, pdfTemplate.routes Template-Upload,
  contract.routes Vertragsdokumente, cachedEmail.controller
  (saveAttachmentTo, saveAttachmentAsInvoice,
  saveAttachmentAsContractDocument).
- Inline-Vorschau bleibt – Pentester-Empfehlung "disposition=inline
  abschalten" wurde bewusst nicht umgesetzt (löst Acrobat-Risiko
  nicht, bricht aber ~20 UI-Stellen).
- Smoke-Test: 5 Payload-Typen abgelehnt, clean PDF + Non-PDF + JSXForm
  durchgewinkt.

68.2: JpgToPdfModal-Self-DoS – MAX_IMAGES=50, MAX_IMAGE_BYTES=25MB.
2026-06-03 13:18:23 +02:00
duffyduck 30f528596c JPGs → PDF: neuer Button überall bei PDF-Upload
- Neue Komponente JpgToPdfModal (jsPDF clientseitig, kein Backend-Roundtrip).
- Bilder hinzufügen per Klick, Drag&Drop oder Strg+V (Clipboard).
- Reihenfolge per Drag&Drop sortierbar; pro Bild 90°/180°-Drehung +
  horizontal/vertikal-Spiegelung.
- Jedes Bild = eine A4-Seite, Orientation automatisch nach Bild,
  JPEG-Qualität 100%.
- FileUpload-Komponente zeigt den Sekundär-Button automatisch, sobald
  accept PDF einschließt (Datenschutz, Vollmacht, Bankkarten, Ausweise,
  Gewerbeanmeldung, Handelsregister, Kündigungsschreiben/-bestätigung
  + jeweilige Optionen).
- Direktinputs ebenfalls erweitert: Vertragsdokumente (ContractDetail),
  Vollmacht-Tab (CustomerDetail), Rechnungen (InvoicesSection).
- PdfTemplates bewusst ausgenommen – braucht AcroForm-Felder.
2026-06-03 12:27:37 +02:00
duffyduck 0f4ffe3c32 E-Mail als PDF speichern: Tab "Vertragsdokument" ergänzt
Bisher hatte das "E-Mail als PDF speichern"-Modal nur die Tabs
"Als Dokument" + "Als Rechnung" (nur Energieverträge). Wenn die
E-Mail einem Vertrag zugeordnet ist, fehlte die Möglichkeit, sie
direkt als Vertragsdokument (Auftragsformular, Lieferbestätigung
etc.) zu hinterlegen – analog zum Anhang-Modal.

Backend: neuer Endpoint POST /api/emails/:id/save-as-contract-document
{ documentType, notes?, deliveryDate? } – generiert das Mail-PDF,
speichert es unter /uploads/contract-documents und legt einen
ContractDocument-Eintrag an. Bei documentType "Lieferbestätigung"
wird der bestehende maybeActivateOnDeliveryConfirmation-Workflow
getriggert (DRAFT → ACTIVE, startDate-Übernahme).

Frontend: SaveEmailAsPdfModal bekommt den dritten Tab parallel zu
SaveAttachmentModal. Tab erscheint, sobald die E-Mail einem Vertrag
zugeordnet ist (auch bei Nicht-Energieverträgen); Tab "Als Rechnung"
bleibt auf Energieverträge beschränkt. Dokumenttyp-Dropdown und
Notizen-Feld werden aus dem Anhang-Modal übernommen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 19:15:23 +02:00
duffyduck 61daff8df9 Rufnummern: Vorwahl als eigenes Feld – verlässliche PDF-Befüllung
Bisher steht in PhoneNumber.phoneNumber die kombinierte Nummer
("04264 836975"). Die Wechselauftrag-PDFs splittten heuristisch
auf Vorwahl/Anschluss, was bei Sonderformaten daneben ging.

Schema: PhoneNumber.areaCode String? (optional, Bestandsdaten
werden beim nächsten Edit nachgepflegt). Migration
20260601200000_phone_area_code mit IF NOT EXISTS.

ContractForm: aus "Rufnummer" werden zwei Felder – "Vorwahl" und
"Rufnummer". Beim Speichern sendet das Frontend areaCode separat
UND die kombinierte phoneNumber (für Listen/Suchen weiter
unverändert). Beim Edit-Load wird areaCode bevorzugt; falls leer,
splittet die UI heuristisch und prefillt beides – User kann
korrigieren und beim Speichern wird der saubere Wert persistiert.

PDF-Template-Service: phoneAreaCode[N] und phoneLocal[N]
verwenden jetzt primär den gespeicherten areaCode aus der DB
(verlässlich), Heuristik nur als Fallback für Altbestand. Die
Template-Variablen-Liste war bereits korrekt definiert, jetzt
ist die Datenquelle solide.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 14:10:25 +02:00
duffyduck 57eb29c2a6 Pentest 49.1 LOW: Re-Auth jetzt auf JEDE portalUrl-Änderung
Bisher prüfte der Re-Auth-Trigger nur den Host – `https://1und1.de/foo`
→ `https://1und1.de/phishing/path` ging ohne currentPassword durch.
Damit konnte ein gestohlener JWT Phishing-Pfade auf trusted Domains
plazieren.

Backend (provider.controller): normalizeUrlForCompare vergleicht
jetzt die komplette URL (Trailing-Slash, Whitespace, Case),
nicht nur den Host. hostOf-Helper entfernt.

Frontend (ProviderModal): gleiche Normalisierung im UI, damit der
Bestätigungs-Banner mit der Backend-Prüfung synchron läuft.
Banner-Text leicht angepasst (nicht mehr "Domain wurde geändert"
sondern generisch "Portal-URL wurde geändert").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 13:46:56 +02:00
duffyduck 2c0166ed99 Pentest 47.1/47.2/47.3: Re-Auth bei sensiblen Operationen + Provider.name-Strip
47.3 MEDIUM (Admin-Passwort-Reset ohne Re-Auth):
POST /api/users/:id/password verlangt jetzt currentPassword im
Body. Backend prüft per bcrypt.compare gegen den Hash des
aufrufenden Admins. Frontend (UserList-Modal): zusätzliches
Passwort-Feld wird eingeblendet, sobald für einen User ein neues
Passwort gesetzt werden soll. Gestohlener JWT allein reicht damit
nicht mehr.

47.1 MEDIUM (Open Redirect / Phishing via provider.portalUrl):
Selbes Re-Auth-Pattern für Provider-Endpoints. Nur wenn die
Portal-URL-Domain WIRKLICH gewechselt wird (Host-Vergleich)
oder beim Create mit URL, ist currentPassword Pflicht. Reine
Namens-/Tarif-Edits bleiben friction-frei.
Audit-Log bekommt die Portal-URL beim Ändern explizit mitgeloggt
(Forensik bei Vorfällen). Frontend ProviderModal zeigt amber-
farbenen Bestätigungs-Banner mit Passwort-Eingabe sobald der
Host wechselt.

47.2 INFO (provider.name ohne Backend-Sanitization):
Neuer Helper stripProviderStrings in provider.service, wendet
stripHtml auf name + usernameFieldName + passwordFieldName an –
Defense-in-Depth gegen neue Renderpfade (PDF, Mail-Templates).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 12:38:45 +02:00
duffyduck d0d2715baa Pentest 46.1 HIGH + Info-Konsolidierung: zentrale URL-Validierung
46.1 HIGH (Stored XSS via provider.portalUrl): PUT /api/providers/:id
nahm `javascript:alert(...)` als portalUrl ohne Validierung an, das
Portal rendert es als <a href={portalUrl}> → Klick im Kunden-Browser
löste XSS aus.

Fix: neuer zentraler Helper backend/utils/url.validateHttpUrl
- erlaubt nur http(s)-Schemas (sperrt javascript:, data:, file:,
  vbscript:, blob: usw.)
- erfordert absoluten URL mit Host
- per Default keine privaten/Loopback-Hosts (über
  isPrivateOrBlockedHost), weil der Wert Endkunden gezeigt wird
- Trailing-Slash wird gestrippt

Eingebaut in:
- provider.service createProvider + updateProvider (HIGH-Fix)
- appSetting.service validateSettingValue für portalLoginUrl
  (Refactor der bestehenden ad-hoc Validierung → konsolidiert)

Defense-in-depth Frontend: frontend/utils/url.safeHttpUrl liefert
URLs nur zurück wenn http(s), sonst undefined. Eingesetzt in
ContractDetail bei Portal-Link-Rendering und Auto-Login, damit
Alt-Daten in der DB (vor diesem Fix angelegt) nicht klickbar
bleiben.

INFO-Konsolidierung: damit ist die Schema-/Host-Validierung
einheitlich an einer Stelle. Sanitize-Layer (stripHtml in
sanitize.ts) bleibt für reine Text-Felder zuständig.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 11:48:14 +02:00
duffyduck 9519f0dbca EmailProviders-Settings: Input "Bezeichnung im UI" für customerEmailLabel
Das customerEmailLabel-Feld existierte im Backend (samt Update-Logik
und Public-Endpoint), war aber im UI nicht erreichbar – das Label
wurde immer nur aus der Domain abgeleitet.

Neuer optionaler Input "Bezeichnung im UI" unter dem Domain-Hinweis.
Leer = automatisch aus Domain ableiten (bisheriges Verhalten),
ausgefüllt = überschreibt die Ableitung (z.B. "interne Kunden
Email Adressen" als Tab-Label).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 08:37:28 +02:00
duffyduck cd7075e96f Quicklinks auch im "Kein Postfach"-Zustand der E-Mails-Card anzeigen
Der "Stressfrei wechseln Adressen"-Link (sowie "Postfach öffnen")
war nur im Normal-Zweig sichtbar, nicht aber wenn der Kunde noch
gar kein Mailbox-Konto hat. cardTitle in einer gemeinsamen Variable
extrahiert und in beiden Branches verwendet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 08:23:40 +02:00
duffyduck 4acfd9de1c SIM-Karten: Feld "Kartennutzer" für Firmen-/Familienverträge
Bei Firmenverträgen (Vertragsinhaber = Firma, Nutzer = Mitarbeiter)
und Familienverträgen (Inhaber = Eltern, Nutzer = Kind) brauchten
wir ein Feld, das den tatsächlichen Nutzer der SIM-Karte erfasst.

Backend: SimCard.cardUser (String?, optional), Migration
20260601100000_sim_card_user mit IF NOT EXISTS. Im Service durch
Create + Update propagiert.

Frontend: Input "Kartennutzer" pro SIM-Karte in ContractForm
(eigene Zeile oberhalb der technischen Felder Rufnummer/SIM-Nr/
PIN/PUK). In ContractDetail wird der Nutzer als "Nutzer: <Name>"
neben den Hauptkarte/Multisim-Badges angezeigt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 08:10:16 +02:00
duffyduck 9e3bce85f0 Vorgängervertrag-Modal: Kundennr./Vertragsnr. beim Anbieter auch ohne Provider/Tarif anzeigen
Die "Anbieter & Tarif"-Card war nur sichtbar, wenn provider oder
tariff gesetzt waren. Bei Entwürfen ohne Anbieter wurden dadurch
auch customerNumberAtProvider + contractNumberAtProvider versteckt,
obwohl sie pflegbar sind und für den Wechsel-Workflow wichtig sind.
Fix: Card-Sichtbarkeitsbedingung um die beiden Felder erweitert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 07:51:36 +02:00
duffyduck 4fb700cf57 Vertrags-Forms: Mini-Links zu Stammdaten in neuem Tab
ContractEmailsSection (Vertragsansicht): Zusätzlich zu "Postfach
öffnen" gibt es jetzt "Stressfrei wechseln Adressen" → Tab in der
Kundenakte.

ContractForm (Bearbeiten): Kleine ExternalLink-Icons neben den
Select-Labels:
- Lieferadresse + Rechnungsadresse → Kundenakte/Adressen
- Bankkarte → Kundenakte/Bankkarten
- Ausweis → Kundenakte/Ausweise
- Anbieter + Tarif → Settings/Anbieter & Tarife
- Vertriebsplattform → Settings/Vertriebsplattformen

Select-Komponente nimmt jetzt ReactNode als label (statt nur string),
um JSX-Labels mit eingebettetem Link zu erlauben. Rückwärts-
kompatibel zu allen bestehenden String-Aufrufen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 07:47:40 +02:00
duffyduck 5269092d2a Fix: "Wurde sondergekündigt?"-Label nicht über volle Spaltenbreite klickbar
Label-Klasse war flex -> Block-Layout, das die ganze col-span-2-Zeile
einnimmt. Klicks rechts neben dem Text triggern dann ebenfalls die
Checkbox. Fix: inline-flex – die Label-Box passt sich an den Inhalt
(Checkbox + Text) an.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 12:49:37 +02:00
duffyduck a20e331f83 Strom/Gas-Details: "Zähler verwalten"-Link neben Card-Titel
Zusätzlich zum bestehenden Link im Folgezähler-Form bekommt auch
der Card-Header der Strom/Gas-Details einen Link "Zähler verwalten",
der die Zähler-Übersicht des Kunden in einem neuen Tab öffnet –
damit der Link immer sichtbar ist, nicht nur wenn die Folgezähler-
Form aufgeklappt ist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 15:11:01 +02:00
duffyduck 43aaf697a1 Fix: Multi-Meter-Verbrauch auf Vertragslaufzeit clampen
Bei Verträgen, die Vorgänger einer Folgevertrags-Kette sind, sind
über ContractMeter auch Folgezähler verknüpft, 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 dieses
Vertrags ein.

Fix: meterStart = max(installedAt, contractStart),
meterEnd = min(removedAt, contractEnd). Zähler komplett außerhalb
der Laufzeit werden übersprungen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 15:07:19 +02:00
duffyduck b0e45c0ea0 Fix: "Zähler ohne Verträge anzeigen" filtert auf orphans, nicht additiv
Die Checkbox war falsch implementiert (additiv: zeigt auch Orphans).
Soll laut User filternd wirken: gecheckt = nur Zähler ohne Vertrag.

Logik:
- beide aus: alle aktiven Zähler (Default)
- nur "Inaktive": alle Zähler (aktiv + inaktiv)
- nur "ohne Verträge": aktive Zähler OHNE Vertrag
- beide an: alle Zähler ohne Vertrag (aktiv + inaktiv)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 15:00:21 +02:00
duffyduck 0d024b94c2 Kundenakte → Zähler: Checkbox "Zähler ohne Verträge anzeigen"
Standardmäßig werden nur Zähler angezeigt, die mindestens einem
Vertrag zugeordnet sind (entweder als Hauptzähler oder über die
Folgezähler-Kette). Mit der neuen Checkbox lassen sich auch
verwaiste Zähler ins Listing holen – nützlich beim Aufräumen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 14:50:24 +02:00
duffyduck b4b0dbb004 Kundenakte → Zähler: Aufklapp-Liste der zugeordneten Verträge
Pro Zähler wird jetzt ein "Verträge (N)" Aufklapp-Bereich angezeigt,
der alle Verträge auflistet, die diesen Zähler nutzen – sowohl als
aktueller Hauptzähler (energyDetails.meterId) als auch über die
Folgezähler-Kette (ContractMeter). Dedupliziert auf contractId.

Jeder Eintrag ist Link auf den Vertrag im neuen Tab, mit
Vertragsnummer, Anbieter und Status-Badge. Folgezähler-Ketten-
Einträge werden mit "(über Folgezähler-Kette)" markiert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 14:48:22 +02:00
duffyduck 2ee06630b9 Folgezähler-Forms: Checkbox "Alten Zähler deaktivieren" (default an)
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).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 14:39:05 +02:00
duffyduck 3a9cece929 Vertragshistorie: Vertragsnummern als Link in neuem Tab
Erwähnte Vertragsnummern (Pattern PREFIX-RANDOM) in Title und
Description werden gegen previousContract + followUpContract des
aktuellen Vertrags aufgelöst und als Link mit target="_blank"
gerendert. Nicht aufgelöste Nummern bleiben als Text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 14:33:11 +02:00
duffyduck e527aebb84 Vorvertrag-Verbrauch als Schätzwert im Folgevertrag
ContractForm (Strom/Gas): Wenn ein previousContractId gesetzt ist,
wird der Vorvertrag samt Readings nachgeladen, der Verbrauch
clientseitig berechnet und als "Vorvertrag: X kWh [Übernehmen]"
unter dem Jahresverbrauch-Feld angezeigt. Bei Gas auch unter
"Jahresverbrauch (kWh)".

ContractDetail (Strom/Gas): Wenn annualConsumption leer ist und
ein berechenbarer Vorvertrag existiert, wird "~X kWh, geschätzt
aus Vorvertrag" in der Jahresverbrauch-Zelle angezeigt – damit
der Wert beim Lesen schon als Anhaltspunkt da steht.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 14:28:55 +02:00
duffyduck 13213846f4 Folgezähler-Form: Link "Zähler verwalten" → Kundenakte (neuer Tab)
Wenn im Vertrag kein passender Zähler im Dropdown auftaucht, kann
der User mit einem Klick die Zähler-Übersicht des Kunden in einem
neuen Tab öffnen, um dort einen neuen Zähler anzulegen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 14:15:40 +02:00
duffyduck 61ce35821d Endstand alter Zähler fließt in Verbrauchsberechnung ein
Bisher wurde "Letzter Stand alter Zähler" zwar in
ContractMeter.finalReading gespeichert, aber nirgends ausgewertet.

Neuer Helper recordPredecessorFinalReading legt am Wechseldatum
einen regulären MeterReading-Eintrag für den Vorgänger an
(idempotent, mit Validierung gegen vorhandene Stände). Aufgerufen
aus addSuccessorMeter (Vertragsansicht) und createMeter mit
successorOf (Kundenakte).

Folge: Der Endstand erscheint in der Zählerstände-Liste des alten
Zählers und fließt automatisch über calculateMultiMeterConsumption
in den Verbrauch (Zeitraum bis removedAt ist inklusive).

UI-Hinweise in beiden Folgezähler-Forms erklären den Effekt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 14:14:03 +02:00
duffyduck 34e106f253 Fix: Folgezähler-Button auch bei Single-Meter-Verträgen anzeigen
Bei Folgeverträgen / Bestandsverträgen ohne ContractMeter-Eintrag
war der "Folgezähler hinzufügen"-Button unsichtbar, weil er nur
im Multi-Meter-Zweig gerendert wurde.

Zusätzlich im addSuccessorMeter-Backend: bei Single-Meter-Verträgen
wird der bisherige energyDetails.meterId jetzt als ContractMeter
position 0 backfillt und als removed markiert, damit die Kette
lückenlos ist und der alte Zähler im Vertrag dokumentiert bleibt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 14:03:01 +02:00
duffyduck ad4c2bae1d Folgezähler-Deklaration in der Kundenakte (Auto-Propagation)
- Meter.predecessorMeterId (Self-Relation) + Migration
  20260530140000_meter_predecessor mit IF NOT EXISTS
- createMeter akzeptiert optional successorOf:
  {predecessorMeterId, installedAt?, finalReadingPrevious?}.
  Vorgänger wird validiert (gleicher Kunde + Typ); alle Verträge
  mit dem Vorgänger als aktuellen Zähler werden analog zu
  addSuccessorMeter automatisch auf den neuen Zähler umgestellt
  (ContractMeter-Eintrag mit removedAt/finalReading für den
  Vorgänger, neuer ContractMeter mit installedAt + nächster
  Position, energyDetails.meterId aktualisiert)
- MeterModal: Checkbox "Als Folgezähler deklarieren" + Dropdown
  Vorgänger + Wechseldatum + Endstand. Typ/Tarifmodell/Adresse
  werden vom Vorgänger übernommen und disabled. Info-Banner über
  Vertragsauto-Update

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 13:48:23 +02:00
duffyduck 6f378d750c Vertragsansicht: Standort + Inaktiv-Badge beim Zähler anzeigen
In der Zähler-Zelle der Strom/Gas-Details wird jetzt zusätzlich
"Inaktiv" (rot) neben der Zählernummer angezeigt und der
Standort als kleine Zeile darunter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 13:32:12 +02:00
duffyduck c099b41796 Zähler → Lieferadresse-Pflichtfeld + Vertragsfilter
- Meter.addressId (FK → Address, ON DELETE SET NULL) + Migration
  20260530100000_meter_address mit IF NOT EXISTS
- Service erzwingt beim Create: Lieferadresse vorhanden + zum
  Kunden gehörig + Typ DELIVERY_RESIDENCE
- MeterModal: Pflicht-Dropdown "Lieferadresse"; Save disabled
  ohne Adresse; Hinweis-Banner. Bestandszähler ohne Adresse zeigen
  "nicht zugeordnet – bitte über Bearbeiten nachpflegen"
- ContractForm: Zähler-Dropdown filtert auf Vertrags-Lieferadresse;
  deaktivierte Zähler bleiben sichtbar mit "(deaktiviert)"; bei
  Auswahl Toast-Warnung wegen möglichem Altvertrag

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 13:18:24 +02:00
duffyduck d92d0b1eaf Vertrags-Mail-Card: Link "Postfach öffnen" → Kunden-Postfach in neuem Tab
Neben dem "E-Mails"-Titel der Card im Vertragsdetail jetzt ein
kleiner Link mit ExternalLink-Icon, der den Tab "E-Mail-Postfach"
in der Kundenakte in einem neuen Tab öffnet (target="_blank",
rel="noopener noreferrer"). Greift auf das bereits unterstützte
?tab=emails-Deep-Link-Pattern in CustomerDetail zurück.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 12:23:29 +02:00
duffyduck 0bd2f9be7e fix: "Anzeigen"-Buttons öffnen Datei wieder im Browser-Tab
Folge-Symptom des Pen-30.13-Fixes: alle file-downloads liefen mit
Content-Disposition: attachment – das ist gegen Stored-XSS richtig,
hat aber die "Anzeigen"-Buttons (Bankkarten / Ausweise /
Verträge / etc.) kaputtgemacht, weil der Browser jetzt
herunterlud statt im Tab zu öffnen.

Magic-Byte-basierter Whitelist-Pfad eingebaut: optional ?disposition=
inline am Download-Endpoint, ABER nur wenn die ersten Bytes der
Datei das Magic eines safe Typs zeigen (PDF, PNG, JPEG, GIF, WebP).
Bei Mismatch fällt's auf attachment zurück – Stored-XSS bleibt
weiterhin unmöglich, falls jemand HTML als .pdf hochlädt.

Frontend: neuer viewUrl(path)-Alias = fileUrl(path, {inline: true}).
Alle Stellen mit `<a href={fileUrl(...)} target="_blank">` oder
`window.open(fileUrl(...), '_blank')` (13 Stellen über CustomerDetail,
ContractDetail, PdfTemplates, GDPRDashboard, InvoicesSection)
nutzen jetzt viewUrl. Download-Stellen bleiben fileUrl
(= attachment, byte-genaues File-Save).

Live-verifiziert auf dev:
- ohne Param: attachment (default, Stored-XSS-Schutz)
- ?disposition=inline + echte PDF: inline + application/pdf
- ?disposition=inline + HTML als .pdf: attachment (Magic-Mismatch
  → Browser lädt herunter statt zu rendern)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 09:19:04 +02:00
duffyduck 6a670df1c4 fix: 2x Portal-Bugs (Vertragsauswahl + Email-Sync)
Bug 1 — Support-Anfrage: ausgewaehlter Vertrag nicht erkennbar
Im Kundenportal beim Erstellen einer Support-Anfrage war der
Selected-State des Vertrags nur ein dezenter blau-grauer
Hintergrund + Border-Farbwechsel. Auf hellem Bildschirm / nicht-
perfekter Lichtsituation kaum zu sehen.

Fix: kraefigere Markierung mit linkem 4px-Akzent-Bar
(border-l-blue-600), kraefigerem Background (bg-blue-100),
Checkmark-Icon rechtsbuendig und blauer Titel-Text.

Bug 2 — Email-Sync im Portal: "Keine Berechtigung"
POST /api/stressfrei-emails/:id/sync hatte
requirePermission('customers:update') – die Portal-Kunden nicht
haben (nur customers:read fuer eigene Daten). Sie konnten ihr
eigenes Postfach nicht synchronisieren.

Fix: Perm-Middleware aus der Route raus, Mitarbeiter-Check +
Owner-Check in den Controller verlegt:
- isCustomerPortal: nur Owner-Check (canAccessStressfreiEmail)
- Mitarbeiter: muss customers:update haben
Trennung der Threat-Modelle – Portal-User darf sein Postfach
syncen, sonst aber nichts triggern; Mitarbeiter brauchen weiter
die Update-Perm.

Live-verifiziert:
- Portal-User 1 syncs eigenes Konto → Auth passiert (400 wegen
  fehlender IMAP-Config in dev-DB, NICHT 403)
- Portal-User 1 syncs Customer-3-Konto → 403 "Kein Zugriff"
- Mitarbeiter ohne customers:update → weiter 403

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 08:25:16 +02:00
duffyduck 2d4e4cdcc7 Portal-Login-URL als App-Setting (statt nur PUBLIC_URL-Env)
Bugfix: in der "Zugangsdaten versenden"-Mail stand bisher
http://localhost:5173/portal/login als Login-Link, wenn die
PUBLIC_URL-Env nicht gesetzt war – Kunden klickten auf einen
toten Link.

Neue Einstellung "portalLoginUrl" unter Einstellungen → Kundenportal.
Wenn gepflegt, wird sie als Basis-URL für:
  - Portal-Zugangsdaten-Mail (Login-Link)
  - Passwort-Reset-Link
verwendet. Reihenfolge: AppSetting → PUBLIC_URL-Env → localhost-Default.

Backend: getPublicUrl() jetzt async, liest erst aus AppSetting,
fällt auf Env zurück. Trailing-Slash-Bereinigung im Backend
(damit Links nicht doppelt-Slash bekommen) und im Frontend
(damit der gespeicherte Wert sauber ist).

Frontend: neue Card "Portal-Login-URL" oberhalb der Support-
Anfragen-Card in PortalSettings.tsx. Input + Save-Button +
http(s)://-Schema-Validierung + Erfolgs-Toast.

Live-verifiziert: PUT setzt 'https://crm.beispiel.de', Backend-
getPublicUrl liefert 'https://crm.beispiel.de/portal/login'
statt localhost.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 12:49:55 +02:00
duffyduck ee4ca9df07 Zugangsdaten-Card: Portal-Link des Anbieters anzeigen
Im Vertragsdetail unter "Zugangsdaten" zwischen Benutzername und
Passwort jetzt eine zusätzliche Zeile "Portal-Link" mit klickbarem
Link zum Anbieter-Portal (öffnet in neuem Tab, mit Copy-Button).
Greift auf das bestehende c.provider.portalUrl-Feld zurück (wird
auch schon für den Auto-Login-Button verwendet).

Schema und Host werden im Anzeigetext gestrippt, die volle URL
bleibt im href und im title-Attribut sichtbar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 11:27:02 +02:00
duffyduck 9385fc0f11 fix: Portal-E-Mail-Feld konnte nur per Paste befüllt werden
Folge-Symptom des Pentest-29.4-Email-Validators: das Portal-Email-
Input feuerte bei jedem Keystroke einen PUT
/customers/:id/portal mit dem Zwischenstand ("p", "po", "por@") –
der Backend-Validator lehnte das mit 400 ab, der Server-State blieb
unverändert, das Input re-renderte mit dem alten Wert. Effekt: man
konnte nichts tippen, nur per Paste in einem Event eine
vollständige Adresse setzen.

Fix: lokaler emailDraft-State. Während getippt wird, bleibt der
Wert nur im Client. Commit erfolgt erst onBlur oder bei Enter –
oder wird mit Escape verworfen. Bei Mutations-Error gibt's jetzt
auch einen toast statt stiller Revert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:20:36 +02:00
duffyduck c9f4fcf8de fix: Portal-Passwort-Card im Vertragsdetail wieder sichtbar
Folge-Symptom zum PW-Save-Fix: das Speichern hat funktioniert,
aber die "Zugangsdaten"-Card im Read-Only-View hat das Passwort-
Feld nicht angezeigt. Ursache: das Frontend nutzte
`c.portalPasswordEncrypted` als Truthy-Check, aber
sanitizeContract strippt das Feld bewusst aus jeder Response
(Pentest Runde 15 - kein verschlüsselter Blob in /contracts/:id).

Fix: getContractById hängt jetzt ein virtuelles `hasPortalPassword`-
Bool-Flag an die Response. Frontend nutzt das statt
portalPasswordEncrypted. Der verschlüsselte Wert bleibt
server-seitig; der Klartext kommt weiterhin über
GET /contracts/:id/password mit Audit-Log.

Live-verifiziert: PUT setzt PW, GET liefert hasPortalPassword:true
+ portalPasswordEncrypted ist NICHT in der Response.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 02:30:54 +02:00
duffyduck 897abc7b21 Datenschutzerklärung als unterschreibbare PDF-Vorlage
Neuer Endpoint GET /api/gdpr/customer/:customerId/privacy-pdf
generiert eine PDF mit:
- Titel
- Personalisiertem Kopf (Name / Firma + Kundennummer + Datum)
- Voller Datenschutzerklärung (HTML → Text)
- Einwilligungsklausel
- Unterschriftenblock (Ort/Datum links, Unterschrift rechts,
  zweite Linie "Name in Druckbuchstaben" mit vorausgefuelltem
  Kundennamen)

Auth: customers:read + canAccessCustomer. Filename:
"datenschutzerklaerung-<kundennummer>.pdf".

Im Tab "Einwilligungen / Datenschutz" beim Kunden gibt es jetzt
direkt neben dem Upload-Feld den Link "Vorlage zum Unterschreiben"
– Ausdrucken, unterschreiben lassen, scannen, wieder hochladen.

Verifiziert auf dev: Magic-Bytes %PDF-1.3, %%EOF-Marker am Ende,
2 KB Output, pdftotext zeigt korrekten Aufbau inkl. Unterschrift-
Linien.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 15:30:11 +02:00
duffyduck 771f46d2ac Vertragsansicht: Kunden-Schnellansicht-Modal + Cent/Euro-Input
Feature 1 – Kunden-Schnellansicht:
Info-Icon neben dem Kundenlink im Vertragsdetail oeffnet ein Modal
mit den wichtigsten Kundendaten (Firma, Name, Geburtsdatum/-ort,
Gruendungsdatum, Adresse, Telefon, Mobil, E-Mail, Portal-E-Mail,
Steuer-/Handelsregisternr). Jedes Feld hat einen Copy-Button.
Lazy-Fetch via customerApi.getById, staleTime 30s.

Feature 2 – Cent/Euro-Doppel-Input:
Neben dem €/kWh-Arbeitspreis-Feld jetzt ein zweites ct/kWh-Feld.
Bidirektional gekoppelt – Tippen in € aktualisiert ct (×100),
Tippen in ct aktualisiert € (÷100). Backend speichert weiterhin
nur den Euro-Wert; Cent ist reine UI-Hilfe. Float-Rausch-Schutz
verhindert "0.25 → 25.0000000000004". Greift fuer unitPrice und
unitPriceNt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 14:41:37 +02:00
duffyduck 20d42c5270 Energie-Bonus aufgeteilt in Sofort + Neukunden
EnergyContractDetails.bonus war ein einzelnes Feld. Strom-/Gas-
Verträge haben aber typischerweise zwei Boni (Sofort beim Wechsel
+ Neukunden-Bonus nach 12 Monaten), die getrennt verbucht werden
müssen.

Migration 20260524100000_split_energy_bonus:
- ADD COLUMN IF NOT EXISTS instantBonus, newCustomerBonus
- bestehende `bonus`-Werte → instantBonus (Annahme: Sofort)
- DROP COLUMN IF EXISTS bonus

UI:
- ContractForm zeigt zwei Input-Felder
- Detail-Ansicht zeigt beide einzeln + Gesamtbonus
- Kostenvorschau listet beide einzeln, dann Gesamt, dann effektive
  Jahreskosten

Cost-Calc: calculateCosts() bekommt beide Boni; CostCalculation
liefert instantBonus, newCustomerBonus, totalBonus.

PDF-Template: drei neue Variablen energyDetails.instantBonus,
.newCustomerBonus, .totalBonus.

Live-verifiziert auf dev: PUT mit beiden Werten → DB persistiert,
GET liefert zurueck.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 14:27:54 +02:00
duffyduck bf7afdd9a6 Pentest KRITISCH: Backup-Restore braucht Confirm-Body
POST /api/settings/backup/:name/restore startete bei leerem Body
sofort den destruktiven Restore. Im Unterschied zu /factory-reset
fehlte der Magic-String-Confirm-Check, sodass ein versehentlicher
Re-Fire (Doppelklick, Browser-Tab-Replay, eingeloggter Admin auf
bösartiger Drittseite) die komplette DB stillschweigend
überschreiben konnte.

Fix: gleicher Defensive-Pattern wie factoryReset – Body muss
{ "confirm": "RESTORE-BESTAETIGT" } enthalten, sonst 400. Der
Magic-String ist absichtlich ein einzigartiges Token (kein Boolean),
damit kein Auto-JSON-Tooling/Replay aus Versehen triggern kann.

Frontend-API-Client setzt das Token im Body automatisch – der
existierende Bestätigungs-Dialog im UI bleibt UX-mäßig unverändert.

Live-verifiziert:
- leerer Body → 400
- { confirm: "ja" } → 400
- { confirm: "RESTORE-BESTAETIGT" } → 200, Restore läuft

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 01:05:00 +02:00
duffyduck 37df8c0c4a Backup-Operations-Log + EBUSY-Fix beim Restore
Backup-Seite zeigt zwei neue Log-Panels: links Backup-Erstellung,
rechts Backup-Wiederherstellung. Jeder Eintrag mit ✓/✗-Status,
Summary, Timestamp + User. Klick öffnet Modal mit vollständigem
Verlauf – alle console.log/error/warn/info-Zeilen werden während
der Operation in einen Puffer mitgefangen und im fullLog-Feld
persistiert. Auto-Refresh alle 5s.

Persistenz: neue Tabelle BackupLog mit Migration
20260519100000_backup_log (CREATE TABLE IF NOT EXISTS für Re-Deploys
auf DBs mit Vorab-db-push). fullLog auf 1 MB gecappt.

Endpoints (settings:update):
- GET /api/settings/backup-logs?operation=CREATE|RESTORE&limit=50
- GET /api/settings/backup-logs/:id

EBUSY-Fix: Der neue Log-Verlauf hat sofort einen alten Bug
sichtbar gemacht. backup.service.restoreBackup rief
deleteDirectory(UPLOADS_DIR) auf, dessen finales rmdirSync auf
/app/uploads ein EBUSY warf – das Verzeichnis ist im Container ein
Bind-Mount und lässt sich nicht aushängen. Fix: neuer Helper
emptyDirectory() löscht nur die Inhalte, das Verzeichnis bleibt
stehen.

Live-verifiziert: 4867 Datensätze + 1 Datei in 13.2s
wiederhergestellt; Log-Modal zeigt den vollständigen Verlauf.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 11:53:04 +02:00
duffyduck 95541e8ac4 fix: DSGVO-Rolle sieht Menüpunkte in Einstellungen wieder
System-Block in Settings.tsx war komplett in
hasPermission('settings:update') gewickelt. DSGVO-User haben aber nur
audit:* und gdpr:* Perms – kein settings:update – und sahen damit
weder DSGVO-Dashboard, Datenschutzerklärung, Vollmacht-Vorlage,
Impressum, Website-Datenschutz, E-Mail-Versandlog noch Audit-Log.

Outer-Check auf (settings:update || audit:read || gdpr:admin)
erweitert. Innere Per-Card-Checks bleiben unverändert, sodass jeder
User nur das sieht, wofür er Perms hat.

Backend-API mit reinem DSGVO-Token gegengetestet: alle 9 Endpoints
liefern 200 – Routes hatten kein Permission-Problem.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:44:48 +02:00
duffyduck 6ae815393e backup-restore: vollständiger Stack im Server-Log + lesbare UI-Details
Der globale ORM-Leak-Sanitizer ersetzt error/details, die TypeError/
"Cannot read properties of undefined" enthalten, durch "Operation
fehlgeschlagen". Das ist richtig für Auth-Endpoints, blockt aber bei
legitimen Admin-Operationen wie Restore die Diagnose-Info.

Backend (restoreBackup):
- console.error mit "[restore]"-Prefix loggt Backup-Name + vollen
  Stack ins Server-Log. Per `docker logs opencrm-app | tail -200`
  einsehbar.
- makeRestoreErrorReadable() strippt Stack-Frames, rephrased
  bekannte JS-Runtime-Marker ("TypeError:" → "Code-Fehler:",
  "Cannot read properties of undefined (reading 'x')" → "Wert
  fehlt: x") + cuttet auf 500 Zeichen. Dadurch passiert die
  Meldung den globalen Sanitizer und landet lesbar im Response.
- Response bekommt zusätzliches `hint`-Feld mit dem konkreten
  docker-Befehl.

Frontend (DatabaseBackup):
- extractError liefert jetzt strukturiertes Objekt
  {headline, details, hint} statt nur String.
- Dialog: Headline fett, details in Mono-Box, hint italic darunter.
- Toast: Headline + details zusammen, 10s sichtbar.

Live-verifiziert:
- Bad name → "Backup nicht gefunden" (klare Meldung)
- Echtes Backup → "4859 Datensätze wiederhergestellt" als Toast,
  Dialog zu

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:30:13 +02:00
duffyduck 06c427ee39 backup-restore: Toast bei Erfolg, ausführliche Fehlermeldung im Dialog
Vorher: nach Klick auf "Ja, wiederherstellen" passierte UI-seitig
einfach … nichts Sichtbares außer dass der Dialog (irgendwann) zuging.
Bei einem 500er-Fehler blieb der Dialog offen ohne erkennbare
Begründung – der User dachte, die Aktion sei nicht durchgelaufen,
und klickte teils nochmal.

Jetzt:
- Erfolg → Dialog zu, grüne Toast-Meldung mit der Backend-Response
  ("X Datensätze und Y Dateien wiederhergestellt"), 6s sichtbar.
- Fehler → Dialog bleibt offen mit roter Detail-Box drinnen,
  Backend-Felder error + details zusammengefügt, plus
  Toast-Notification 8s. Button-Label wird zu "Erneut versuchen",
  Sekundär-Button zu "Schließen".
- Beim Schließen wird mutation.reset() aufgerufen, damit beim
  nächsten Öffnen keine alten Fehler dranhängen.

extractError-Helper ist allgemein – kann später für andere
Backup-Aktionen wiederverwendet werden.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:20:16 +02:00