fix(frontend): Reset-Link auswerten, Absturz bei defektem localStorage verhindern

- PasswordReset las den Query-Parameter token nie aus. Wer auf den Link
  in der Reset-Mail klickte, landete wieder auf Schritt 1 mit leerem
  Token-Feld — der Mailversand aus d9fecb6 lief damit ins Leere. Der
  Token wird jetzt aus der URL übernommen und direkt Schritt 2 gezeigt.
- "Zurück zum Login" und die Weiterleitung nach dem Zurücksetzen zeigten
  auf "/" und damit beim Unterpfad-Deployment auf das Portal.
- PublicUserList rief JSON.parse(localStorage.getItem(...)) ungeschützt
  im Render-Pfad auf. Ein beschädigter Eintrag — oder ein Browser, der
  Site-Data blockiert — ließ die gesamte öffentliche Liste weiß werden.
  Lesen und Schreiben laufen jetzt über try/catch, und der Wert wird auf
  Plausibilität geprüft.
- Der Typfilter-Effekt feuerte auch beim Mounten, obwohl useUsers bereits
  selbst lädt: jeder Aufruf der öffentlichen Liste setzte zwei identische
  Anfragen ab. Der erste Lauf wird jetzt übersprungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
thomas 2026-08-25 19:42:37 +02:00
parent 868699a1bc
commit f4939ef6c5
6 changed files with 168 additions and 42 deletions

View File

