From bad8769000747a788347cce56ef1942f97eb7961 Mon Sep 17 00:00:00 2001 From: Huarch Date: Wed, 9 Sep 2026 15:07:25 +0800 Subject: [PATCH] fix(auth): redirect expired sessions before permission checks --- .../auth/RoutePermissionGuard.test.tsx | 54 +++++++++++++++++++ src/components/auth/RoutePermissionGuard.tsx | 17 ++++++ .../auth/SessionExpiryDialog.test.tsx | 42 ++++++++++++++- src/components/auth/SessionExpiryDialog.tsx | 32 +++++++++-- 4 files changed, 140 insertions(+), 5 deletions(-) create mode 100644 src/components/auth/RoutePermissionGuard.test.tsx diff --git a/src/components/auth/RoutePermissionGuard.test.tsx b/src/components/auth/RoutePermissionGuard.test.tsx new file mode 100644 index 0000000..08f8051 --- /dev/null +++ b/src/components/auth/RoutePermissionGuard.test.tsx @@ -0,0 +1,54 @@ +import { render, screen } from "@testing-library/react"; + +import { useAccessStore } from "@/store/accessStore"; +import { useAuthStore } from "@/store/authStore"; +import { RoutePermissionGuard } from "./RoutePermissionGuard"; + +jest.mock("next/navigation", () => ({ + usePathname: () => "/network-simulation", +})); + +describe("RoutePermissionGuard", () => { + beforeEach(() => { + useAccessStore.setState({ + context: null, + permissions: [], + loading: false, + }); + useAuthStore.setState({ + accessToken: null, + sessionExpired: false, + sessionExpiryReason: null, + }); + }); + + it("prioritizes an expired session over a missing route permission", () => { + useAuthStore.setState({ + sessionExpired: true, + sessionExpiryReason: "unauthorized", + }); + + render( + +
受保护内容
+
, + ); + + expect(screen.getByText("登录状态已失效")).toBeInTheDocument(); + expect(screen.getByText("正在跳转到登录页面…")).toBeInTheDocument(); + expect(screen.queryByText("无权访问此功能")).not.toBeInTheDocument(); + expect(screen.queryByText(/simulation\.view/)).not.toBeInTheDocument(); + expect(screen.queryByText("受保护内容")).not.toBeInTheDocument(); + }); + + it("shows the permission error when the session is still valid", () => { + render( + +
受保护内容
+
, + ); + + expect(screen.getByText("无权访问此功能")).toBeInTheDocument(); + expect(screen.getByText(/simulation\.view/)).toBeInTheDocument(); + }); +}); diff --git a/src/components/auth/RoutePermissionGuard.tsx b/src/components/auth/RoutePermissionGuard.tsx index 0c455c0..4894fb1 100644 --- a/src/components/auth/RoutePermissionGuard.tsx +++ b/src/components/auth/RoutePermissionGuard.tsx @@ -7,6 +7,7 @@ import type { ReactNode } from "react"; import { permissionForPath } from "@/lib/permissions"; import { useAccessStore } from "@/store/accessStore"; +import { useAuthStore } from "@/store/authStore"; export const RoutePermissionGuard = ({ children, @@ -16,8 +17,24 @@ export const RoutePermissionGuard = ({ const pathname = usePathname(); const permissions = useAccessStore((state) => state.permissions); const loading = useAccessStore((state) => state.loading); + const sessionExpired = useAuthStore((state) => state.sessionExpired); const requiredPermission = permissionForPath(pathname); + if (sessionExpired) { + return ( + + + + 登录状态已失效 + + 正在跳转到登录页面… + + + + + ); + } + if (requiredPermission && loading) { return ( diff --git a/src/components/auth/SessionExpiryDialog.test.tsx b/src/components/auth/SessionExpiryDialog.test.tsx index ab1e778..b43e8d3 100644 --- a/src/components/auth/SessionExpiryDialog.test.tsx +++ b/src/components/auth/SessionExpiryDialog.test.tsx @@ -1,5 +1,6 @@ import { createTheme, ThemeProvider } from "@mui/material/styles"; -import { act, render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { signIn } from "next-auth/react"; import { useAuthStore } from "@/store/authStore"; import { SessionExpiryDialog } from "./SessionExpiryDialog"; @@ -10,6 +11,8 @@ jest.mock("next-auth/react", () => ({ describe("SessionExpiryDialog", () => { beforeEach(() => { + jest.useFakeTimers(); + jest.mocked(signIn).mockReset().mockResolvedValue(undefined); useAuthStore.setState({ accessToken: null, sessionExpired: true, @@ -19,6 +22,7 @@ describe("SessionExpiryDialog", () => { afterEach(() => { act(() => useAuthStore.getState().clearSessionExpired()); + jest.useRealTimers(); }); it("renders above every regular application overlay", () => { @@ -36,4 +40,40 @@ describe("SessionExpiryDialog", () => { zIndex: theme.zIndex.tooltip + 1, }); }); + + it("shows the expired state before automatically starting login", () => { + window.history.replaceState({}, "", "/network-simulation?tab=history"); + + render(); + + expect(screen.getByText("登录已过期")).toBeInTheDocument(); + expect(screen.getByText("即将自动跳转到登录页面。")) + .toBeInTheDocument(); + expect(signIn).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(2_000); + }); + + expect(signIn).toHaveBeenCalledTimes(1); + expect(signIn).toHaveBeenCalledWith("keycloak", { + callbackUrl: "/network-simulation?tab=history", + redirect: true, + }); + }); + + it("allows a retry when starting automatic login fails", async () => { + jest.mocked(signIn) + .mockRejectedValueOnce(new Error("network unavailable")) + .mockResolvedValueOnce(undefined); + + render(); + + await act(async () => { + jest.advanceTimersByTime(2_000); + }); + fireEvent.click(screen.getByRole("button", { name: "重新认证" })); + + expect(signIn).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/components/auth/SessionExpiryDialog.tsx b/src/components/auth/SessionExpiryDialog.tsx index c42ecd9..2f2e87d 100644 --- a/src/components/auth/SessionExpiryDialog.tsx +++ b/src/components/auth/SessionExpiryDialog.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { signIn } from "next-auth/react"; import AccessTimeOutlinedIcon from "@mui/icons-material/AccessTimeOutlined"; import { @@ -18,6 +18,7 @@ import { useTheme } from "@mui/material/styles"; import { useAuthStore } from "@/store/authStore"; const WARNING_WINDOW_MS = 15 * 60 * 1000; +const EXPIRED_REDIRECT_DELAY_MS = 2_000; type SessionExpiryDialogProps = { expiresAt?: number; @@ -31,6 +32,7 @@ export const SessionExpiryDialog = ({ const reason = useAuthStore((state) => state.sessionExpiryReason); const [now, setNow] = useState(() => Date.now()); const [warningDismissed, setWarningDismissed] = useState(false); + const redirectStartedRef = useRef(false); useEffect(() => { const timer = window.setInterval(() => setNow(Date.now()), 30_000); @@ -47,10 +49,27 @@ export const SessionExpiryDialog = ({ [expiresAt, now, sessionExpired, warningDismissed], ); - const handleReauthenticate = () => { + const handleReauthenticate = useCallback(() => { + if (redirectStartedRef.current) return; + redirectStartedRef.current = true; const callbackUrl = `${window.location.pathname}${window.location.search}`; - void signIn("keycloak", { callbackUrl, redirect: true }); - }; + void signIn("keycloak", { callbackUrl, redirect: true }).catch(() => { + redirectStartedRef.current = false; + }); + }, []); + + useEffect(() => { + if (!sessionExpired) { + redirectStartedRef.current = false; + return; + } + + const timer = window.setTimeout( + handleReauthenticate, + EXPIRED_REDIRECT_DELAY_MS, + ); + return () => window.clearTimeout(timer); + }, [handleReauthenticate, sessionExpired]); const isOpen = sessionExpired || isExpiringSoon; const title = sessionExpired ? "登录已过期" : "登录即将到期"; @@ -77,6 +96,11 @@ export const SessionExpiryDialog = ({ 重新认证不会自动重放已失败的写入请求;请在返回后确认内容并再次提交。 + {sessionExpired && ( + + 即将自动跳转到登录页面。 + + )}