From d98fa359ae1ce5cc09867137e44ab44bf7dd29cf Mon Sep 17 00:00:00 2001 From: Huarch Date: Wed, 4 Nov 2026 15:20:46 +0800 Subject: [PATCH] fix(auth): redirect expired routes into login flow The global route guard previously rendered redirect copy without navigating and also blocked the login route itself. Allow the login page through, redirect protected unauthenticated routes, and avoid a /login callback loop. --- src/app/RefineContext.test.tsx | 37 ++++++++++++++++++- src/app/RefineContext.tsx | 2 +- .../auth/RoutePermissionGuard.test.tsx | 25 ++++++++++++- src/components/auth/RoutePermissionGuard.tsx | 16 +++++++- 4 files changed, 74 insertions(+), 6 deletions(-) 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 (