fix(auth): redirect expired sessions before permission checks

This commit is contained in:
2026-09-09 15:07:25 +08:00
parent 29b8babd68
commit bad8769000
4 changed files with 140 additions and 5 deletions
@@ -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(
<RoutePermissionGuard>
<div></div>
</RoutePermissionGuard>,
);
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(
<RoutePermissionGuard>
<div></div>
</RoutePermissionGuard>,
);
expect(screen.getByText("无权访问此功能")).toBeInTheDocument();
expect(screen.getByText(/simulation\.view/)).toBeInTheDocument();
});
});
@@ -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 (
<Box sx={{ p: 3 }}>
<Alert severity="warning">
<Stack spacing={0.5}>
<Typography variant="body1"></Typography>
<Typography variant="body2">
</Typography>
</Stack>
</Alert>
</Box>
);
}
if (requiredPermission && loading) {
return (
<Box sx={{ minHeight: 320, display: "grid", placeItems: "center" }}>
@@ -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(<SessionExpiryDialog />);
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(<SessionExpiryDialog />);
await act(async () => {
jest.advanceTimersByTime(2_000);
});
fireEvent.click(screen.getByRole("button", { name: "重新认证" }));
expect(signIn).toHaveBeenCalledTimes(2);
});
});
+28 -4
View File
@@ -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 = ({
<Typography variant="body2" color="text.secondary">
</Typography>
{sessionExpired && (
<Typography variant="body2" color="text.secondary">
</Typography>
)}
</Stack>
</DialogContent>
<DialogActions>