Web: abgelaufene Anmeldung erkennen statt leere Seiten zeigen
Zwei Defekte, die zusammen das gemeldete Muster ergeben.
1) Es gab keine zentrale Behandlung von HTTP 401. AuthProvider prueft die
Anmeldung EINMAL beim Start; laeuft das Token danach ab, bleibt der Zustand
"angemeldet" stehen, waehrend jede Abfrage scheitert. Seiten mit
.catch(() => {}) (18 Stellen) zeigen dann schlicht nichts, andere eine
englische Servermeldung - genau das "mal gar nichts, mal die Haelfte".
Jetzt raeumt der API-Client bei 401 die Sitzung auf und der Login sagt, dass
sie abgelaufen ist. Beim Anmelden selbst greift das nicht, dort heisst 401
"Passwort falsch".
2) Der Start hatte ein blankes `catch`, das das Token bei JEDEM Fehler loeschte -
auch bei 502 oder weggebrochenem Netz. Wer waehrend eines Server-Neustarts F5
drueckte, war deshalb abgemeldet, obwohl die Sitzung voellig in Ordnung war.
Das duerfte der haeufigere Ausloeser gewesen sein, weil in letzter Zeit oft neu
deployt wurde. Jetzt wird nur bei 401/403 abgemeldet; bei allem anderen bleibt
die Anmeldung liegen und es erscheint "Server nicht erreichbar" mit einem
Knopf zum erneuten Versuchen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -115,9 +115,31 @@ function Sidebar() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Server nicht erreichbar – die Anmeldung bleibt bestehen, nur der Abruf nicht. */
|
||||||
|
function StartFehler({ text, erneut }) {
|
||||||
|
return (
|
||||||
|
<div className="center" style={{ padding: 60 }}>
|
||||||
|
<div className="alert error" style={{ maxWidth: 520 }}>
|
||||||
|
<Icon name="alert" size={16} />
|
||||||
|
<div>
|
||||||
|
<div>{text}</div>
|
||||||
|
<div className="muted small" style={{ marginTop: 4 }}>
|
||||||
|
Deine Anmeldung ist noch gültig – sobald der Server wieder antwortet,
|
||||||
|
geht es weiter.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn primary" style={{ marginTop: 16 }} onClick={erneut}>
|
||||||
|
Erneut versuchen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Protected({ children, adminOnly = false, wide = false }) {
|
function Protected({ children, adminOnly = false, wide = false }) {
|
||||||
const { user, isAdmin, loading } = useAuth();
|
const { user, isAdmin, loading, startFehler, erneutVersuchen } = useAuth();
|
||||||
if (loading) return <div className="center muted" style={{ padding: 60 }}>Lädt…</div>;
|
if (loading) return <div className="center muted" style={{ padding: 60 }}>Lädt…</div>;
|
||||||
|
if (startFehler) return <StartFehler text={startFehler} erneut={erneutVersuchen} />;
|
||||||
if (!user) return <Navigate to="/login" replace />;
|
if (!user) return <Navigate to="/login" replace />;
|
||||||
if (adminOnly && !isAdmin) return <Navigate to="/" replace />;
|
if (adminOnly && !isAdmin) return <Navigate to="/" replace />;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -15,6 +15,14 @@ export function setToken(token) {
|
|||||||
else localStorage.removeItem(TOKEN_KEY);
|
else localStorage.removeItem(TOKEN_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wird bei einer abgelaufenen/ungueltigen Anmeldung gerufen (HTTP 401).
|
||||||
|
// Ohne das bleibt die Oberflaeche „angemeldet", waehrend jede Abfrage scheitert –
|
||||||
|
// man sieht dann leere Seiten statt eines Hinweises.
|
||||||
|
let beiAbmeldung = null;
|
||||||
|
export function setUnauthorizedHandler(fn) {
|
||||||
|
beiAbmeldung = fn;
|
||||||
|
}
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
constructor(message, status) {
|
constructor(message, status) {
|
||||||
super(message);
|
super(message);
|
||||||
@@ -41,6 +49,12 @@ async function request(path, { method = "GET", body, form, formData } = {}) {
|
|||||||
|
|
||||||
const resp = await fetch(`${API_BASE}${path}`, { method, headers, body: payload });
|
const resp = await fetch(`${API_BASE}${path}`, { method, headers, body: payload });
|
||||||
|
|
||||||
|
// Abgelaufene Anmeldung: EINMAL zentral behandeln statt in jeder Seite.
|
||||||
|
// Beim Anmelden selbst nicht – dort heisst 401 schlicht „Passwort falsch".
|
||||||
|
if (resp.status === 401 && path !== "/auth/login") {
|
||||||
|
beiAbmeldung?.();
|
||||||
|
}
|
||||||
|
|
||||||
if (resp.status === 204) return null;
|
if (resp.status === 204) return null;
|
||||||
|
|
||||||
let data = null;
|
let data = null;
|
||||||
|
|||||||
@@ -1,24 +1,56 @@
|
|||||||
import { createContext, useContext, useEffect, useState } from "react";
|
import { createContext, useContext, useEffect, useState } from "react";
|
||||||
import { api, getToken, setToken } from "./api";
|
import { api, ApiError, getToken, setToken, setUnauthorizedHandler } from "./api";
|
||||||
|
|
||||||
const AuthContext = createContext(null);
|
const AuthContext = createContext(null);
|
||||||
|
|
||||||
export function AuthProvider({ children }) {
|
export function AuthProvider({ children }) {
|
||||||
const [user, setUser] = useState(null);
|
const [user, setUser] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
// Anmeldung war da, ist aber abgelaufen – der Login-Bildschirm sagt warum.
|
||||||
|
const [abgelaufen, setAbgelaufen] = useState(false);
|
||||||
|
// Start fehlgeschlagen, ABER nicht wegen der Anmeldung (Server weg, Netz weg).
|
||||||
|
// Dann bleibt das Token liegen; alles andere hiesse, ein kurzer Aussetzer
|
||||||
|
// meldet einen ab.
|
||||||
|
const [startFehler, setStartFehler] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
async function pruefen() {
|
||||||
async function boot() {
|
setStartFehler(null);
|
||||||
if (getToken()) {
|
if (!getToken()) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
setUser(await api.me());
|
setUser(await api.me());
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
// NUR bei abgelehnter Anmeldung abmelden. Ein 502 waehrend eines
|
||||||
|
// Server-Neustarts ist kein Grund, die Sitzung wegzuwerfen.
|
||||||
|
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
|
||||||
setToken(null);
|
setToken(null);
|
||||||
|
setUser(null);
|
||||||
|
} else if (err instanceof ApiError) {
|
||||||
|
setStartFehler(err.message);
|
||||||
|
} else {
|
||||||
|
// Kein HTTP-Fehler, sondern gar keine Antwort (Netz weg, Server aus).
|
||||||
|
// Die Browser-Meldung („Failed to fetch") hilft niemandem.
|
||||||
|
setStartFehler("Server nicht erreichbar.");
|
||||||
}
|
}
|
||||||
}
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
boot();
|
}
|
||||||
|
|
||||||
|
useEffect(() => { pruefen(); }, []);
|
||||||
|
|
||||||
|
// Laeuft die Anmeldung waehrend der Nutzung ab, scheitert ab da JEDE Abfrage.
|
||||||
|
// Ohne diesen Griff bliebe die Oberflaeche „angemeldet" und zeigte nur noch
|
||||||
|
// leere Seiten – genau das war der Fehler.
|
||||||
|
useEffect(() => {
|
||||||
|
setUnauthorizedHandler(() => {
|
||||||
|
setToken(null);
|
||||||
|
setUser(null);
|
||||||
|
setAbgelaufen(true);
|
||||||
|
});
|
||||||
|
return () => setUnauthorizedHandler(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function login(username, password) {
|
async function login(username, password) {
|
||||||
@@ -26,18 +58,29 @@ export function AuthProvider({ children }) {
|
|||||||
setToken(res.access_token);
|
setToken(res.access_token);
|
||||||
const me = await api.me();
|
const me = await api.me();
|
||||||
setUser(me);
|
setUser(me);
|
||||||
|
setAbgelaufen(false);
|
||||||
|
setStartFehler(null);
|
||||||
return me;
|
return me;
|
||||||
}
|
}
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
setToken(null);
|
setToken(null);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
|
setAbgelaufen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function erneutVersuchen() {
|
||||||
|
setLoading(true);
|
||||||
|
await pruefen();
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAdmin = user?.role === "admin";
|
const isAdmin = user?.role === "admin";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, isAdmin, loading, login, logout }}>
|
<AuthContext.Provider value={{
|
||||||
|
user, isAdmin, loading, login, logout,
|
||||||
|
abgelaufen, startFehler, erneutVersuchen,
|
||||||
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Icon from "../components/Icon";
|
|||||||
import BrandMark from "../components/BrandMark";
|
import BrandMark from "../components/BrandMark";
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const { login } = useAuth();
|
const { login, abgelaufen } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
@@ -33,6 +33,11 @@ export default function Login() {
|
|||||||
<BrandMark />
|
<BrandMark />
|
||||||
</div>
|
</div>
|
||||||
<p className="lead">Lebensmittel-Lagerverwaltung</p>
|
<p className="lead">Lebensmittel-Lagerverwaltung</p>
|
||||||
|
{abgelaufen && !error && (
|
||||||
|
<div className="alert info"><Icon name="alert" size={16} />
|
||||||
|
Deine Sitzung ist abgelaufen – bitte melde dich neu an.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="alert error"><Icon name="alert" size={16} />{error}</div>
|
<div className="alert error"><Icon name="alert" size={16} />{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user