Compare commits

...
Author SHA1 Message Date
jiang eff59c5a7e feat(profile): publish stable default avatar asset
Generic Container CI/CD / test-build-publish (push) Successful in 1m4s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m4s
2026-11-04 15:20:50 +08:00
jiang d98fa359ae 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.
2026-11-04 15:20:46 +08:00
5 changed files with 74 additions and 6 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

+36 -1
View File
@@ -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" };
+1 -1
View File
@@ -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();
});
});
+14 -2
View File
@@ -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 (