fix(auth): redirect expired sessions before permission checks
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user