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;