fix(auth): align restricted header and logout state

This commit is contained in:
2026-11-04 14:12:44 +08:00
parent 80f05e481b
commit 5a0bb6864a
9 changed files with 206 additions and 169 deletions
+35 -8
View File
@@ -18,6 +18,8 @@ let mockSessionState: {
},
status: "authenticated",
};
let mockPathname = "/network-simulation";
let mockProjectProviderBlocks = false;
jest.mock("next-auth/react", () => ({
SessionProvider: ({ children }: { children: ReactNode }) => children,
@@ -26,7 +28,7 @@ jest.mock("next-auth/react", () => ({
}));
jest.mock("next/navigation", () => ({
usePathname: () => "/network-simulation",
usePathname: () => mockPathname,
}));
jest.mock("@refinedev/core", () => ({
@@ -43,6 +45,11 @@ jest.mock("@refinedev/mui", () => ({
}));
jest.mock("@refinedev/nextjs-router", () => ({}));
jest.mock("@/components/header", () => ({
Header: ({ accessMode }: { accessMode: string }) => (
<div data-testid="access-header">{accessMode}</div>
),
}));
jest.mock("@providers/data-provider", () => ({ dataProvider: {} }));
jest.mock("@/providers/notification-provider/useAppNotificationProvider", () => ({
useAppNotificationProvider: {},
@@ -51,7 +58,8 @@ jest.mock("@contexts/color-mode", () => ({
ColorModeContextProvider: ({ children }: { children: ReactNode }) => children,
}));
jest.mock("@/contexts/ProjectContext", () => ({
ProjectProvider: ({ children }: { children: ReactNode }) => children,
ProjectProvider: ({ children }: { children: ReactNode }) =>
mockProjectProviderBlocks ? <div>请选择项目环境</div> : children,
}));
jest.mock("@/lib/authToken", () => ({
getAccessToken: jest.fn().mockResolvedValue("expired-access-token"),
@@ -62,6 +70,8 @@ describe("RefineContext access authentication", () => {
const originalRequest = global.Request;
beforeEach(() => {
mockPathname = "/network-simulation";
mockProjectProviderBlocks = false;
mockSessionState = {
data: {
accessToken: "expired-access-token",
@@ -116,6 +126,7 @@ describe("RefineContext access authentication", () => {
});
it("shows an authorization message when access-context rejects the account", async () => {
mockProjectProviderBlocks = true;
global.fetch = jest.fn().mockResolvedValue({
ok: false,
status: 403,
@@ -132,10 +143,7 @@ describe("RefineContext access authentication", () => {
expect(
await screen.findByText("账号尚未获得访问权限"),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "退出登录" }),
).toBeInTheDocument();
expect(screen.getByText("Test User")).toBeInTheDocument();
expect(screen.getByTestId("access-header")).toHaveTextContent("account");
expect(screen.queryByText("应用内容")).not.toBeInTheDocument();
expect(useAuthStore.getState()).toMatchObject({
sessionExpired: false,
@@ -159,8 +167,9 @@ describe("RefineContext access authentication", () => {
expect(await screen.findByText("无权访问当前项目")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "切换项目" }),
).toBeInTheDocument();
screen.queryByRole("button", { name: "切换项目" }),
).not.toBeInTheDocument();
expect(screen.getByTestId("access-header")).toHaveTextContent("project");
expect(screen.queryByText("应用内容")).not.toBeInTheDocument();
});
@@ -183,4 +192,22 @@ describe("RefineContext access authentication", () => {
});
});
});
it("does not treat an intentional logout landing on login as session expiry", async () => {
mockPathname = "/login";
mockSessionState = { data: null, status: "unauthenticated" };
render(
<App>
<div>登录页面</div>
</App>,
);
await waitFor(() => {
expect(useAuthStore.getState()).toMatchObject({
sessionExpired: false,
sessionExpiryReason: null,
});
});
});
});
+35 -39
View File
@@ -68,7 +68,6 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
const markSessionExpired = useAuthStore((state) => state.markSessionExpired);
const clearSessionExpired = useAuthStore((state) => state.clearSessionExpired);
const currentProjectId = useProjectStore((state) => state.currentProjectId);
const setCurrentProject = useProjectStore((state) => state.setCurrentProject);
const currentProjectCode = useProjectStore(
(state) => state.currentProjectCode,
);
@@ -97,13 +96,17 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
return;
}
if (status === "unauthenticated") {
if (to === "/login") {
clearSessionExpired();
return;
}
markSessionExpired("unauthorized");
return;
}
if (status === "authenticated") {
clearSessionExpired();
}
}, [clearSessionExpired, data?.error, markSessionExpired, status]);
}, [clearSessionExpired, data?.error, markSessionExpired, status, to]);
useEffect(() => {
if (status !== "authenticated") {
@@ -190,6 +193,9 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
: useAuthStore.getState().accessToken,
}),
clearLocalState: () => {
const authStore = useAuthStore.getState();
authStore.setAccessToken(null);
authStore.clearSessionExpired();
if (data?.user?.id) {
sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`);
}
@@ -198,13 +204,6 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
navigate: (path) => window.location.assign(path),
});
const handleChooseAnotherProject = () => {
setCurrentProject(null, null);
localStorage.removeItem("MAP_WORKSPACE");
localStorage.removeItem("MAP_EXTENT");
window.location.assign("/");
};
const authProvider: AuthProvider = {
login: async () => {
signIn("keycloak", {
@@ -406,37 +405,34 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
];
return (
<ProjectProvider>
<RefineKbarProvider>
<ColorModeContextProvider defaultMode={props.defaultMode}>
<RefineSnackbarProvider>
<Refine
routerProvider={appRouterProvider}
dataProvider={dataProvider}
notificationProvider={useAppNotificationProvider}
authProvider={authProvider}
accessControlProvider={accessControlProvider}
resources={resources}
options={{
syncWithLocation: true,
warnWhenUnsavedChanges: true,
}}
<RefineKbarProvider>
<ColorModeContextProvider defaultMode={props.defaultMode}>
<RefineSnackbarProvider>
<Refine
routerProvider={appRouterProvider}
dataProvider={dataProvider}
notificationProvider={useAppNotificationProvider}
authProvider={authProvider}
accessControlProvider={accessControlProvider}
resources={resources}
options={{
syncWithLocation: true,
warnWhenUnsavedChanges: true,
}}
>
<SessionExpiryDialog expiresAt={data?.sessionExpiresAt} />
<RoutePermissionGuard
authenticated={status === "authenticated"}
accessDeniedReason={accessDeniedReason}
>
<SessionExpiryDialog expiresAt={data?.sessionExpiresAt} />
<RoutePermissionGuard
authenticated={status === "authenticated"}
accessDeniedReason={accessDeniedReason}
userName={data?.user?.name ?? data?.user?.username}
onLogout={() => void handleLogout()}
onChooseAnotherProject={handleChooseAnotherProject}
>
<ProjectProvider>
{props.children}
</RoutePermissionGuard>
<RefineKbar />
</Refine>
</RefineSnackbarProvider>
</ColorModeContextProvider>
</RefineKbarProvider>
</ProjectProvider>
</ProjectProvider>
</RoutePermissionGuard>
<RefineKbar />
</Refine>
</RefineSnackbarProvider>
</ColorModeContextProvider>
</RefineKbarProvider>
);
};
@@ -1,5 +1,36 @@
import authOptions from "./options";
describe("Keycloak profile mapping", () => {
const mapProfile = authOptions.providers[0].profile as (profile: {
sub: string;
preferred_username: string;
name?: string;
email?: string;
picture?: string;
}) => { image?: string | null };
it("uses the Keycloak picture instead of a shared local avatar", () => {
expect(
mapProfile({
sub: "user-1",
preferred_username: "zjb",
picture: "https://keycloak.example/avatar/zjb.png",
}),
).toMatchObject({
image: "https://keycloak.example/avatar/zjb.png",
});
});
it("leaves the image empty when Keycloak has no picture", () => {
const profile = mapProfile({
sub: "user-1",
preferred_username: "zjb",
});
expect(profile.image).toBeUndefined();
});
});
describe("NextAuth access token refresh", () => {
it("forces a Keycloak refresh for an agent credential request", async () => {
const previousEnv = {
+5 -2
View File
@@ -1,7 +1,6 @@
import { NextAuthOptions } from "next-auth";
import { JWT } from "next-auth/jwt";
import KeycloakProvider from "next-auth/providers/keycloak";
import Avatar from "@assets/avatar/avatar-small.jpeg";
const SESSION_MAX_AGE_SECONDS = 12 * 60 * 60;
const ACCESS_TOKEN_REFRESH_SKEW_MS = 30_000;
@@ -86,12 +85,16 @@ const authOptions: NextAuthOptions = {
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET ?? "",
issuer: process.env.KEYCLOAK_ISSUER ?? "",
profile(profile) {
const picture =
typeof (profile as { picture?: unknown }).picture === "string"
? String((profile as { picture: string }).picture).trim()
: "";
return {
id: profile.sub,
username: profile.preferred_username,
name: profile.name ?? profile.preferred_username,
email: profile.email,
image: Avatar.src,
image: picture || null,
};
},
}),