From 0b9b55d23752a982f4debfc89986f1aeeb19d14f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 15:25:00 +0900 Subject: [PATCH] fix(frontend): style the session-expired auth error screen Bare unstyled red text ("Token is not active" etc.) rendered on a blank page when the OIDC session expired. Replace it with a login-card-styled screen that offers a "Log in again" action preserving the return URL, matching the pattern already used for the initial login screen and the #433 return-URL fix. --- frontend/src/App.test.tsx | 30 ++++++++++++++++++++++++++++ frontend/src/App.tsx | 41 ++++++++++++++++++++++++++++++++++++++- frontend/src/i18n.ts | 16 +++++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) 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 ( +
+
+
+
+

{brandName}

+

+ {isSessionExpired + ? t("Your session has expired.") + : t("An authentication error occurred.")} +

+
+
+ +
+

+ {auth.error.message} +

+
+
+
+ ); } if (!auth.isAuthenticated) { diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 650acfca8..b6d576cbc 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -34,6 +34,10 @@ const TRANSLATIONS: Partial>> = { "Loading authentication state...": "인증 상태를 불러오는 중...", "Authenticated, but no access token was returned.": "인증되었지만 액세스 토큰이 반환되지 않았습니다.", "Log in": "로그인", + "Log in again": "다시 로그인", + "Your session has expired.": "세션이 만료되었습니다.", + "An authentication error occurred.": "인증 오류가 발생했습니다.", + "Technical detail": "기술적 세부 정보", "Log out": "로그아웃", Calendar: "캘린더", "CalDAV events": "CalDAV 이벤트", @@ -373,6 +377,10 @@ const TRANSLATIONS: Partial>> = { "Loading authentication state...": "正在加载身份验证状态...", "Authenticated, but no access token was returned.": "已完成身份验证,但未返回访问令牌。", "Log in": "登录", + "Log in again": "重新登录", + "Your session has expired.": "您的会话已过期。", + "An authentication error occurred.": "发生身份验证错误。", + "Technical detail": "技术详情", "Log out": "退出登录", Calendar: "日历", "CalDAV events": "CalDAV 事件", @@ -735,6 +743,10 @@ const TRANSLATIONS: Partial>> = { "Loading authentication state...": "認証状態を読み込んでいます...", "Authenticated, but no access token was returned.": "認証済みですが、アクセストークンが返されませんでした。", "Log in": "ログイン", + "Log in again": "再ログイン", + "Your session has expired.": "セッションの有効期限が切れました。", + "An authentication error occurred.": "認証エラーが発生しました。", + "Technical detail": "技術的な詳細", "Log out": "ログアウト", Calendar: "カレンダー", "CalDAV events": "CalDAV イベント", @@ -1073,6 +1085,10 @@ const TRANSLATIONS: Partial>> = { "Loading authentication state...": "Đang tải trạng thái xác thực...", "Authenticated, but no access token was returned.": "Đã xác thực nhưng không nhận được mã thông báo truy cập.", "Log in": "Đăng nhập", + "Log in again": "Đăng nhập lại", + "Your session has expired.": "Phiên làm việc của bạn đã hết hạn.", + "An authentication error occurred.": "Đã xảy ra lỗi xác thực.", + "Technical detail": "Chi tiết kỹ thuật", "Log out": "Đăng xuất", Calendar: "Lịch", "CalDAV events": "Sự kiện CalDAV",