fix(frontend): show a banner when LoginGate can't reach the backend

LoginGate's initial session check swallowed every failure into the
same "unauthenticated" branch, so a genuinely unreachable backend
looked identical to a normal logged-out state -- the one screen in
the app where "backend not here" showed no feedback at all, since
every banner-migrated component only renders after authentication.

api/client.ts's request() now throws a dedicated NetworkError (a
distinct type, not just a distinguishable message) for a fetch()
failure specifically, so LoginGate.tsx's catch can tell that apart
from a real 401 via instanceof and show a banner before falling
through to "unauthenticated" either way.

Verified with a real headless-browser run against the dev server
with no backend: the banner renders correctly and coexists with the
existing ?auth_error= banner without conflict.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 11:00:00 +02:00
parent 0e3295b746
commit fe344867c0
2 changed files with 26 additions and 9 deletions

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api, BASE_URL } from "./api/client"; import { api, BASE_URL, NetworkError } from "./api/client";
import { showError } from "./banner"; import { showError } from "./banner";
import "./LoginGate.css"; import "./LoginGate.css";
import App from "./App"; import App from "./App";
@@ -34,7 +34,17 @@ export function LoginGate() {
setSession(s); setSession(s);
setStatus("authenticated"); setStatus("authenticated");
}) })
.catch(() => setStatus("unauthenticated")); .catch((e) => {
// A NetworkError here means the backend itself isn't reachable, not
// that this browser is genuinely logged out -- worth a banner, since
// otherwise this is the one place in the app where "backend not
// here" would show no feedback at all. Either way the screen still
// falls through to "unauthenticated" (showing the Log in button),
// since there's no session to trust regardless of why the check
// failed.
if (e instanceof NetworkError) showError(e.message);
setStatus("unauthenticated");
});
}, []); }, []);
// Keycloak redirects back here with ?auth_error=<code> when the // Keycloak redirects back here with ?auth_error=<code> when the

View File

@@ -15,6 +15,15 @@ import type {
export const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8080"; export const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8080";
// Thrown when fetch() itself rejects (offline, connection refused, CORS --
// the backend genuinely isn't reachable), as opposed to a normal non-2xx
// HTTP response. A distinct type, not just a distinguishable message
// string, so a caller that needs to tell the two apart (LoginGate.tsx's
// initial session check, which must not treat "backend unreachable" the
// same as "genuinely not logged in") can check `instanceof` rather than
// matching on text that's free to change.
export class NetworkError extends Error {}
async function request<T>(path: string, init?: RequestInit): Promise<T> { async function request<T>(path: string, init?: RequestInit): Promise<T> {
let res: Response; let res: Response;
try { try {
@@ -24,13 +33,11 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
...init, ...init,
}); });
} catch { } catch {
// fetch() itself rejecting (not a non-2xx response, which is handled // The raw browser error text here (e.g. "TypeError: Failed to fetch")
// below) means the backend genuinely isn't reachable -- offline, // is not something to show a user; every caller's catch-and-showError
// connection refused, CORS, etc. The raw browser error text here // already displays whatever this throws, so the friendly text only
// (e.g. "TypeError: Failed to fetch") is not something to show a user; // needs to be written once, here.
// every caller's catch-and-showError already displays whatever this throw new NetworkError("Can't reach the server — check your connection.");
// throws, so the friendly text only needs to be written once, here.
throw new Error("Can't reach the server — check your connection.");
} }
if (res.status === 401 && path !== "/api/session/me") { if (res.status === 401 && path !== "/api/session/me") {
// An established session expired mid-use (not the initial "am I logged // An established session expired mid-use (not the initial "am I logged