From 9fc0c9bc4c68bcc5ac3cb6a2c9e6aa0e29570078 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Tue, 13 Jan 2026 00:27:09 +0100 Subject: [PATCH] user magic link inmplemented --- src/App.tsx | 52 +++-- src/api/authApi.ts | 74 ++++++- src/hooks/useAuthentication.ts | 84 +++++++- src/pages/Login.module.css | 15 ++ src/pages/Login.tsx | 9 + src/pages/PasswordResetRequest.module.css | 242 ++++++++++++++++++++++ src/pages/PasswordResetRequest.tsx | 129 ++++++++++++ src/pages/Register.module.css | 27 +++ src/pages/Register.tsx | 178 +++++++--------- src/pages/Reset.module.css | 234 +++++++++++++++++++++ src/pages/Reset.tsx | 219 ++++++++++++++++++++ 11 files changed, 1136 insertions(+), 127 deletions(-) create mode 100644 src/pages/PasswordResetRequest.module.css create mode 100644 src/pages/PasswordResetRequest.tsx create mode 100644 src/pages/Reset.module.css create mode 100644 src/pages/Reset.tsx diff --git a/src/App.tsx b/src/App.tsx index 6bb6130..de17105 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,8 @@ import './index.css'; import Login from './pages/Login'; import Register from './pages/Register'; +import PasswordResetRequest from './pages/PasswordResetRequest'; +import Reset from './pages/Reset'; import { AuthProvider } from './providers/auth/AuthProvider'; import { ProtectedRoute } from './providers/auth/ProtectedRoute'; @@ -39,25 +41,37 @@ function App() { return ( - - - - - {/* Public route */} - } /> - } /> - - - - }> - {/* All page routing is now handled by the Page Loader in Home.tsx */} - - - - - - + + + {/* PUBLIC AUTH ROUTES - NO AUTHENTICATION REQUIRED */} + } /> + } /> + } /> + } /> + + {/* PROTECTED ROUTE - requires authentication */} + + + + + + + + } /> + + {/* Catch-all redirect to home */} + + + + + + + + } /> + + ); diff --git a/src/api/authApi.ts b/src/api/authApi.ts index 93ae5af..05075f4 100644 --- a/src/api/authApi.ts +++ b/src/api/authApi.ts @@ -22,7 +22,6 @@ export interface LoginResponse { export interface RegisterData { username: string; - password: string; email: string; fullName: string; language?: string; @@ -38,8 +37,19 @@ export interface RegisterRequest { language: string; enabled: boolean; privilege: string; + authenticationAuthority: string; }; - password: string; + frontendUrl: string; +} + +export interface PasswordResetRequestResponse { + success: boolean; + message: string; +} + +export interface PasswordResetResponse { + success: boolean; + message: string; } export interface RegisterResponse { @@ -142,11 +152,13 @@ export async function fetchCurrentUserApi(authAuthority?: string): Promise } /** - * Register a new user + * Register a new user (magic link based - no password required) * Endpoint: POST /api/local/register + * + * After registration, user receives an email with a magic link to set their password. */ export async function registerApi(registerData: RegisterData): Promise { - // Prepare data to match backend expectations + // Prepare data to match backend expectations (no password - magic link flow) const dataToSend: RegisterRequest = { userData: { username: registerData.username, @@ -154,9 +166,10 @@ export async function registerApi(registerData: RegisterData): Promise { + const headers: Record = { + 'Content-Type': 'application/json' + }; + + addCSRFTokenToHeaders(headers); + + const response = await api.post( + '/api/local/password-reset-request', + { + username, + frontendUrl: window.location.origin + }, + { headers } + ); + + return response.data; +} + +/** + * Reset password using token from magic link + * Endpoint: POST /api/local/password-reset + */ +export async function resetPasswordApi(token: string, password: string): Promise { + const headers: Record = { + 'Content-Type': 'application/json' + }; + + addCSRFTokenToHeaders(headers); + + const response = await api.post( + '/api/local/password-reset', + { token, password }, + { headers } + ); + + return response.data; +} + /** * Register with Microsoft account * Endpoint: POST /api/msft/register diff --git a/src/hooks/useAuthentication.ts b/src/hooks/useAuthentication.ts index d095502..3365511 100644 --- a/src/hooks/useAuthentication.ts +++ b/src/hooks/useAuthentication.ts @@ -12,11 +12,15 @@ import { registerWithMsalApi, checkUsernameAvailabilityApi, logoutApi, + requestPasswordResetApi, + resetPasswordApi, type LoginResponse, type RegisterResponse, type UsernameAvailabilityResponse, type RegisterData, - type MsalRegisterData + type MsalRegisterData, + type PasswordResetRequestResponse, + type PasswordResetResponse } from '../api/authApi'; // Regular authentication @@ -480,6 +484,84 @@ export function useUsernameAvailability() { }; } +// Password reset request (by username) +export function usePasswordResetRequest() { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + const requestReset = async (username: string): Promise => { + setIsLoading(true); + setError(null); + setSuccess(false); + + try { + const response = await requestPasswordResetApi(username); + setSuccess(true); + return response; + } catch (error: any) { + // For security, we don't reveal if the username exists or not + // So we still show success even on error + setSuccess(true); + return { success: true, message: 'If a user with this username exists, a reset link has been sent to their email.' }; + } finally { + setIsLoading(false); + } + }; + + return { + requestReset, + isLoading, + error, + success + }; +} + +// Password reset (set new password with token) +export function usePasswordReset() { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + const resetPassword = async (token: string, password: string): Promise => { + setIsLoading(true); + setError(null); + setSuccess(false); + + try { + const response = await resetPasswordApi(token, password); + setSuccess(true); + return response; + } catch (error: any) { + let errorMessage = 'Passwort-Zurücksetzung fehlgeschlagen'; + + if (error.response) { + if (error.response.data?.detail) { + if (Array.isArray(error.response.data.detail)) { + errorMessage = error.response.data.detail.map((err: any) => err.msg || err).join(', '); + } else { + errorMessage = error.response.data.detail; + } + } + } else if (error.message) { + errorMessage = error.message; + } + + setError(errorMessage); + throw error; + } finally { + setIsLoading(false); + } + }; + + return { + resetPassword, + isLoading, + error, + success + }; +} + // Logout function export function useLogout() { const [isLoading, setIsLoading] = useState(false); diff --git a/src/pages/Login.module.css b/src/pages/Login.module.css index 8489b8b..0744cee 100644 --- a/src/pages/Login.module.css +++ b/src/pages/Login.module.css @@ -263,3 +263,18 @@ button:disabled { text-align: center; font-family: var(--font-family); } + +.passwordResetLink { + display: flex; + justify-content: center; + margin-top: -0.5rem; +} + +.passwordResetLink .textButton { + color: #9CA3AF; + font-size: 0.85rem; +} + +.passwordResetLink .textButton:hover { + color: #F25843; +} diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 99d6aab..28deeb4 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -152,6 +152,15 @@ function Login() { {isLoginLoading ? "wird geladen..." : "Anmelden"} +
+ +
+
oder
diff --git a/src/pages/PasswordResetRequest.module.css b/src/pages/PasswordResetRequest.module.css new file mode 100644 index 0000000..430c494 --- /dev/null +++ b/src/pages/PasswordResetRequest.module.css @@ -0,0 +1,242 @@ +.container { + display: flex; + min-height: 100vh; + + font-family: "DM Sans", sans-serif; + color: #181818; +} + +.mainContent { + flex: 1; + display: flex; + flex-direction: column; + padding: 3rem; + background-color: #181818; +} + +.loginSection { + display: flex; + flex-direction: column; + align-items: center; + flex: 1; +} + +.logoText { + + font-size: 35px; + display: flex; + align-items: center; + letter-spacing: -0.5px; + font-weight: 200; +} + +.logoPower { + color: #E5E7EB; +} + +.logoOn { + color: #F25843; + font-weight: 700; +} + +.logo img { + height: 40px; +} + +.loginBox { + + + background-color: #181818; + width: 25%; + height: auto; + + margin-top: 5%; + padding: 2rem; + + border-radius: 25px; + border: 1px solid rgba(199, 197, 178, 0.15); /* washed-out color */ + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02), + 0 0 10px rgba(0, 0, 0, 0.1); +} + +.title { + font-family: "DM Sans", sans-serif; + color: #E5E7EB; + font-size: 1.5rem; + font-weight: 500; + margin-bottom: 1.5rem; + text-align: center; +} + +.loginForm { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.floatingLabelInput { + position: relative; +} + +.label { + position: absolute; + left: 16px; + top: 50%; + transform: translateY(-50%); + color: #C7C5B2; + font-size: 1rem; + pointer-events: none; + transition: all 0.3s ease; + background-color: transparent; + font-family: var(--font-family); +} + +.focusedLabel { + position: absolute; + left: 12px; + top: -8px; + transform: translateY(0); + color: #F25843; + font-size: 0.85rem; + pointer-events: none; + transition: all 0.3s ease; + background-color: #181818; + padding: 0 4px; + font-family: var(--font-family); + font-weight: 500; +} + +.input { + width: 100%; + height: 50px; + padding: 12px 16px; + border: 1px solid var(--color-gray-disabled); + border-radius: 25px; + + font-size: 1rem; + transition: all 0.2s ease; + background-color: var(--color-bg); + color: var(--color-text); + font-family: var(--font-family); +} + +.input:focus { + outline: none; + border-color: #F25843; + box-shadow: 0 0 0 2px rgba(242, 88, 67, 0.1); +} + +.input::placeholder { + color: transparent; +} + +/* Fix browser autocomplete styling */ +.input:-webkit-autofill, +.input:-webkit-autofill:hover, +.input:-webkit-autofill:focus, +.input:-webkit-autofill:active { + -webkit-box-shadow: 0 0 0 30px #181818 inset !important; + -webkit-text-fill-color: #E5E7EB !important; + background-color: #181818 !important; + transition: background-color 5000s ease-in-out 0s; +} + +/* Ensure label background matches when autofilled */ +.input:-webkit-autofill + .label, +.input:-webkit-autofill + .focusedLabel { + background-color: #181818 !important; +} + +.button { + width: 100%; + height: 50px; + padding: 12px 20px; + border-radius: 25px; + + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + border: none; + text-align: center; +} + +.loginButton { + background-color: #F25843; + color: #E5E7EB; +} + +.loginButton:hover { + background-color: var(--color-secondary-hover); +} + +.registerLink { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; +} + +.registerLink span { + color: #E5E7EB; + font-size: 0.8rem; +} + +.textButton { + background: none; + border: none; + color: var(--color-secondary); + font-weight: 500; + cursor: pointer; + padding: 0; + font-size: 0.9rem; + font-family: var(--font-family); +} + +.textButton:hover { + text-decoration: underline; +} + +button:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.error { + color: var(--color-secondary); + background-color: var(--color-secondary-disabled); + border: 1px solid var(--color-secondary); + border-radius: 25px; + padding: 12px; + font-size: 0.9rem; + text-align: center; + font-family: var(--font-family); + margin-bottom: 10px; +} + +.success { + color: #10b981; + background-color: rgba(16, 185, 129, 0.1); + border: 1px solid #10b981; + border-radius: 25px; + padding: 12px; + font-size: 0.9rem; + text-align: center; + font-family: var(--font-family); + margin-bottom: 10px; +} + +.infoMessage { + background-color: rgba(59, 130, 246, 0.1); + border: 1px solid rgba(59, 130, 246, 0.3); + border-radius: 12px; + padding: 12px; + font-size: 0.85rem; + color: #93c5fd; + text-align: center; + font-family: var(--font-family); +} + +.infoMessage p { + margin: 0; +} diff --git a/src/pages/PasswordResetRequest.tsx b/src/pages/PasswordResetRequest.tsx new file mode 100644 index 0000000..e84a6fa --- /dev/null +++ b/src/pages/PasswordResetRequest.tsx @@ -0,0 +1,129 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import styles from './PasswordResetRequest.module.css'; +import { usePasswordResetRequest } from '../hooks/useAuthentication'; +import { generateAndStoreCSRFToken } from '../utils/csrfUtils'; + +function PasswordResetRequest() { + const navigate = useNavigate(); + const { requestReset, isLoading, success } = usePasswordResetRequest(); + const [username, setUsername] = useState(''); + const [usernameFocused, setUsernameFocused] = useState(false); + const [validationError, setValidationError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + + // Set page title and generate CSRF token + useEffect(() => { + document.title = "PowerOn AI Platform - Passwort zurücksetzen"; + generateAndStoreCSRFToken(); + }, []); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setValidationError(null); + + if (!username.trim()) { + setValidationError('Bitte geben Sie Ihren Benutzernamen ein.'); + return; + } + + try { + await requestReset(username.trim()); + setSuccessMessage('Falls ein Konto mit diesem Benutzernamen existiert, wurde ein Reset-Link an die hinterlegte E-Mail-Adresse gesendet. Bitte prüfen Sie auch Ihren Spam-Ordner.'); + + // Redirect to login after delay + setTimeout(() => { + navigate('/login', { + state: { + passwordResetRequested: true, + message: 'Bitte prüfen Sie Ihre E-Mail für den Passwort-Reset-Link.' + } + }); + }, 5000); + } catch (err) { + // For security, still show success message even on error + setSuccessMessage('Falls ein Konto mit diesem Benutzernamen existiert, wurde ein Reset-Link an die hinterlegte E-Mail-Adresse gesendet. Bitte prüfen Sie auch Ihren Spam-Ordner.'); + setTimeout(() => { + navigate('/login'); + }, 5000); + } + }; + + return ( +
+
+
+
+ Power + On +
+
+
+
+

Passwort zurücksetzen

+
+ {validationError && ( +
{validationError}
+ )} + + {successMessage && ( +
{successMessage}
+ )} + + {!successMessage && ( + <> +
+ { + setUsername(e.target.value); + setValidationError(null); + }} + onFocus={() => setUsernameFocused(true)} + onBlur={() => setUsernameFocused(false)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleSubmit(e); + } + }} + className={`${styles.input} ${usernameFocused || username ? styles.focused : ''}`} + /> + +
+ +
+

Geben Sie Ihren Benutzernamen ein. Falls ein Konto existiert, erhalten Sie einen Link zum Zurücksetzen des Passworts an Ihre hinterlegte E-Mail-Adresse.

+
+ + + + )} + +
+ Zurück zum + +
+
+
+
+
+
+ ); +} + +export default PasswordResetRequest; diff --git a/src/pages/Register.module.css b/src/pages/Register.module.css index cc4e681..4112bb0 100644 --- a/src/pages/Register.module.css +++ b/src/pages/Register.module.css @@ -227,3 +227,30 @@ button:disabled { font-family: var(--font-family); margin-bottom: 10px; } + +.success { + color: #10b981; + background-color: rgba(16, 185, 129, 0.1); + border: 1px solid #10b981; + border-radius: 25px; + padding: 12px; + font-size: 0.9rem; + text-align: center; + font-family: var(--font-family); + margin-bottom: 10px; +} + +.infoMessage { + background-color: rgba(59, 130, 246, 0.1); + border: 1px solid rgba(59, 130, 246, 0.3); + border-radius: 12px; + padding: 12px; + font-size: 0.85rem; + color: #93c5fd; + text-align: center; + font-family: var(--font-family); +} + +.infoMessage p { + margin: 0; +} diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx index 0285d06..3072fdb 100644 --- a/src/pages/Register.tsx +++ b/src/pages/Register.tsx @@ -7,8 +7,6 @@ import { generateAndStoreCSRFToken } from '../utils/csrfUtils'; interface RegisterFormData { username: string; - password: string; - confirmPassword: string; email: string; fullName: string; } @@ -20,15 +18,12 @@ function Register() { const { checkAvailability, isChecking, error: availabilityError } = useUsernameAvailability(); const [formData, setFormData] = useState({ username: '', - password: '', - confirmPassword: '', email: '', fullName: '' }); const [validationError, setValidationError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); const [usernameFocused, setUsernameFocused] = useState(false); - const [passwordFocused, setPasswordFocused] = useState(false); - const [confirmPasswordFocused, setConfirmPasswordFocused] = useState(false); const [emailFocused, setEmailFocused] = useState(false); const [fullNameFocused, setFullNameFocused] = useState(false); const [usernameHighlight, setUsernameHighlight] = useState(false); @@ -55,16 +50,11 @@ function Register() { }; const validateForm = (): boolean => { - if (!formData.username || !formData.password || !formData.confirmPassword || !formData.email || !formData.fullName) { + if (!formData.username || !formData.email || !formData.fullName) { setValidationError('Bitte füllen Sie alle Pflichtfelder aus.'); return false; } - if (formData.password !== formData.confirmPassword) { - setValidationError('Die Passwörter stimmen nicht überein.'); - return false; - } - if (!formData.email.includes('@')) { setValidationError('Bitte geben Sie eine gültige E-Mail-Adresse ein.'); return false; @@ -96,15 +86,21 @@ function Register() { return; } - // Username is available, proceed with registration - const { confirmPassword, ...registrationData } = formData; - await register(registrationData); - navigate('/login', { - state: { - registered: true, - message: 'Registration erfolgreich. Bitte melden Sie sich an.' - } - }); + // Username is available, proceed with registration (no password - magic link flow) + await register(formData); + + // Show success message instead of immediate redirect + setSuccessMessage('Registrierung erfolgreich! Bitte prüfen Sie Ihre E-Mail (auch den Spam-Ordner) für den Link zum Setzen Ihres Passworts.'); + + // Redirect to login page after delay + setTimeout(() => { + navigate('/login', { + state: { + registered: true, + message: 'Registrierung erfolgreich. Bitte prüfen Sie Ihre E-Mail für den Passwort-Link.' + } + }); + }, 5000); } catch (err) { console.error('Registration failed:', err); } @@ -135,89 +131,73 @@ function Register() {
{getErrorMessage()}
)} -
- setUsernameFocused(true)} - onBlur={() => setUsernameFocused(false)} - className={`${styles.input} ${usernameFocused || formData.username ? styles.focused : ''} ${usernameHighlight ? styles.usernameError : ''}`} - /> - -
+ {successMessage && ( +
{successMessage}
+ )} + + {!successMessage && ( + <> +
+ setUsernameFocused(true)} + onBlur={() => setUsernameFocused(false)} + className={`${styles.input} ${usernameFocused || formData.username ? styles.focused : ''} ${usernameHighlight ? styles.usernameError : ''}`} + /> + +
-
- setEmailFocused(true)} - onBlur={() => setEmailFocused(false)} - className={`${styles.input} ${emailFocused || formData.email ? styles.focused : ''}`} - /> - -
+
+ setEmailFocused(true)} + onBlur={() => setEmailFocused(false)} + className={`${styles.input} ${emailFocused || formData.email ? styles.focused : ''}`} + /> + +
-
- setFullNameFocused(true)} - onBlur={() => setFullNameFocused(false)} - className={`${styles.input} ${fullNameFocused || formData.fullName ? styles.focused : ''}`} - /> - -
+
+ setFullNameFocused(true)} + onBlur={() => setFullNameFocused(false)} + className={`${styles.input} ${fullNameFocused || formData.fullName ? styles.focused : ''}`} + /> + +
-
- setPasswordFocused(true)} - onBlur={() => setPasswordFocused(false)} - className={`${styles.input} ${passwordFocused || formData.password ? styles.focused : ''}`} - /> - -
+
+