@ -1,11 +1,29 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { requestPasswordReset, resetPassword } from '../../services/passwordReset'; import { requestPasswordReset, resetPassword } from '../../services/passwordReset';
import { BASE_PATH } from '../../utils/constants';
import './PasswordReset.css'; import './PasswordReset.css';
// Ziel nach dem Zuruecksetzen bzw. fuer "Zurueck zum Login": die App selbst.
// Ein absolutes "/" landet beim Unterpfad-Deployment auf dem Portal.
const APP_HOME = `${BASE_PATH}/`;
// Token aus dem Link der Reset-Mail lesen (…/passwort-zuruecksetzen?token=…).
// Ohne das landete jeder, der auf den gemailten Link klickt, wieder auf Schritt 1
// mit leerem Token-Feld der Mailversand war damit wirkungslos.
const tokenFromUrl = () => {
if (typeof window === 'undefined') return '';
try {
return new URLSearchParams(window.location.search).get('token') || '';
} catch {
return '';
}
};
function PasswordReset() { function PasswordReset() {
const [step, setStep] = useState('request'); // 'request' or 'reset' const initialToken = tokenFromUrl();
const [step, setStep] = useState(initialToken ? 'reset' : 'request');
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [token, setToken] = useState(''); const [token, setToken] = useState(initialToken);
const [newPassword, setNewPassword] = useState(''); const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -62,7 +80,7 @@ function PasswordReset() {
setToken(''); setToken('');
setNewPassword(''); setNewPassword('');
setConfirmPassword(''); setConfirmPassword('');
window.location.href = '/'; // Redirect to login window.location.href = APP_HOME; // Redirect to login
}, 2000); }, 2000);
} else { } else {
setMessage({ type: 'error', text: result.message }); setMessage({ type: 'error', text: result.message });
@ -100,7 +118,7 @@ function PasswordReset() {
</button> </button>
<div className="form-footer"> <div className="form-footer">
<a href="/">Zurück zum Login</a> <a href={APP_HOME}>Zurück zum Login</a>
</div> </div>
</form> </form>
) : ( ) : (

View File

@ -1,4 +1,4 @@
import React, { useState, useMemo, useEffect, useCallback } from 'react'; import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import { useConfigContext } from '../../contexts/ConfigContext'; import { useConfigContext } from '../../contexts/ConfigContext';
import MapView from '../map/MapView'; import MapView from '../map/MapView';
import FilterPanel from '../users/FilterPanel'; import FilterPanel from '../users/FilterPanel';
@ -6,18 +6,32 @@ import { calculateDistance } from '../../utils/helpers';
import { getGeocodeByPostalCode } from '../../services/users'; import { getGeocodeByPostalCode } from '../../services/users';
import './PublicUserList.css'; import './PublicUserList.css';
const LOCATION_KEY = 'userLocation';
// localStorage kann werfen (privater Modus, blockierte Site-Data) und der Inhalt
// kann beschaedigt sein. Beides darf die Seite nicht mitreissen.
const readStoredLocation = () => {
try {
const raw = localStorage.getItem(LOCATION_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
return (typeof parsed?.lat === 'number' && typeof parsed?.lng === 'number') ? parsed : null;
} catch {
return null;
}
};
const PublicUserList = ({ users, loading, onRefetch }) => { const PublicUserList = ({ users, loading, onRefetch }) => {
const { userTypeLabels } = useConfigContext(); const { userTypeLabels } = useConfigContext();
// Load saved location from localStorage on mount
const savedLocation = localStorage.getItem('userLocation');
const initialCoords = savedLocation ? JSON.parse(savedLocation) : null;
const [showMap, setShowMap] = useState(true); const [showMap, setShowMap] = useState(true);
const [showFilters, setShowFilters] = useState(false); const [showFilters, setShowFilters] = useState(false);
const [locationStatus, setLocationStatus] = useState(initialCoords ? 'granted' : 'idle'); // Gespeicherten Standort lazy und abgesichert lesen. Vorher stand ein
// ungeschuetztes JSON.parse im Render-Pfad: ein beschaedigter Eintrag - oder ein
// Browser, der Site-Data blockiert - hat die gesamte Liste weiss werden lassen.
const [coords, setCoords] = useState(readStoredLocation);
const [locationStatus, setLocationStatus] = useState(coords ? 'granted' : 'idle');
const [locationError, setLocationError] = useState(''); const [locationError, setLocationError] = useState('');
const [coords, setCoords] = useState(initialCoords);
const [radiusKm, setRadiusKm] = useState(100); const [radiusKm, setRadiusKm] = useState(100);
const [postalCode, setPostalCode] = useState(''); const [postalCode, setPostalCode] = useState('');
const [postalSearching, setPostalSearching] = useState(false); const [postalSearching, setPostalSearching] = useState(false);
@ -28,8 +42,11 @@ const PublicUserList = ({ users, loading, onRefetch }) => {
// Save location to localStorage whenever it changes // Save location to localStorage whenever it changes
useEffect(() => { useEffect(() => {
if (coords) { if (!coords) return;
localStorage.setItem('userLocation', JSON.stringify(coords)); try {
localStorage.setItem(LOCATION_KEY, JSON.stringify(coords));
} catch {
// Speicher nicht verfuegbar - der Standort gilt dann nur fuer diese Sitzung.
} }
}, [coords]); }, [coords]);
@ -37,8 +54,15 @@ const PublicUserList = ({ users, loading, onRefetch }) => {
setFilters(prev => ({ ...prev, [key]: value })); setFilters(prev => ({ ...prev, [key]: value }));
}, []); }, []);
// Re-fetch from backend when type filter changes (server-side filtering) // Re-fetch from backend when type filter changes (server-side filtering).
// Der erste Lauf wird uebersprungen: useUsers laedt beim Mounten bereits selbst,
// sonst setzt jeder Seitenaufruf zwei identische Anfragen ab.
const skipInitialRefetch = useRef(true);
useEffect(() => { useEffect(() => {
if (skipInitialRefetch.current) {
skipInitialRefetch.current = false;
return;
}
if (onRefetch) { if (onRefetch) {
onRefetch(filters.type ? { type: filters.type } : {}); onRefetch(filters.type ? { type: filters.type } : {});
} }

View File

@ -1,11 +1,29 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { requestPasswordReset, resetPassword } from '../../services/passwordReset'; import { requestPasswordReset, resetPassword } from '../../services/passwordReset';
import { BASE_PATH } from '../../utils/constants';
import './PasswordReset.css'; import './PasswordReset.css';
// Ziel nach dem Zuruecksetzen bzw. fuer "Zurueck zum Login": die App selbst.
// Ein absolutes "/" landet beim Unterpfad-Deployment auf dem Portal.
const APP_HOME = `${BASE_PATH}/`;
// Token aus dem Link der Reset-Mail lesen (…/passwort-zuruecksetzen?token=…).
// Ohne das landete jeder, der auf den gemailten Link klickt, wieder auf Schritt 1
// mit leerem Token-Feld der Mailversand war damit wirkungslos.
const tokenFromUrl = () => {
if (typeof window === 'undefined') return '';
try {
return new URLSearchParams(window.location.search).get('token') || '';
} catch {
return '';
}
};
function PasswordReset() { function PasswordReset() {
const [step, setStep] = useState('request'); // 'request' or 'reset' const initialToken = tokenFromUrl();
const [step, setStep] = useState(initialToken ? 'reset' : 'request');
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [token, setToken] = useState(''); const [token, setToken] = useState(initialToken);
const [newPassword, setNewPassword] = useState(''); const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -62,7 +80,7 @@ function PasswordReset() {
setToken(''); setToken('');
setNewPassword(''); setNewPassword('');
setConfirmPassword(''); setConfirmPassword('');
window.location.href = '/'; // Redirect to login window.location.href = APP_HOME; // Redirect to login
}, 2000); }, 2000);
} else { } else {
setMessage({ type: 'error', text: result.message }); setMessage({ type: 'error', text: result.message });
@ -100,7 +118,7 @@ function PasswordReset() {
</button> </button>
<div className="form-footer"> <div className="form-footer">
<a href="/">Zurück zum Login</a> <a href={APP_HOME}>Zurück zum Login</a>
</div> </div>
</form> </form>
) : ( ) : (

View File

@ -1,4 +1,4 @@
import React, { useState, useMemo, useEffect, useCallback } from 'react'; import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import { useConfigContext } from '../../contexts/ConfigContext'; import { useConfigContext } from '../../contexts/ConfigContext';
import MapView from '../map/MapView'; import MapView from '../map/MapView';
import FilterPanel from '../users/FilterPanel'; import FilterPanel from '../users/FilterPanel';
@ -6,18 +6,32 @@ import { calculateDistance } from '../../utils/helpers';
import { getGeocodeByPostalCode } from '../../services/users'; import { getGeocodeByPostalCode } from '../../services/users';
import './PublicUserList.css'; import './PublicUserList.css';
const LOCATION_KEY = 'userLocation';
// localStorage kann werfen (privater Modus, blockierte Site-Data) und der Inhalt
// kann beschädigt sein. Beides darf die Seite nicht mitreißen.
const readStoredLocation = () => {
try {
const raw = localStorage.getItem(LOCATION_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
return (typeof parsed?.lat === 'number' && typeof parsed?.lng === 'number') ? parsed : null;
} catch {
return null;
}
};
const PublicUserList = ({ users, loading, onRefetch }) => { const PublicUserList = ({ users, loading, onRefetch }) => {
const { userTypeLabels } = useConfigContext(); const { userTypeLabels } = useConfigContext();
// Load saved location from localStorage on mount
const savedLocation = localStorage.getItem('userLocation');
const initialCoords = savedLocation ? JSON.parse(savedLocation) : null;
const [showMap, setShowMap] = useState(true); const [showMap, setShowMap] = useState(true);
const [showFilters, setShowFilters] = useState(false); const [showFilters, setShowFilters] = useState(false);
const [locationStatus, setLocationStatus] = useState(initialCoords ? 'granted' : 'idle'); // Gespeicherten Standort lazy und abgesichert lesen. Vorher stand ein
// ungeschütztes JSON.parse im Render-Pfad: ein beschädigter Eintrag oder ein
// Browser, der Site-Data blockiert hat die gesamte Liste weiß werden lassen.
const [coords, setCoords] = useState(readStoredLocation);
const [locationStatus, setLocationStatus] = useState(coords ? 'granted' : 'idle');
const [locationError, setLocationError] = useState(''); const [locationError, setLocationError] = useState('');
const [coords, setCoords] = useState(initialCoords);
const [radiusKm, setRadiusKm] = useState(100); const [radiusKm, setRadiusKm] = useState(100);
const [postalCode, setPostalCode] = useState(''); const [postalCode, setPostalCode] = useState('');
const [postalSearching, setPostalSearching] = useState(false); const [postalSearching, setPostalSearching] = useState(false);
@ -28,8 +42,11 @@ const PublicUserList = ({ users, loading, onRefetch }) => {
// Save location to localStorage whenever it changes // Save location to localStorage whenever it changes
useEffect(() => { useEffect(() => {
if (coords) { if (!coords) return;
localStorage.setItem('userLocation', JSON.stringify(coords)); try {
localStorage.setItem(LOCATION_KEY, JSON.stringify(coords));
} catch {
// Speicher nicht verfügbar der Standort gilt dann nur für diese Sitzung.
} }
}, [coords]); }, [coords]);
@ -37,8 +54,15 @@ const PublicUserList = ({ users, loading, onRefetch }) => {
setFilters(prev => ({ ...prev, [key]: value })); setFilters(prev => ({ ...prev, [key]: value }));
}, []); }, []);
// Re-fetch from backend when type filter changes (server-side filtering) // Re-fetch from backend when type filter changes (server-side filtering).
// Der erste Lauf wird übersprungen: useUsers lädt beim Mounten bereits selbst,
// sonst setzt jeder Seitenaufruf zwei identische Anfragen ab.
const skipInitialRefetch = useRef(true);
useEffect(() => { useEffect(() => {
if (skipInitialRefetch.current) {
skipInitialRefetch.current = false;
return;
}
if (onRefetch) { if (onRefetch) {
onRefetch(filters.type ? { type: filters.type } : {}); onRefetch(filters.type ? { type: filters.type } : {});
} }

View File

@ -1,11 +1,29 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { requestPasswordReset, resetPassword } from '../../services/passwordReset'; import { requestPasswordReset, resetPassword } from '../../services/passwordReset';
import { BASE_PATH } from '../../utils/constants';
import './PasswordReset.css'; import './PasswordReset.css';
// Ziel nach dem Zuruecksetzen bzw. fuer "Zurueck zum Login": die App selbst.
// Ein absolutes "/" landet beim Unterpfad-Deployment auf dem Portal.
const APP_HOME = `${BASE_PATH}/`;
// Token aus dem Link der Reset-Mail lesen (…/passwort-zuruecksetzen?token=…).
// Ohne das landete jeder, der auf den gemailten Link klickt, wieder auf Schritt 1
// mit leerem Token-Feld der Mailversand war damit wirkungslos.
const tokenFromUrl = () => {
if (typeof window === 'undefined') return '';
try {
return new URLSearchParams(window.location.search).get('token') || '';
} catch {
return '';
}
};
function PasswordReset() { function PasswordReset() {
const [step, setStep] = useState('request'); // 'request' or 'reset' const initialToken = tokenFromUrl();
const [step, setStep] = useState(initialToken ? 'reset' : 'request');
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [token, setToken] = useState(''); const [token, setToken] = useState(initialToken);
const [newPassword, setNewPassword] = useState(''); const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -62,7 +80,7 @@ function PasswordReset() {
setToken(''); setToken('');
setNewPassword(''); setNewPassword('');
setConfirmPassword(''); setConfirmPassword('');
window.location.href = '/'; // Redirect to login window.location.href = APP_HOME; // Redirect to login
}, 2000); }, 2000);
} else { } else {
setMessage({ type: 'error', text: result.message }); setMessage({ type: 'error', text: result.message });
@ -100,7 +118,7 @@ function PasswordReset() {
</button> </button>
<div className="form-footer"> <div className="form-footer">
<a href="/">Zurück zum Login</a> <a href={APP_HOME}>Zurück zum Login</a>
</div> </div>
</form> </form>
) : ( ) : (

View File

@ -1,4 +1,4 @@
import React, { useState, useMemo, useEffect, useCallback } from 'react'; import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import { useConfigContext } from '../../contexts/ConfigContext'; import { useConfigContext } from '../../contexts/ConfigContext';
import MapView from '../map/MapView'; import MapView from '../map/MapView';
import FilterPanel from '../users/FilterPanel'; import FilterPanel from '../users/FilterPanel';
@ -6,18 +6,32 @@ import { calculateDistance } from '../../utils/helpers';
import { getGeocodeByPostalCode } from '../../services/users'; import { getGeocodeByPostalCode } from '../../services/users';
import './PublicUserList.css'; import './PublicUserList.css';
const LOCATION_KEY = 'userLocation';
// localStorage kann werfen (privater Modus, blockierte Site-Data) und der Inhalt
// kann beschaedigt sein. Beides darf die Seite nicht mitreissen.
const readStoredLocation = () => {
try {
const raw = localStorage.getItem(LOCATION_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
return (typeof parsed?.lat === 'number' && typeof parsed?.lng === 'number') ? parsed : null;
} catch {
return null;
}
};
const PublicUserList = ({ users, loading, onRefetch }) => { const PublicUserList = ({ users, loading, onRefetch }) => {
const { userTypeLabels } = useConfigContext(); const { userTypeLabels } = useConfigContext();
// Load saved location from localStorage on mount
const savedLocation = localStorage.getItem('userLocation');
const initialCoords = savedLocation ? JSON.parse(savedLocation) : null;
const [showMap, setShowMap] = useState(true); const [showMap, setShowMap] = useState(true);
const [showFilters, setShowFilters] = useState(false); const [showFilters, setShowFilters] = useState(false);
const [locationStatus, setLocationStatus] = useState(initialCoords ? 'granted' : 'idle'); // Gespeicherten Standort lazy und abgesichert lesen. Vorher stand ein
// ungeschuetztes JSON.parse im Render-Pfad: ein beschaedigter Eintrag - oder ein
// Browser, der Site-Data blockiert - hat die gesamte Liste weiss werden lassen.
const [coords, setCoords] = useState(readStoredLocation);
const [locationStatus, setLocationStatus] = useState(coords ? 'granted' : 'idle');
const [locationError, setLocationError] = useState(''); const [locationError, setLocationError] = useState('');
const [coords, setCoords] = useState(initialCoords);
const [radiusKm, setRadiusKm] = useState(100); const [radiusKm, setRadiusKm] = useState(100);
const [postalCode, setPostalCode] = useState(''); const [postalCode, setPostalCode] = useState('');
const [postalSearching, setPostalSearching] = useState(false); const [postalSearching, setPostalSearching] = useState(false);
@ -28,8 +42,11 @@ const PublicUserList = ({ users, loading, onRefetch }) => {
// Save location to localStorage whenever it changes // Save location to localStorage whenever it changes
useEffect(() => { useEffect(() => {
if (coords) { if (!coords) return;
localStorage.setItem('userLocation', JSON.stringify(coords)); try {
localStorage.setItem(LOCATION_KEY, JSON.stringify(coords));
} catch {
// Speicher nicht verfuegbar - der Standort gilt dann nur fuer diese Sitzung.
} }
}, [coords]); }, [coords]);
@ -37,8 +54,15 @@ const PublicUserList = ({ users, loading, onRefetch }) => {
setFilters(prev => ({ ...prev, [key]: value })); setFilters(prev => ({ ...prev, [key]: value }));
}, []); }, []);
// Re-fetch from backend when type filter changes (server-side filtering) // Re-fetch from backend when type filter changes (server-side filtering).
// Der erste Lauf wird uebersprungen: useUsers laedt beim Mounten bereits selbst,
// sonst setzt jeder Seitenaufruf zwei identische Anfragen ab.
const skipInitialRefetch = useRef(true);
useEffect(() => { useEffect(() => {
if (skipInitialRefetch.current) {
skipInitialRefetch.current = false;
return;
}
if (onRefetch) { if (onRefetch) {
onRefetch(filters.type ? { type: filters.type } : {}); onRefetch(filters.type ? { type: filters.type } : {});
} }