diff --git a/frontend/src/LoginGate.tsx b/frontend/src/LoginGate.tsx index ea2e7aa..944a6eb 100644 --- a/frontend/src/LoginGate.tsx +++ b/frontend/src/LoginGate.tsx @@ -1,5 +1,5 @@ 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 "./LoginGate.css"; import App from "./App"; @@ -34,7 +34,17 @@ export function LoginGate() { setSession(s); 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= when the diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 42c83bb..1442137 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -15,6 +15,15 @@ import type { 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(path: string, init?: RequestInit): Promise { let res: Response; try { @@ -24,13 +33,11 @@ async function request(path: string, init?: RequestInit): Promise { ...init, }); } catch { - // fetch() itself rejecting (not a non-2xx response, which is handled - // below) means the backend genuinely isn't reachable -- offline, - // connection refused, CORS, etc. The raw browser error text here - // (e.g. "TypeError: Failed to fetch") is not something to show a user; - // every caller's catch-and-showError already displays whatever this - // throws, so the friendly text only needs to be written once, here. - throw new Error("Can't reach the server — check your connection."); + // The raw browser error text here (e.g. "TypeError: Failed to fetch") + // is not something to show a user; every caller's catch-and-showError + // already displays whatever this throws, so the friendly text only + // needs to be written once, here. + throw new NetworkError("Can't reach the server — check your connection."); } if (res.status === 401 && path !== "/api/session/me") { // An established session expired mid-use (not the initial "am I logged