diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index 7462abd2c..f2396e03d 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -42,6 +42,36 @@ describe("App, unauthenticated", () => {
}),
);
});
+
+ it("shows a session-expired screen with a working retry, not a bare error string", async () => {
+ // Live bug (2026-08-23): a raw IdP error string (Keycloak's literal
+ // "Token is not active" once the session's token expires) used to be
+ // the entire page -- a bare red line of un-translated text with no
+ // layout and no way back short of a manual reload.
+ mockAuth.error = new Error("Token is not active");
+ render();
+
+ expect(screen.getByText("Your session has expired.")).toBeInTheDocument();
+ // The raw detail stays visible for diagnostics, but is not the only content.
+ expect(screen.getByText("Token is not active")).toBeInTheDocument();
+
+ const button = screen.getByRole("button", { name: /log in again/i });
+ await userEvent.click(button);
+ expect(signinRedirect).toHaveBeenCalledTimes(1);
+ expect(signinRedirect).toHaveBeenCalledWith(
+ expect.objectContaining({
+ state: expect.objectContaining({ returnUrl: expect.stringMatching(/^\//) }),
+ }),
+ );
+ });
+
+ it("falls back to a generic authentication-error screen for an unrelated auth error", () => {
+ mockAuth.error = new Error("Network request failed");
+ render();
+
+ expect(screen.getByText("An authentication error occurred.")).toBeInTheDocument();
+ expect(screen.queryByText("Your session has expired.")).not.toBeInTheDocument();
+ });
});
function jsonResponse(body: unknown): Response {
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 86a1e9b26..08f053eb5 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -4596,7 +4596,46 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean
}
if (auth.error) {
- return
{t(auth.error.message)}
;
+ // A raw IdP error string (e.g. Keycloak's literal "Token is not active"
+ // once the session's access/refresh token expires) used to be dumped
+ // as the entire page: a bare red line of un-translated text with no
+ // layout, no explanation, and no way back in short of a manual reload.
+ // Session/token expiry is a routine, recoverable condition, not a
+ // fatal one -- give it the same login-card treatment as the normal
+ // login screen, with the raw detail kept as diagnostic-only text.
+ const isSessionExpired = /token|session/i.test(auth.error.message) &&
+ /expired|inactive|not active|invalid/i.test(auth.error.message);
+ return (
+