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:
+8
@@ -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;
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user