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
+ Anbieter hat kein Portal oder Kunde bekommt keine Zugangsdaten. Die Warnung „Portal-Zugangsdaten fehlen" im Cockpit wird für diesen Vertrag unterdrückt. +
+ )} -