Files
opencrm/frontend/src/pages/PasswordResetConfirm.tsx
T
duffyduck 3e1fc3eab2 Mitarbeiter-Passwörter auf 25 Zeichen (BSI-Empfehlung)
Portal-Customer-Schwellwert bleibt 12 (Handy-Eingabe → längere PWs
erhöhen Reuse-Risiko). Mitarbeiter/Admin nutzen Passwort-Manager,
für die kostet die Länge nichts.

passwordGenerator.ts:
- STAFF_MIN_PASSWORD_LENGTH = 25, PORTAL_MIN_PASSWORD_LENGTH = 12
- validatePasswordComplexity({ minLength }) parametrisiert

Mitarbeiter-Pfade auf 25:
- createUser, register, setUserPassword
- confirmPasswordReset: Audience aus Token bestimmen
  (getPasswordResetAudience), User → 25, Customer → 12. Kein
  Body-Hint, damit kein Downgrade-Trick möglich.

Portal-Pfade unverändert (default 12):
- setPortalPassword, changeInitialPortalPassword

Seed-Admin:
- 28-char Zufallspasswort (statt 16) mit allen 4 Klassen garantiert
- SEED_ADMIN_PASSWORD-ENV nur akzeptiert wenn ≥ 25 Zeichen,
  sonst Log-Warnung + Random-Fallback

Frontend:
- UserList: Hinweis "Mind. 25 Zeichen". Update + PW gleichzeitig →
  zwei API-Calls (PUT + POST /users/:id/password) statt
  Password im Body durchzuschmuggeln (Backend strippt es eh)
- PasswordResetConfirm: Hinweis "Mind. 12 (Mitarbeiter: 25)"
- userApi.setPassword(id, password) neu

Live-verifiziert:
- POST /users/6/password "Hallo123!Test" (12) → 400 "mindestens 25"
- POST /users/6/password "MeinExtremLangesPW2026!Test" → 200,
  Login mit neuem PW → success
- POST /customers/3/portal/password "Hallo123!Test" (12) → 200
- POST /users createUser mit 12-char-PW → 400 "mindestens 25"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:19:58 +02:00

152 lines
5.5 KiB
TypeScript

import { useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { Lock, CheckCircle, AlertCircle, Eye, EyeOff } from 'lucide-react';
import Button from '../components/ui/Button';
import Input from '../components/ui/Input';
import Card from '../components/ui/Card';
import axios from 'axios';
export default function PasswordResetConfirm() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const token = searchParams.get('token') || '';
const [password, setPassword] = useState('');
const [passwordConfirm, setPasswordConfirm] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!token) {
setError('Ungültiger Link: Kein Token enthalten.');
return;
}
// Server prüft Komplexität endgültig (Mitarbeiter: 25 Zeichen, Portal-
// Kunden: 12). Frontend macht nur die naheliegenden Sanity-Checks.
if (password.length < 12) {
setError('Das Passwort muss mindestens 12 Zeichen lang sein (Mitarbeiter: 25).');
return;
}
if (password !== passwordConfirm) {
setError('Die Passwörter stimmen nicht überein.');
return;
}
setIsLoading(true);
try {
await axios.post('/api/auth/password-reset/confirm', { token, password });
setSuccess(true);
setTimeout(() => navigate('/login'), 3000);
} catch (err: any) {
setError(err.response?.data?.error || 'Fehler beim Zurücksetzen. Bitte versuche es erneut.');
} finally {
setIsLoading(false);
}
};
if (!token) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<Card className="w-full max-w-md">
<div className="text-center">
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-gray-900 mb-2">Ungültiger Link</h1>
<p className="text-gray-600 mb-6">
Dieser Reset-Link ist unvollständig. Bitte fordere einen neuen an.
</p>
<Link to="/password-reset/request">
<Button className="w-full">Neuen Link anfordern</Button>
</Link>
</div>
</Card>
</div>
);
}
if (success) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<Card className="w-full max-w-md">
<div className="text-center">
<CheckCircle className="w-12 h-12 text-green-500 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-gray-900 mb-2">Passwort geändert</h1>
<p className="text-gray-600 mb-6">
Dein Passwort wurde erfolgreich zurückgesetzt. Du wirst in Kürze zum Login weitergeleitet.
</p>
<Link to="/login">
<Button className="w-full">Jetzt einloggen</Button>
</Link>
</div>
</Card>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<Card className="w-full max-w-md">
<div className="text-center mb-6">
<Lock className="w-10 h-10 text-blue-500 mx-auto mb-3" />
<h1 className="text-2xl font-bold text-gray-900">Neues Passwort</h1>
<p className="text-gray-600 mt-2 text-sm">Vergib ein neues Passwort für deinen Account.</p>
</div>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Neues Passwort *</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
autoComplete="new-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"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<p className="text-xs text-gray-500 mt-1">
Mind. 12 Zeichen (Mitarbeiter: 25), Groß-/Kleinbuchstabe, Ziffer, Sonderzeichen
</p>
</div>
<Input
label="Passwort bestätigen *"
type={showPassword ? 'text' : 'password'}
value={passwordConfirm}
onChange={(e) => setPasswordConfirm(e.target.value)}
required
minLength={6}
autoComplete="new-password"
/>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Wird gespeichert…' : 'Passwort festlegen'}
</Button>
</form>
</Card>
</div>
);
}