Vertrag: „Portaldaten nicht möglich / nicht benötigt"-Opt-out

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 14:45:32 +02:00
co-authored by Claude Opus 4.7
parent aeecc3327f
commit dcdb3cb057
7 changed files with 132 additions and 35 deletions
@@ -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;
+5
View File
@@ -765,6 +765,11 @@ model Contract {
stressfreiEmailId Int? stressfreiEmailId Int?
stressfreiEmail StressfreiEmail? @relation(fields: [stressfreiEmailId], references: [id]) 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) // Snooze: Vertrag zurückstellen bis Datum (für Cockpit)
nextReviewDate DateTime? // Erneute Prüfung am nextReviewDate DateTime? // Erneute Prüfung am
+16 -1
View File
@@ -233,6 +233,7 @@ interface ContractCreateData {
portalUsername?: string; portalUsername?: string;
portalPassword?: string; portalPassword?: string;
stressfreiEmailId?: number; stressfreiEmailId?: number;
portalCredentialsNotRequired?: boolean;
notes?: string; notes?: string;
// Kündigungsdaten // Kündigungsdaten
cancellationConfirmationDate?: Date; cancellationConfirmationDate?: Date;
@@ -461,12 +462,26 @@ export async function updateContract(
? encrypt(portalPassword) ? encrypt(portalPassword)
: undefined; : 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 // Update main contract
await prisma.contract.update({ await prisma.contract.update({
where: { id }, where: { id },
data: { data: {
...contractData, ...contractData,
...(portalPasswordEncrypted ? { portalPasswordEncrypted } : {}), ...(clearCredentials
? {
portalUsername: null,
portalPasswordEncrypted: null,
stressfreiEmailId: null,
}
: portalPasswordEncrypted
? { portalPasswordEncrypted }
: {}),
}, },
}); });
+17 -11
View File
@@ -443,18 +443,24 @@ export async function getCockpitData(opts?: { customerIds?: number[] }): Promise
} }
// 3. FEHLENDE PORTAL-ZUGANGSDATEN // 3. FEHLENDE PORTAL-ZUGANGSDATEN
// Benutzername kann entweder manuell (portalUsername) oder via Stressfrei-Wechseln E-Mail (stressfreiEmailId) gesetzt sein // Benutzername kann entweder manuell (portalUsername) oder via Stressfrei-Wechseln E-Mail (stressfreiEmailId) gesetzt sein.
const hasUsername = contract.portalUsername || contract.stressfreiEmailId; // Vertragsseitiges Opt-out: wenn der Anbieter kein Portal hat oder der
const hasPassword = contract.portalPasswordEncrypted; // 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) { if (!hasUsername || !hasPassword) {
issues.push({ issues.push({
type: 'missing_portal_credentials', type: 'missing_portal_credentials',
label: 'Portal-Zugangsdaten fehlen', label: 'Portal-Zugangsdaten fehlen',
urgency: 'warning', urgency: 'warning',
details: 'Benutzername oder Passwort für das Anbieter-Portal fehlt', details: 'Benutzername oder Passwort für das Anbieter-Portal fehlt',
}); });
summary.byCategory.missingCredentials++; summary.byCategory.missingCredentials++;
}
} }
// 4. KEINE KUNDENNUMMER BEIM ANBIETER // 4. KEINE KUNDENNUMMER BEIM ANBIETER
+29
View File
@@ -97,6 +97,35 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt ## ✅ 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** - [x] **🔧 Pentest R111 contract-categories: falscher Permission-String**
- Der Pentester meldete 403 auf `POST/PUT/DELETE /api/contract-categories/*` - Der Pentester meldete 403 auf `POST/PUT/DELETE /api/contract-categories/*`
für Admin trotz vorhandener `contract-categories:*`-Permissions. für Admin trotz vorhandener `contract-categories:*`-Permissions.
+54 -23
View File
@@ -220,7 +220,7 @@ export default function ContractForm() {
const [isContractLoaded, setIsContractLoaded] = useState(false); const [isContractLoaded, setIsContractLoaded] = useState(false);
// Portal-Benutzername: Manuell oder Stressfrei-Adresse // 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<string>(''); const [selectedStressfreiEmailId, setSelectedStressfreiEmailId] = useState<string>('');
// Passwort-Sichtbarkeit // Passwort-Sichtbarkeit
@@ -442,8 +442,14 @@ export default function ContractForm() {
setPhoneNumbers([]); setPhoneNumbers([]);
} }
// Portal-Benutzername-Typ initialisieren // Portal-Benutzername-Typ initialisieren.
if (c.stressfreiEmailId) { // 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'); setUsernameType('stressfrei');
setSelectedStressfreiEmailId(c.stressfreiEmailId.toString()); setSelectedStressfreiEmailId(c.stressfreiEmailId.toString());
} else { } else {
@@ -576,10 +582,13 @@ export default function ContractForm() {
cancellationPeriodId: safeParseInt(data.cancellationPeriodId) ?? null, cancellationPeriodId: safeParseInt(data.cancellationPeriodId) ?? null,
contractDurationId: safeParseInt(data.contractDurationId) ?? null, contractDurationId: safeParseInt(data.contractDurationId) ?? null,
commission: data.commission ? parseFloat(data.commission) : 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, portalUsername: usernameType === 'manual' ? emptyToNull(data.portalUsername) : null,
stressfreiEmailId: usernameType === 'stressfrei' && selectedStressfreiEmailId ? parseInt(selectedStressfreiEmailId) : 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), notes: emptyToNull(data.notes),
cancellationConfirmationDate: data.cancellationConfirmationDate ? new Date(data.cancellationConfirmationDate) : null, cancellationConfirmationDate: data.cancellationConfirmationDate ? new Date(data.cancellationConfirmationDate) : null,
cancellationConfirmationOptionsDate: data.cancellationConfirmationOptionsDate ? new Date(data.cancellationConfirmationOptionsDate) : 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. Keine {customerEmailLabel} Adressen für diesen Kunden vorhanden. Bitte zuerst beim Kunden anlegen.
</p> </p>
)} )}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="usernameType"
checked={usernameType === 'not_required'}
onChange={() => {
setUsernameType('not_required');
setValue('portalUsername', '');
setValue('portalPassword', '');
setSelectedStressfreiEmailId('');
}}
className="text-blue-600"
/>
<span className="text-sm">Nicht möglich zu bekommen / nicht benötigt</span>
</label>
{usernameType === 'not_required' && (
<p className="text-xs text-gray-500">
Anbieter hat kein Portal oder Kunde bekommt keine Zugangsdaten. Die Warnung Portal-Zugangsdaten fehlen" im Cockpit wird für diesen Vertrag unterdrückt.
</p>
)}
</div> </div>
</div> </div>
<div className="mt-8"> {usernameType !== 'not_required' && (
<label className="block text-sm font-medium text-gray-700 mb-1"> <div className="mt-8">
{isEdit ? 'Neues Passwort (leer lassen = unverändert)' : 'Portal Passwort'} <label className="block text-sm font-medium text-gray-700 mb-1">
</label> {isEdit ? 'Neues Passwort (leer lassen = unverändert)' : 'Portal Passwort'}
<div className="relative"> </label>
<input <div className="relative">
type={showPortalPassword ? 'text' : 'password'} <input
{...register('portalPassword')} type={showPortalPassword ? 'text' : 'password'}
className="block w-full px-3 py-2 pr-10 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" {...register('portalPassword')}
/> className="block w-full px-3 py-2 pr-10 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
<button />
type="button" <button
onClick={() => setShowPortalPassword(!showPortalPassword)} type="button"
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600" onClick={() => setShowPortalPassword(!showPortalPassword)}
> className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600"
{showPortalPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />} >
</button> {showPortalPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div> </div>
</div> )}
</div> </div>
</Card> </Card>
+3
View File
@@ -481,6 +481,9 @@ export interface Contract {
* bleibt server-seitig (sanitizeContract strippt `portalPasswordEncrypted`). * bleibt server-seitig (sanitizeContract strippt `portalPasswordEncrypted`).
* Entschlüsselter Wert kommt über `GET /contracts/:id/password`. */ * Entschlüsselter Wert kommt über `GET /contracts/:id/password`. */
hasPortalPassword?: boolean; 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; notes?: string;
// Kündigungsdokumente // Kündigungsdokumente
cancellationLetterPath?: string; cancellationLetterPath?: string;