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.
This commit is contained in:
@@ -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<unknown>) | 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<string, never>) => Promise<unknown> };
|
||||
}) => {
|
||||
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(
|
||||
<App>
|
||||
<div>登录页面</div>
|
||||
</App>,
|
||||
);
|
||||
|
||||
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" };
|
||||
|
||||
@@ -214,7 +214,7 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
|
||||
const authProvider: AuthProvider = {
|
||||
login: async () => {
|
||||
signIn("keycloak", {
|
||||
callbackUrl: to ? to.toString() : "/",
|
||||
callbackUrl: to && to !== "/login" ? to.toString() : "/",
|
||||
redirect: true,
|
||||
});
|
||||
return { success: true };
|
||||
|
||||
@@ -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(
|
||||
<RoutePermissionGuard authenticated={false}>
|
||||
<div>受保护内容</div>
|
||||
@@ -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(
|
||||
<RoutePermissionGuard authenticated={false}>
|
||||
<div>登录页面</div>
|
||||
</RoutePermissionGuard>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("登录页面")).toBeInTheDocument();
|
||||
expect(screen.queryByText("登录状态已失效")).not.toBeInTheDocument();
|
||||
expect(replaceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user