diff --git a/src/app/RefineContext.test.tsx b/src/app/RefineContext.test.tsx index d02be92..247b49c 100644 --- a/src/app/RefineContext.test.tsx +++ b/src/app/RefineContext.test.tsx @@ -1,4 +1,5 @@ import { render, screen, waitFor } from "@testing-library/react"; +import { signIn } from "next-auth/react"; import type { ReactNode } from "react"; import { useAccessStore } from "@/store/accessStore"; @@ -25,6 +26,8 @@ let mockSessionState: { }; let mockPathname = "/network-simulation"; let mockProjectProviderBlocks = false; +let capturedLogin: (() => Promise) | null = null; +const replaceMock = jest.fn(); jest.mock("next-auth/react", () => ({ SessionProvider: ({ children }: { children: ReactNode }) => children, @@ -34,10 +37,20 @@ jest.mock("next-auth/react", () => ({ jest.mock("next/navigation", () => ({ usePathname: () => mockPathname, + useRouter: () => ({ replace: replaceMock }), })); jest.mock("@refinedev/core", () => ({ - Refine: ({ children }: { children: ReactNode }) => children, + Refine: ({ + children, + authProvider, + }: { + children: ReactNode; + authProvider: { login: (params: Record) => Promise }; + }) => { + capturedLogin = () => authProvider.login({}); + return children; + }, })); jest.mock("@refinedev/kbar", () => ({ @@ -78,6 +91,9 @@ describe("RefineContext access authentication", () => { clearIntentionalLogout(); mockPathname = "/network-simulation"; mockProjectProviderBlocks = false; + capturedLogin = null; + replaceMock.mockReset(); + jest.mocked(signIn).mockClear(); mockSessionState = { data: { accessToken: "expired-access-token", @@ -217,6 +233,25 @@ describe("RefineContext access authentication", () => { }); }); + it("starts login with a safe post-authentication callback from the login route", async () => { + mockPathname = "/login"; + mockSessionState = { data: null, status: "unauthenticated" }; + + render( + +
登录页面
+
, + ); + + expect(capturedLogin).not.toBeNull(); + await capturedLogin!(); + + expect(signIn).toHaveBeenCalledWith("keycloak", { + callbackUrl: "/", + redirect: true, + }); + }); + it("does not treat returning to a protected route after logout as session expiry", async () => { markIntentionalLogout(); mockSessionState = { data: null, status: "unauthenticated" }; diff --git a/src/app/RefineContext.tsx b/src/app/RefineContext.tsx index e835ed9..a676ca5 100644 --- a/src/app/RefineContext.tsx +++ b/src/app/RefineContext.tsx @@ -214,7 +214,7 @@ export const App = (props: React.PropsWithChildren) => { const authProvider: AuthProvider = { login: async () => { signIn("keycloak", { - callbackUrl: to ? to.toString() : "/", + callbackUrl: to && to !== "/login" ? to.toString() : "/", redirect: true, }); return { success: true }; diff --git a/src/components/auth/RoutePermissionGuard.test.tsx b/src/components/auth/RoutePermissionGuard.test.tsx index 13bc844..f50a4e6 100644 --- a/src/components/auth/RoutePermissionGuard.test.tsx +++ b/src/components/auth/RoutePermissionGuard.test.tsx @@ -4,8 +4,12 @@ import { useAccessStore } from "@/store/accessStore"; import { useAuthStore } from "@/store/authStore"; import { RoutePermissionGuard } from "./RoutePermissionGuard"; +const replaceMock = jest.fn(); +let mockPathname = "/network-simulation"; + jest.mock("next/navigation", () => ({ - usePathname: () => "/network-simulation", + usePathname: () => mockPathname, + useRouter: () => ({ replace: replaceMock }), })); jest.mock("@/components/header", () => ({ @@ -16,6 +20,8 @@ jest.mock("@/components/header", () => ({ describe("RoutePermissionGuard", () => { beforeEach(() => { + replaceMock.mockReset(); + mockPathname = "/network-simulation"; useAccessStore.setState({ context: null, permissions: [], @@ -86,7 +92,7 @@ describe("RoutePermissionGuard", () => { expect(screen.getByTestId("access-header")).toHaveTextContent("project"); }); - it("checks authentication before the expired state effect runs", () => { + it("redirects an unauthenticated protected route to login", () => { render(
受保护内容
@@ -95,5 +101,20 @@ describe("RoutePermissionGuard", () => { expect(screen.getByText("登录状态已失效")).toBeInTheDocument(); expect(screen.queryByText("无权访问此功能")).not.toBeInTheDocument(); + expect(replaceMock).toHaveBeenCalledWith("/login"); + }); + + it("allows the login route to render while unauthenticated", () => { + mockPathname = "/login"; + + render( + +
登录页面
+
, + ); + + expect(screen.getByText("登录页面")).toBeInTheDocument(); + expect(screen.queryByText("登录状态已失效")).not.toBeInTheDocument(); + expect(replaceMock).not.toHaveBeenCalled(); }); }); diff --git a/src/components/auth/RoutePermissionGuard.tsx b/src/components/auth/RoutePermissionGuard.tsx index 0b1db14..133c5ff 100644 --- a/src/components/auth/RoutePermissionGuard.tsx +++ b/src/components/auth/RoutePermissionGuard.tsx @@ -8,8 +8,8 @@ import { Stack, Typography, } from "@mui/material"; -import { usePathname } from "next/navigation"; -import type { ReactNode } from "react"; +import { usePathname, useRouter } from "next/navigation"; +import { type ReactNode, useEffect } from "react"; import { Header } from "@/components/header"; import { permissionForPath } from "@/lib/permissions"; @@ -26,10 +26,22 @@ export const RoutePermissionGuard = ({ accessDeniedReason?: "account" | "project" | null; }) => { const pathname = usePathname(); + const router = useRouter(); const permissions = useAccessStore((state) => state.permissions); const loading = useAccessStore((state) => state.loading); const sessionExpired = useAuthStore((state) => state.sessionExpired); const requiredPermission = permissionForPath(pathname); + const isLoginRoute = pathname === "/login"; + + useEffect(() => { + if (!authenticated && !isLoginRoute) { + router.replace("/login"); + } + }, [authenticated, isLoginRoute, router]); + + if (isLoginRoute) { + return children; + } if (!authenticated || sessionExpired) { return (