diff --git a/src/app/RefineContext.test.tsx b/src/app/RefineContext.test.tsx
new file mode 100644
index 0000000..8b80725
--- /dev/null
+++ b/src/app/RefineContext.test.tsx
@@ -0,0 +1,137 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import type { ReactNode } from "react";
+
+import { useAccessStore } from "@/store/accessStore";
+import { useAuthStore } from "@/store/authStore";
+import { App } from "./RefineContext";
+
+let mockSessionState: {
+ data: {
+ accessToken: string;
+ user: { id: string; name: string };
+ } | null;
+ status: "authenticated" | "unauthenticated";
+} = {
+ data: {
+ accessToken: "expired-access-token",
+ user: { id: "user-1", name: "Test User" },
+ },
+ status: "authenticated",
+};
+
+jest.mock("next-auth/react", () => ({
+ SessionProvider: ({ children }: { children: ReactNode }) => children,
+ signIn: jest.fn().mockResolvedValue(undefined),
+ useSession: () => mockSessionState,
+}));
+
+jest.mock("next/navigation", () => ({
+ usePathname: () => "/network-simulation",
+}));
+
+jest.mock("@refinedev/core", () => ({
+ Refine: ({ children }: { children: ReactNode }) => children,
+}));
+
+jest.mock("@refinedev/kbar", () => ({
+ RefineKbar: () => null,
+ RefineKbarProvider: ({ children }: { children: ReactNode }) => children,
+}));
+
+jest.mock("@refinedev/mui", () => ({
+ RefineSnackbarProvider: ({ children }: { children: ReactNode }) => children,
+}));
+
+jest.mock("@refinedev/nextjs-router", () => ({}));
+jest.mock("@providers/data-provider", () => ({ dataProvider: {} }));
+jest.mock("@/providers/notification-provider/useAppNotificationProvider", () => ({
+ useAppNotificationProvider: {},
+}));
+jest.mock("@contexts/color-mode", () => ({
+ ColorModeContextProvider: ({ children }: { children: ReactNode }) => children,
+}));
+jest.mock("@/contexts/ProjectContext", () => ({
+ ProjectProvider: ({ children }: { children: ReactNode }) => children,
+}));
+jest.mock("@/lib/authToken", () => ({
+ getAccessToken: jest.fn().mockResolvedValue("expired-access-token"),
+}));
+
+describe("RefineContext access authentication", () => {
+ const originalFetch = global.fetch;
+ const originalRequest = global.Request;
+
+ beforeEach(() => {
+ mockSessionState = {
+ data: {
+ accessToken: "expired-access-token",
+ user: { id: "user-1", name: "Test User" },
+ },
+ status: "authenticated",
+ };
+ useAuthStore.setState({
+ accessToken: null,
+ sessionExpired: false,
+ sessionExpiryReason: null,
+ });
+ useAccessStore.setState({
+ context: null,
+ permissions: [],
+ loading: true,
+ });
+ global.Request = class TestRequest {} as unknown as typeof Request;
+ global.fetch = jest.fn().mockResolvedValue({
+ ok: false,
+ status: 401,
+ headers: new Headers(),
+ } as Response);
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ global.fetch = originalFetch;
+ global.Request = originalRequest;
+ });
+
+ it("marks the session expired when access-context rejects an expired token", async () => {
+ render(
+
+ 应用内容
+ ,
+ );
+
+ await waitFor(() => {
+ expect(global.fetch).toHaveBeenCalledWith(
+ expect.stringContaining("/api/v1/access-context"),
+ expect.not.objectContaining({ skipAuthRedirect: true }),
+ );
+ });
+
+ await waitFor(() => {
+ expect(useAuthStore.getState()).toMatchObject({
+ sessionExpired: true,
+ sessionExpiryReason: "unauthorized",
+ });
+ });
+ });
+
+ it("prioritizes an unauthenticated session over route permissions", async () => {
+ mockSessionState = { data: null, status: "unauthenticated" };
+
+ render(
+
+ 应用内容
+ ,
+ );
+
+ expect(screen.getByText("登录状态已失效")).toBeInTheDocument();
+ expect(screen.queryByText("无权访问此功能")).not.toBeInTheDocument();
+
+ await waitFor(() => {
+ expect(useAuthStore.getState()).toMatchObject({
+ sessionExpired: true,
+ sessionExpiryReason: "unauthorized",
+ });
+ });
+ });
+});
diff --git a/src/app/RefineContext.tsx b/src/app/RefineContext.tsx
index b02f022..1e6cfb6 100644
--- a/src/app/RefineContext.tsx
+++ b/src/app/RefineContext.tsx
@@ -56,7 +56,7 @@ type AppProps = {
defaultMode?: string;
};
-const App = (props: React.PropsWithChildren) => {
+export const App = (props: React.PropsWithChildren) => {
const { data, status } = useSession();
const to = usePathname();
const setAccessToken = useAuthStore((state) => state.setAccessToken);
@@ -84,6 +84,10 @@ const App = (props: React.PropsWithChildren) => {
markSessionExpired("refresh_failed");
return;
}
+ if (status === "unauthenticated") {
+ markSessionExpired("unauthorized");
+ return;
+ }
if (status === "authenticated") {
clearSessionExpired();
}
@@ -99,7 +103,6 @@ const App = (props: React.PropsWithChildren) => {
setAccessLoading(true);
apiFetch(`${config.BACKEND_URL}/api/v1/access-context`, {
projectHeaderMode: currentProjectId ? "include" : "omit",
- skipAuthRedirect: true,
})
.then(async (response) => {
if (cancelled) return;
@@ -368,7 +371,9 @@ const App = (props: React.PropsWithChildren) => {
}}
>
- {props.children}
+
+ {props.children}
+
diff --git a/src/components/admin/SystemAdminPanel.tsx b/src/components/admin/SystemAdminPanel.tsx
index 46009fc..1288c50 100644
--- a/src/components/admin/SystemAdminPanel.tsx
+++ b/src/components/admin/SystemAdminPanel.tsx
@@ -657,7 +657,6 @@ export const SystemAdminPanel = () => {
try {
const adminResponse = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/users/me`, {
projectHeaderMode: "omit",
- skipAuthRedirect: true,
});
if (!adminResponse.ok) {
if (!cancelled) {
diff --git a/src/components/auth/RoutePermissionGuard.test.tsx b/src/components/auth/RoutePermissionGuard.test.tsx
index 08f8051..acf5f49 100644
--- a/src/components/auth/RoutePermissionGuard.test.tsx
+++ b/src/components/auth/RoutePermissionGuard.test.tsx
@@ -29,7 +29,7 @@ describe("RoutePermissionGuard", () => {
});
render(
-
+
受保护内容
,
);
@@ -43,7 +43,7 @@ describe("RoutePermissionGuard", () => {
it("shows the permission error when the session is still valid", () => {
render(
-
+
受保护内容
,
);
@@ -51,4 +51,15 @@ describe("RoutePermissionGuard", () => {
expect(screen.getByText("无权访问此功能")).toBeInTheDocument();
expect(screen.getByText(/simulation\.view/)).toBeInTheDocument();
});
+
+ it("checks authentication before the expired state effect runs", () => {
+ render(
+
+ 受保护内容
+ ,
+ );
+
+ expect(screen.getByText("登录状态已失效")).toBeInTheDocument();
+ expect(screen.queryByText("无权访问此功能")).not.toBeInTheDocument();
+ });
});
diff --git a/src/components/auth/RoutePermissionGuard.tsx b/src/components/auth/RoutePermissionGuard.tsx
index 4894fb1..fa90f8b 100644
--- a/src/components/auth/RoutePermissionGuard.tsx
+++ b/src/components/auth/RoutePermissionGuard.tsx
@@ -11,8 +11,10 @@ import { useAuthStore } from "@/store/authStore";
export const RoutePermissionGuard = ({
children,
+ authenticated,
}: {
children: ReactNode;
+ authenticated: boolean;
}) => {
const pathname = usePathname();
const permissions = useAccessStore((state) => state.permissions);
@@ -20,7 +22,7 @@ export const RoutePermissionGuard = ({
const sessionExpired = useAuthStore((state) => state.sessionExpired);
const requiredPermission = permissionForPath(pathname);
- if (sessionExpired) {
+ if (!authenticated || sessionExpired) {
return (