Passwort-Komplexität + Portal-Credentials-UX

validatePasswordComplexity (12 Zeichen, Groß/Klein/Zahl/Sonderzeichen)
zentral in passwordGenerator.ts; jetzt erzwungen in setPortalPassword,
confirmPasswordReset, register, createUser, updateUser.

Neue Endpoints:
- POST /customers/:id/portal/password/generate → 16-Zeichen Zufallspasswort
- POST /customers/:id/portal/send-credentials → Versand per Mail
  (nur wenn portalEnabled aktiv)

Frontend (CustomerDetail): Generate-Button vor Setzen, Send-Credentials
nach gesetztem Passwort, Live-Komplexitäts-Hint (✓/○) während Eingabe,
alert() durch Toast-Notifications ersetzt.

Live-verifiziert: schwaches Passwort → 400 mit Detail-Fehler, komplexes
Passwort → 200, Generator liefert 16-Zeichen-Passwort.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 18:26:11 +02:00
parent 6af1a4bbd4
commit 8a5ffbb563
8 changed files with 353 additions and 10 deletions
@@ -1796,6 +1796,38 @@ function ContractsTab({
);
}
// Passwort-Komplexität muss zur Backend-Regel in
// backend/src/utils/passwordGenerator.ts:validatePasswordComplexity passen.
function passwordMeetsComplexity(pw: string): boolean {
return (
pw.length >= 12 &&
/[a-z]/.test(pw) &&
/[A-Z]/.test(pw) &&
/[0-9]/.test(pw) &&
/[^A-Za-z0-9]/.test(pw)
);
}
// Live-Hinweis welche Komplexitäts-Anforderungen noch fehlen
function PasswordComplexityHint({ password }: { password: string }) {
const checks = [
{ ok: password.length >= 12, label: '≥ 12 Zeichen' },
{ ok: /[a-z]/.test(password), label: 'Kleinbuchstabe' },
{ ok: /[A-Z]/.test(password), label: 'Großbuchstabe' },
{ ok: /[0-9]/.test(password), label: 'Ziffer' },
{ ok: /[^A-Za-z0-9]/.test(password), label: 'Sonderzeichen' },
];
return (
<ul className="mt-2 text-xs space-y-0.5">
{checks.map((c) => (
<li key={c.label} className={c.ok ? 'text-green-600' : 'text-gray-500'}>
{c.ok ? '✓' : '○'} {c.label}
</li>
))}
</ul>
);
}
// Gespeichertes Passwort anzeigen
function StoredPasswordDisplay({ customerId }: { customerId: number }) {
const [showStoredPassword, setShowStoredPassword] = useState(false);
@@ -1898,10 +1930,35 @@ function PortalTab({
onSuccess: () => {
setNewPassword('');
queryClient.invalidateQueries({ queryKey: ['customer-portal', customerId] });
alert('Passwort wurde gesetzt');
toast.success('Passwort wurde gesetzt');
},
onError: (error: Error) => {
alert(error.message);
toast.error(error.message);
},
});
// Passwort generieren (16 Zeichen, komplex) ins Input-Feld füllen
const generatePasswordMutation = useMutation({
mutationFn: () => customerApi.generatePortalPassword(customerId),
onSuccess: (res) => {
const generated = res.data?.password || '';
setNewPassword(generated);
setShowPassword(true);
toast.success('Komplexes Passwort generiert jetzt „Setzen" klicken.');
},
onError: (error: Error) => {
toast.error(error.message);
},
});
// Zugangsdaten per E-Mail an den Kunden senden
const sendCredentialsMutation = useMutation({
mutationFn: () => customerApi.sendPortalCredentials(customerId),
onSuccess: (res) => {
toast.success(res.message || 'Zugangsdaten gesendet');
},
onError: (error: Error) => {
toast.error(error.message);
},
});
@@ -2003,7 +2060,7 @@ function PortalTab({
type={showPassword ? 'text' : 'password'}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Mindestens 6 Zeichen"
placeholder="Mind. 12 Zeichen, Groß/Klein/Zahl/Sonderzeichen"
disabled={!canEdit}
/>
<button
@@ -2014,15 +2071,48 @@ function PortalTab({
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<Button
variant="secondary"
onClick={() => generatePasswordMutation.mutate()}
disabled={!canEdit || generatePasswordMutation.isPending}
title='Komplexes Passwort generieren (16 Zeichen, Groß/Klein/Zahl/Sonderzeichen). Wird ins Feld geschrieben danach "Setzen" klicken.'
>
{generatePasswordMutation.isPending ? 'Generieren...' : 'Generieren'}
</Button>
<Button
onClick={() => setPasswordMutation.mutate(newPassword)}
disabled={!canEdit || newPassword.length < 6 || setPasswordMutation.isPending}
disabled={!canEdit || !passwordMeetsComplexity(newPassword) || setPasswordMutation.isPending}
title={passwordMeetsComplexity(newPassword) ? 'Passwort speichern' : 'Komplexität nicht erfüllt'}
>
{setPasswordMutation.isPending ? 'Speichern...' : 'Setzen'}
</Button>
</div>
{/* Komplexitäts-Hinweise: zeigt live welche Anforderungen erfüllt sind */}
{newPassword.length > 0 && !passwordMeetsComplexity(newPassword) && (
<PasswordComplexityHint password={newPassword} />
)}
{portal?.hasPassword && (
<StoredPasswordDisplay customerId={customerId} />
<>
<StoredPasswordDisplay customerId={customerId} />
<div className="mt-3">
<Button
variant="secondary"
size="sm"
onClick={() => {
if (confirm(
'Aktuelles Portal-Passwort und Login-URL per E-Mail an den Kunden senden?\n\n' +
'Hinweis: Das Passwort wird im Klartext in der E-Mail enthalten sein.'
)) {
sendCredentialsMutation.mutate();
}
}}
disabled={!canEdit || sendCredentialsMutation.isPending}
title="Login-URL + E-Mail + Passwort an die Kunden-E-Mail versenden"
>
{sendCredentialsMutation.isPending ? 'Sende...' : 'Zugangsdaten per E-Mail versenden'}
</Button>
</div>
</>
)}
</div>
)}
+8
View File
@@ -168,6 +168,14 @@ export const customerApi = {
const res = await api.get<ApiResponse<{ password: string | null }>>(`/customers/${customerId}/portal/password`);
return res.data;
},
generatePortalPassword: async (customerId: number) => {
const res = await api.post<ApiResponse<{ password: string }>>(`/customers/${customerId}/portal/password/generate`);
return res.data;
},
sendPortalCredentials: async (customerId: number) => {
const res = await api.post<ApiResponse<void>>(`/customers/${customerId}/portal/send-credentials`);
return res.data;
},
// Vertreter-Verwaltung
getRepresentatives: async (customerId: number) => {
const res = await api.get<ApiResponse<CustomerRepresentative[]>>(`/customers/${customerId}/representatives`);