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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user