Nach der Registrierung erhalten Sie eine E-Mail mit einem Link zum Setzen Ihres Passworts.

+
-
- setConfirmPasswordFocused(true)} - onBlur={() => setConfirmPasswordFocused(false)} - className={`${styles.input} ${confirmPasswordFocused || formData.confirmPassword ? styles.focused : ''}`} - /> - -
+
+

+ Mit der Registrierung stimmen Sie unseren Datenschutzbestimmungen zur KI-Nutzung zu. +

+
-
-

- Mit der Registrierung stimmen Sie unseren Datenschutzbestimmungen zur KI-Nutzung zu. -

-
- - + + + )}
Bereits registriert? diff --git a/src/pages/Reset.module.css b/src/pages/Reset.module.css new file mode 100644 index 0000000..4a01484 --- /dev/null +++ b/src/pages/Reset.module.css @@ -0,0 +1,234 @@ +.container { + display: flex; + min-height: 100vh; + + font-family: "DM Sans", sans-serif; + color: #181818; +} + +.mainContent { + flex: 1; + display: flex; + flex-direction: column; + padding: 3rem; + background-color: #181818; +} + +.loginSection { + display: flex; + flex-direction: column; + align-items: center; + flex: 1; +} + +.logoText { + + font-size: 35px; + display: flex; + align-items: center; + letter-spacing: -0.5px; + font-weight: 200; +} + +.logoPower { + color: #E5E7EB; +} + +.logoOn { + color: #F25843; + font-weight: 700; +} + +.logo img { + height: 40px; +} + +.loginBox { + + + background-color: #181818; + width: 25%; + height: auto; + + margin-top: 5%; + padding: 2rem; + + border-radius: 25px; + border: 1px solid rgba(199, 197, 178, 0.15); /* washed-out color */ + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02), + 0 0 10px rgba(0, 0, 0, 0.1); +} + +.title { + font-family: "DM Sans", sans-serif; + color: #E5E7EB; + font-size: 1.5rem; + font-weight: 500; + margin-bottom: 1.5rem; + text-align: center; +} + +.loginForm { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.floatingLabelInput { + position: relative; +} + +.label { + position: absolute; + left: 16px; + top: 50%; + transform: translateY(-50%); + color: #C7C5B2; + font-size: 1rem; + pointer-events: none; + transition: all 0.3s ease; + background-color: transparent; + font-family: var(--font-family); +} + +.focusedLabel { + position: absolute; + left: 12px; + top: -8px; + transform: translateY(0); + color: #F25843; + font-size: 0.85rem; + pointer-events: none; + transition: all 0.3s ease; + background-color: #181818; + padding: 0 4px; + font-family: var(--font-family); + font-weight: 500; +} + +.input { + width: 100%; + height: 50px; + padding: 12px 16px; + border: 1px solid var(--color-gray-disabled); + border-radius: 25px; + + font-size: 1rem; + transition: all 0.2s ease; + background-color: var(--color-bg); + color: var(--color-text); + font-family: var(--font-family); +} + +.input:focus { + outline: none; + border-color: #F25843; + box-shadow: 0 0 0 2px rgba(242, 88, 67, 0.1); +} + +.input::placeholder { + color: transparent; +} + +/* Fix browser autocomplete styling */ +.input:-webkit-autofill, +.input:-webkit-autofill:hover, +.input:-webkit-autofill:focus, +.input:-webkit-autofill:active { + -webkit-box-shadow: 0 0 0 30px #181818 inset !important; + -webkit-text-fill-color: #E5E7EB !important; + background-color: #181818 !important; + transition: background-color 5000s ease-in-out 0s; +} + +/* Ensure label background matches when autofilled */ +.input:-webkit-autofill + .label, +.input:-webkit-autofill + .focusedLabel { + background-color: #181818 !important; +} + +.passwordHint { + font-size: 0.8rem; + color: #9CA3AF; + margin-top: -0.5rem; + padding-left: 16px; +} + +.button { + width: 100%; + height: 50px; + padding: 12px 20px; + border-radius: 25px; + + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + border: none; + text-align: center; +} + +.loginButton { + background-color: #F25843; + color: #E5E7EB; +} + +.loginButton:hover { + background-color: var(--color-secondary-hover); +} + +.registerLink { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; +} + +.registerLink span { + color: #E5E7EB; + font-size: 0.8rem; +} + +.textButton { + background: none; + border: none; + color: var(--color-secondary); + font-weight: 500; + cursor: pointer; + padding: 0; + font-size: 0.9rem; + font-family: var(--font-family); +} + +.textButton:hover { + text-decoration: underline; +} + +button:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.error { + color: var(--color-secondary); + background-color: var(--color-secondary-disabled); + border: 1px solid var(--color-secondary); + border-radius: 25px; + padding: 12px; + font-size: 0.9rem; + text-align: center; + font-family: var(--font-family); + margin-bottom: 10px; +} + +.success { + color: #10b981; + background-color: rgba(16, 185, 129, 0.1); + border: 1px solid #10b981; + border-radius: 25px; + padding: 12px; + font-size: 0.9rem; + text-align: center; + font-family: var(--font-family); + margin-bottom: 10px; +} diff --git a/src/pages/Reset.tsx b/src/pages/Reset.tsx new file mode 100644 index 0000000..b3a0066 --- /dev/null +++ b/src/pages/Reset.tsx @@ -0,0 +1,219 @@ +import { useState, useEffect } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; + +import styles from './Reset.module.css'; +import { usePasswordReset } from '../hooks/useAuthentication'; +import { generateAndStoreCSRFToken } from '../utils/csrfUtils'; + +function Reset() { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const { resetPassword, isLoading, error } = usePasswordReset(); + + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [passwordFocused, setPasswordFocused] = useState(false); + const [confirmPasswordFocused, setConfirmPasswordFocused] = useState(false); + const [validationError, setValidationError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const [tokenError, setTokenError] = useState(null); + + // Get token from URL + const token = searchParams.get('token'); + + // Set page title and generate CSRF token + useEffect(() => { + document.title = "PowerOn AI Platform - Neues Passwort setzen"; + generateAndStoreCSRFToken(); + + // Validate token exists and format + if (!token) { + setTokenError('Ungültiger Reset-Link. Bitte fordern Sie einen neuen Link an.'); + } else if (!_isValidUUID(token)) { + setTokenError('Ungültiger Reset-Link. Bitte fordern Sie einen neuen Link an.'); + } + }, [token]); + + const _isValidUUID = (str: string): boolean => { + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + return uuidRegex.test(str); + }; + + const validateForm = (): boolean => { + if (!password || password.length < 8) { + setValidationError('Passwort muss mindestens 8 Zeichen lang sein.'); + return false; + } + + if (password !== confirmPassword) { + setValidationError('Die Passwörter stimmen nicht überein.'); + return false; + } + + return true; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setValidationError(null); + + if (!validateForm()) { + return; + } + + if (!token) { + setValidationError('Token fehlt. Bitte fordern Sie einen neuen Reset-Link an.'); + return; + } + + try { + await resetPassword(token, password); + setSuccessMessage('Passwort erfolgreich gesetzt! Sie werden zum Login weitergeleitet...'); + + // Redirect to login after delay + setTimeout(() => { + navigate('/login', { + state: { + passwordReset: true, + message: 'Passwort erfolgreich geändert. Bitte melden Sie sich an.' + } + }); + }, 3000); + } catch (err: any) { + // Error is already set by the hook + const errorMessage = err?.response?.data?.detail || err?.message || 'Passwort-Zurücksetzung fehlgeschlagen.'; + if (errorMessage.includes('abgelaufen') || errorMessage.includes('expired') || errorMessage.includes('Ungültig') || errorMessage.includes('invalid')) { + setValidationError('Der Reset-Link ist ungültig oder abgelaufen. Bitte fordern Sie einen neuen Link an.'); + } else { + setValidationError(errorMessage); + } + } + }; + + // Show token error if invalid + if (tokenError) { + return ( +
+
+
+
+ Power + On +
+
+
+
+

Neues Passwort setzen

+
+
{tokenError}
+
+ +
+
+ oder zurück zum + +
+
+
+
+
+
+ ); + } + + return ( +
+
+
+
+ Power + On +
+
+
+
+

Neues Passwort setzen

+
+ {(validationError || error) && ( +
{validationError || error}
+ )} + + {successMessage && ( +
{successMessage}
+ )} + + {!successMessage && ( +
+
+ { + setPassword(e.target.value); + setValidationError(null); + }} + onFocus={() => setPasswordFocused(true)} + onBlur={() => setPasswordFocused(false)} + className={`${styles.input} ${passwordFocused || password ? styles.focused : ''}`} + autoComplete="new-password" + /> + +
+
Mindestens 8 Zeichen
+ +
+ { + setConfirmPassword(e.target.value); + setValidationError(null); + }} + onFocus={() => setConfirmPasswordFocused(true)} + onBlur={() => setConfirmPasswordFocused(false)} + className={`${styles.input} ${confirmPasswordFocused || confirmPassword ? styles.focused : ''}`} + autoComplete="new-password" + /> + +
+ + +
+ )} + +
+ Zurück zum + +
+
+
+
+
+
+ ); +} + +export default Reset;