From dcdb3cb0577dcbd39ba984e263fd6d543ff560db Mon Sep 17 00:00:00 2001 From: duffyduck Date: Fri, 17 Jul 2026 14:45:32 +0200 Subject: [PATCH] =?UTF-8?q?Vertrag:=20=E2=80=9EPortaldaten=20nicht=20m?= =?UTF-8?q?=C3=B6glich=20/=20nicht=20ben=C3=B6tigt"-Opt-out?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dritter Radio-Button in der Zugangsdaten-Card des Vertragsformulars. Wenn gesetzt, unterdrückt das Cockpit die Warnung „Portal-Zugangs- daten fehlen" für diesen Vertrag – für Anbieter ohne Portal oder Kunden, die bewusst keine Zugangsdaten pflegen. Verstopft das Cockpit sonst dauerhaft. Neues Feld Contract.portalCredentialsNotRequired (Boolean, default false) + idempotente Migration (ADD COLUMN IF NOT EXISTS). Bestand bleibt auf false, Warnung greift wie bisher. Beim Umschalten auf Opt-out werden portalUsername, portalPasswordEncrypted und stressfreiEmailId server-seitig explizit auf NULL gesetzt – Datenhygiene, damit keine verwaisten Anmeldedaten in der DB stehen bleiben. Co-Authored-By: Claude Opus 4.7 --- .../migration.sql | 8 ++ backend/prisma/schema.prisma | 5 ++ backend/src/services/contract.service.ts | 17 +++- .../src/services/contractCockpit.service.ts | 28 ++++--- docs/todo.md | 29 +++++++ frontend/src/pages/contracts/ContractForm.tsx | 77 +++++++++++++------ frontend/src/types/index.ts | 3 + 7 files changed, 132 insertions(+), 35 deletions(-) create mode 100644 backend/prisma/migrations/20260717100000_contract_portal_credentials_not_required/migration.sql diff --git a/backend/prisma/migrations/20260717100000_contract_portal_credentials_not_required/migration.sql b/backend/prisma/migrations/20260717100000_contract_portal_credentials_not_required/migration.sql new file mode 100644 index 00000000..389ec413 --- /dev/null +++ b/backend/prisma/migrations/20260717100000_contract_portal_credentials_not_required/migration.sql @@ -0,0 +1,8 @@ +-- Vertrag: „Portaldaten nicht möglich / nicht benötigt"-Marker. +-- Wenn gesetzt, unterdrückt das Cockpit die Warnung „Portal-Zugangsdaten +-- fehlen" für diesen Vertrag (Anbieter ohne Portal / Kunde bekommt keine +-- Zugangsdaten). Bestehende Verträge bleiben auf FALSE, damit die +-- Warnung wie bisher greift. + +ALTER TABLE `Contract` + ADD COLUMN IF NOT EXISTS `portalCredentialsNotRequired` BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index c09aa4ac..8b374ab7 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -765,6 +765,11 @@ model Contract { stressfreiEmailId Int? stressfreiEmail StressfreiEmail? @relation(fields: [stressfreiEmailId], references: [id]) + // Portaldaten bewusst nicht hinterlegt (Anbieter hat kein Portal oder + // Kunde bekommt keine Zugangsdaten). Wenn true, unterdrückt das Cockpit + // die „Portal-Zugangsdaten fehlen"-Warnung für diesen Vertrag. + portalCredentialsNotRequired Boolean @default(false) + // Snooze: Vertrag zurückstellen bis Datum (für Cockpit) nextReviewDate DateTime? // Erneute Prüfung am diff --git a/backend/src/services/contract.service.ts b/backend/src/services/contract.service.ts index 21c07ec9..57a2f55b 100644 --- a/backend/src/services/contract.service.ts +++ b/backend/src/services/contract.service.ts @@ -233,6 +233,7 @@ interface ContractCreateData { portalUsername?: string; portalPassword?: string; stressfreiEmailId?: number; + portalCredentialsNotRequired?: boolean; notes?: string; // Kündigungsdaten cancellationConfirmationDate?: Date; @@ -461,12 +462,26 @@ export async function updateContract( ? encrypt(portalPassword) : undefined; + // Opt-out „Portaldaten nicht benötigt": Alte Anmeldedaten explizit + // wegräumen, damit nach dem Umschalten kein verwaister verschlüsselter + // Blob + Username in der DB stehen bleibt. Nur greifen, wenn das Flag + // in diesem Update wirklich auf true gesetzt wird (nicht bei jedem PUT). + const clearCredentials = contractData.portalCredentialsNotRequired === true; + // Update main contract await prisma.contract.update({ where: { id }, data: { ...contractData, - ...(portalPasswordEncrypted ? { portalPasswordEncrypted } : {}), + ...(clearCredentials + ? { + portalUsername: null, + portalPasswordEncrypted: null, + stressfreiEmailId: null, + } + : portalPasswordEncrypted + ? { portalPasswordEncrypted } + : {}), }, }); diff --git a/backend/src/services/contractCockpit.service.ts b/backend/src/services/contractCockpit.service.ts index dc88b969..88e793ad 100644 --- a/backend/src/services/contractCockpit.service.ts +++ b/backend/src/services/contractCockpit.service.ts @@ -443,18 +443,24 @@ export async function getCockpitData(opts?: { customerIds?: number[] }): Promise } // 3. FEHLENDE PORTAL-ZUGANGSDATEN - // Benutzername kann entweder manuell (portalUsername) oder via Stressfrei-Wechseln E-Mail (stressfreiEmailId) gesetzt sein - const hasUsername = contract.portalUsername || contract.stressfreiEmailId; - const hasPassword = contract.portalPasswordEncrypted; + // Benutzername kann entweder manuell (portalUsername) oder via Stressfrei-Wechseln E-Mail (stressfreiEmailId) gesetzt sein. + // Vertragsseitiges Opt-out: wenn der Anbieter kein Portal hat oder der + // Kunde bewusst keine Zugangsdaten pflegt, unterdrückt + // `portalCredentialsNotRequired` die Warnung. So verstopft die Zeile + // nicht dauerhaft das Cockpit. + if (!contract.portalCredentialsNotRequired) { + const hasUsername = contract.portalUsername || contract.stressfreiEmailId; + const hasPassword = contract.portalPasswordEncrypted; - if (!hasUsername || !hasPassword) { - issues.push({ - type: 'missing_portal_credentials', - label: 'Portal-Zugangsdaten fehlen', - urgency: 'warning', - details: 'Benutzername oder Passwort für das Anbieter-Portal fehlt', - }); - summary.byCategory.missingCredentials++; + if (!hasUsername || !hasPassword) { + issues.push({ + type: 'missing_portal_credentials', + label: 'Portal-Zugangsdaten fehlen', + urgency: 'warning', + details: 'Benutzername oder Passwort für das Anbieter-Portal fehlt', + }); + summary.byCategory.missingCredentials++; + } } // 4. KEINE KUNDENNUMMER BEIM ANBIETER diff --git a/docs/todo.md b/docs/todo.md index 207eb816..6b3e941c 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,35 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🔕 Vertrag: „Portaldaten nicht möglich / nicht benötigt"-Opt-out** + - Dritter Radio-Button in der `Zugangsdaten`-Card des Vertragsformulars: + „Nicht möglich zu bekommen / nicht benötigt". Wenn gesetzt, unterdrückt + das Cockpit die Warnung „Portal-Zugangsdaten fehlen" für diesen + Vertrag (Anbieter hat kein Portal / Kunde bekommt keine Zugangsdaten). + - Neues Feld `Contract.portalCredentialsNotRequired` (Boolean, default + false) + Migration `20260717100000_contract_portal_credentials_not_required` + (`ADD COLUMN IF NOT EXISTS`). Bestand bleibt unverändert warnend. + - Passwort-Spalte im Formular wird bei Opt-out ausgeblendet; alte + Username-/Passwort-Werte werden beim Umschalten geleert, damit der + Vertrag nach dem Speichern nicht mit Leichen zurückkommt. + +- [x] **🔧 Debug-Hilfe: Fehler bei „PIN/PUK anzeigen" sichtbar machen** + - Der stumme `alert('PIN/PUK konnte nicht geladen werden')` maskierte + die eigentliche Ursache. Ersetzt durch Toast mit Backend-Message + (Vertrag nicht gefunden / Kein Zugriff / Decrypt-Fehler), + `console.error` mit Stacktrace und einem Info-Toast, falls die SIM + in der DB gar keine PIN/PUK hinterlegt hat. Kein Verhaltens-Change am + Endpoint – nur Diagnose. + +- [x] **🔗 Vertrag-Zugangsdaten: Externtab-Link zu Kunden-Adressen** + - Kleines Externtab-Icon neben dem Label „Portal Benutzername", öffnet + `/customers/{id}?tab=stressfrei` in neuem Tab, damit man beim Anlegen + einer Portal-Wechseladresse nicht durch die Vertragsansicht zurück + navigieren muss. Sichtbar in beiden Modi. + - Als R116 vom Pentester geprüft, kein Finding (SPA-Parameter, Backend + wertet `?tab=` nicht aus; Sub-Route-IDOR-Check auf `canAccessCustomer` + steht). + - [x] **🔧 Pentest R111 – contract-categories: falscher Permission-String** - Der Pentester meldete 403 auf `POST/PUT/DELETE /api/contract-categories/*` für Admin trotz vorhandener `contract-categories:*`-Permissions. diff --git a/frontend/src/pages/contracts/ContractForm.tsx b/frontend/src/pages/contracts/ContractForm.tsx index 34a6fca5..52c5bb32 100644 --- a/frontend/src/pages/contracts/ContractForm.tsx +++ b/frontend/src/pages/contracts/ContractForm.tsx @@ -220,7 +220,7 @@ export default function ContractForm() { const [isContractLoaded, setIsContractLoaded] = useState(false); // Portal-Benutzername: Manuell oder Stressfrei-Adresse - const [usernameType, setUsernameType] = useState<'manual' | 'stressfrei'>('manual'); + const [usernameType, setUsernameType] = useState<'manual' | 'stressfrei' | 'not_required'>('manual'); const [selectedStressfreiEmailId, setSelectedStressfreiEmailId] = useState(''); // Passwort-Sichtbarkeit @@ -442,8 +442,14 @@ export default function ContractForm() { setPhoneNumbers([]); } - // Portal-Benutzername-Typ initialisieren - if (c.stressfreiEmailId) { + // Portal-Benutzername-Typ initialisieren. + // Opt-out („nicht benötigt") gewinnt vor Stressfrei/Manual, damit ein + // versehentlich noch alter portalUsername den Radio-Zustand nicht + // überstimmt. + if (c.portalCredentialsNotRequired) { + setUsernameType('not_required'); + setSelectedStressfreiEmailId(''); + } else if (c.stressfreiEmailId) { setUsernameType('stressfrei'); setSelectedStressfreiEmailId(c.stressfreiEmailId.toString()); } else { @@ -576,10 +582,13 @@ export default function ContractForm() { cancellationPeriodId: safeParseInt(data.cancellationPeriodId) ?? null, contractDurationId: safeParseInt(data.contractDurationId) ?? null, commission: data.commission ? parseFloat(data.commission) : null, - // Portal-Benutzername: entweder manuell oder Stressfrei-Adresse + // Portal-Benutzername: manuell, Stressfrei-Adresse oder Opt-out. + // Bei 'not_required' werden Username-Felder + Passwort explizit + // geleert und das Flag gesetzt – so verschwindet die Cockpit-Warnung. portalUsername: usernameType === 'manual' ? emptyToNull(data.portalUsername) : null, stressfreiEmailId: usernameType === 'stressfrei' && selectedStressfreiEmailId ? parseInt(selectedStressfreiEmailId) : null, - portalPassword: data.portalPassword || undefined, // Passwort: undefined = nicht ändern + portalCredentialsNotRequired: usernameType === 'not_required', + portalPassword: usernameType === 'not_required' ? undefined : (data.portalPassword || undefined), // Passwort: undefined = nicht ändern notes: emptyToNull(data.notes), cancellationConfirmationDate: data.cancellationConfirmationDate ? new Date(data.cancellationConfirmationDate) : null, cancellationConfirmationOptionsDate: data.cancellationConfirmationOptionsDate ? new Date(data.cancellationConfirmationOptionsDate) : null, @@ -1179,27 +1188,49 @@ export default function ContractForm() { Keine {customerEmailLabel} Adressen für diesen Kunden vorhanden. Bitte zuerst beim Kunden anlegen.

)} + + {usernameType === 'not_required' && ( +

+ Anbieter hat kein Portal oder Kunde bekommt keine Zugangsdaten. Die Warnung „Portal-Zugangsdaten fehlen" im Cockpit wird für diesen Vertrag unterdrückt. +

+ )} -
- -
- - + {usernameType !== 'not_required' && ( +
+ +
+ + +
-
+ )}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 8e531c79..f3a62060 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -481,6 +481,9 @@ export interface Contract { * bleibt server-seitig (sanitizeContract strippt `portalPasswordEncrypted`). * Entschlüsselter Wert kommt über `GET /contracts/:id/password`. */ hasPortalPassword?: boolean; + /** Opt-out: Anbieter hat kein Portal / Kunde bekommt keine Zugangsdaten. + * Wenn true, unterdrückt das Cockpit die „Portal-Zugangsdaten fehlen"-Warnung. */ + portalCredentialsNotRequired?: boolean; notes?: string; // Kündigungsdokumente cancellationLetterPath?: string;