Einmalpasswort-Flow für Portal-Credentials
Wenn Admin "Zugangsdaten versenden" klickt, ist das Passwort jetzt ein echtes Einmalpasswort: beim ersten erfolgreichen Portal-Login werden Hash + Encrypted-Feld sofort genullt und der Kunde wird zwangsweise auf eine "Neues Passwort vergeben"-Seite geleitet. Erst nach eigenem Passwort kommt er ins Portal. Schema: - Customer.portalPasswordMustChange: Boolean @default(false) Backend: - sendPortalCredentials setzt Flag = true + erweitertes Mail-Template mit Einmalpasswort-Warnung - customerLogin: bei Flag=true wird OTP konsumiert (Hash+Encrypted=null, portalLastLogin aktualisiert), Response enthält mustChangePassword=true in token-payload + user-objekt - setCustomerPortalPassword (manuelles Setzen) räumt Flag wieder auf - changeInitialPortalPassword: neue Service-Funktion + Endpoint POST /api/auth/change-initial-portal-password (authenticated, nur Portal-User), validiert Komplexität, setzt neuen Hash, löscht Encrypted, invalidiert Session via portalTokenInvalidatedAt Frontend: - User-Type erweitert um mustChangePassword - AuthContext.customerLogin gibt User zurück (für sofortige Routing- Entscheidung) - Login.tsx: redirect zu /change-initial-password wenn mustChangePassword - ProtectedRoute: zwingt eingeloggte User mit Flag immer zur Change-Seite - ChangeInitialPasswordGate: blockt User OHNE Flag vom Zugriff - ChangeInitialPassword: eigene Seite mit Live-Komplexitäts-Hint, Passwort-Wiederholung, automatischer Logout + Redirect nach Erfolg Live-verifiziert (10 Schritte): - Setzen → Send → DB-Flag=true → OTP-Login gibt mustChange=true und consumed Hash → Re-Login mit OTP fehlschlägt → Change schwach=400, komplex=200 → neues Passwort funktioniert → Session invalidated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+27
-1
@@ -8,6 +8,7 @@ import Layout from './components/layout/Layout';
|
||||
import Login from './pages/Login';
|
||||
import PasswordResetRequest from './pages/PasswordResetRequest';
|
||||
import PasswordResetConfirm from './pages/PasswordResetConfirm';
|
||||
import ChangeInitialPassword from './pages/ChangeInitialPassword';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import CustomerList from './pages/customers/CustomerList';
|
||||
import CustomerDetail from './pages/customers/CustomerDetail';
|
||||
@@ -49,7 +50,7 @@ import PortalProfile from './pages/portal/PortalProfile';
|
||||
import PortalMeters from './pages/portal/PortalMeters';
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const { isAuthenticated, isLoading, user } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -63,9 +64,31 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
// Force-Change-Password-Flow: nach Einmalpasswort-Login muss der Kunde
|
||||
// zwingend ein eigenes Passwort vergeben, bevor er irgendwohin sonst
|
||||
// navigieren darf.
|
||||
if (user?.mustChangePassword) {
|
||||
return <Navigate to="/change-initial-password" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function ChangeInitialPasswordGate() {
|
||||
const { isAuthenticated, isLoading, user } = useAuth();
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-gray-500">Laden...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
||||
// Wer nicht im Einmalpasswort-Flow ist, hat hier nichts zu suchen.
|
||||
if (!user?.mustChangePassword) return <Navigate to="/" replace />;
|
||||
return <ChangeInitialPassword />;
|
||||
}
|
||||
|
||||
function PortalConsentGate({ children }: { children: React.ReactNode }) {
|
||||
const { isCustomerPortal } = useAuth();
|
||||
|
||||
@@ -153,6 +176,9 @@ function App() {
|
||||
<Route path="/password-reset/request" element={<PasswordResetRequest />} />
|
||||
<Route path="/password-reset" element={<PasswordResetConfirm />} />
|
||||
|
||||
{/* Einmalpasswort → eigenes Passwort vergeben (eingeloggt, eigene Gate-Logik) */}
|
||||
<Route path="/change-initial-password" element={<ChangeInitialPasswordGate />} />
|
||||
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
|
||||
Reference in New Issue
Block a user