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 && (
+
+ 即将自动跳转到登录页面。
+
+ )}