diff --git a/e2e/authenticated.spec.ts b/e2e/authenticated.spec.ts
index e66bf17..0eeb064 100644
--- a/e2e/authenticated.spec.ts
+++ b/e2e/authenticated.spec.ts
@@ -17,6 +17,7 @@ test("已登录管理员可以打开审计日志页面", async ({ page }) => {
});
test("已认证但未授权的账号会看到明确说明", async ({ page }) => {
+ await page.addInitScript(() => window.localStorage.clear());
await page.route("**/api/v1/access-context", (route) =>
route.fulfill({
status: 403,
@@ -31,12 +32,44 @@ test("已认证但未授权的账号会看到明确说明", async ({ page }) =>
await expect(
page.getByText("身份认证已成功,但当前账号尚未由系统管理员启用"),
).toBeVisible();
- await expect(page.getByText("TJWater 智慧水务平台")).toBeVisible();
- await expect(page.getByRole("button", { name: "退出登录" })).toBeVisible();
+ const accountMenu = page.getByRole("button", {
+ name: "E2E 测试用户 账号菜单",
+ });
+ await expect(accountMenu).toBeVisible();
+ await accountMenu.click();
+ await expect(page.getByRole("menuitem", { name: "登出" })).toBeVisible();
+ await expect(page.getByRole("menuitem", { name: "切换项目" })).toHaveCount(0);
+ await page.keyboard.press("Escape");
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.getByText("账号尚未获得访问权限")).toBeVisible();
- await expect(page.getByRole("button", { name: "退出登录" })).toBeVisible();
+ await expect(accountMenu).toBeVisible();
+});
+
+test("无权访问项目时可以在窄屏切换项目", async ({ page }) => {
+ await page.route("**/api/v1/access-context", (route) =>
+ route.fulfill({
+ status: 403,
+ contentType: "application/json",
+ body: JSON.stringify({ detail: "No access to project" }),
+ }),
+ );
+
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.goto("/");
+
+ await expect(page.getByText("无权访问当前项目")).toBeVisible();
+ await page
+ .getByRole("button", { name: "E2E 测试用户 账号菜单" })
+ .click();
+ await page.getByRole("menuitem", { name: "切换项目" }).click();
+
+ const projectDialog = page.getByRole("dialog");
+ await expect(projectDialog).toBeVisible();
+ const bounds = await projectDialog.boundingBox();
+ expect(bounds).not.toBeNull();
+ expect(bounds!.x).toBeGreaterThanOrEqual(0);
+ expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(390);
});
test("系统管理员可以手动同步 Keycloak 用户目录", async ({ page }) => {
diff --git a/src/app/RefineContext.test.tsx b/src/app/RefineContext.test.tsx
index 7960461..a755f5a 100644
--- a/src/app/RefineContext.test.tsx
+++ b/src/app/RefineContext.test.tsx
@@ -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 }) => (
+
{accessMode}
+ ),
+}));
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 ? 请选择项目环境
: 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(
+
+ 登录页面
+ ,
+ );
+
+ await waitFor(() => {
+ expect(useAuthStore.getState()).toMatchObject({
+ sessionExpired: false,
+ sessionExpiryReason: null,
+ });
+ });
+ });
});
diff --git a/src/app/RefineContext.tsx b/src/app/RefineContext.tsx
index 7154a60..8ebe24c 100644
--- a/src/app/RefineContext.tsx
+++ b/src/app/RefineContext.tsx
@@ -68,7 +68,6 @@ export const App = (props: React.PropsWithChildren) => {
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) => {
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) => {
: 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) => {
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) => {
];
return (
-
-
-
-
-
+
+
+
+
+
-
- void handleLogout()}
- onChooseAnotherProject={handleChooseAnotherProject}
- >
+
{props.children}
-
-
-
-
-
-
-
+
+
+
+
+
+
+
);
};
diff --git a/src/app/api/auth/[...nextauth]/options.test.ts b/src/app/api/auth/[...nextauth]/options.test.ts
index d922e92..89f4db1 100644
--- a/src/app/api/auth/[...nextauth]/options.test.ts
+++ b/src/app/api/auth/[...nextauth]/options.test.ts
@@ -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 = {
diff --git a/src/app/api/auth/[...nextauth]/options.ts b/src/app/api/auth/[...nextauth]/options.ts
index 4d92dfa..f5b6684 100644
--- a/src/app/api/auth/[...nextauth]/options.ts
+++ b/src/app/api/auth/[...nextauth]/options.ts
@@ -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,
};
},
}),
diff --git a/src/components/auth/RoutePermissionGuard.test.tsx b/src/components/auth/RoutePermissionGuard.test.tsx
index 77e5bd6..13bc844 100644
--- a/src/components/auth/RoutePermissionGuard.test.tsx
+++ b/src/components/auth/RoutePermissionGuard.test.tsx
@@ -1,4 +1,4 @@
-import { fireEvent, render, screen } from "@testing-library/react";
+import { render, screen } from "@testing-library/react";
import { useAccessStore } from "@/store/accessStore";
import { useAuthStore } from "@/store/authStore";
@@ -8,6 +8,12 @@ jest.mock("next/navigation", () => ({
usePathname: () => "/network-simulation",
}));
+jest.mock("@/components/header", () => ({
+ Header: ({ accessMode }: { accessMode: string }) => (
+ {accessMode}
+ ),
+}));
+
describe("RoutePermissionGuard", () => {
beforeEach(() => {
useAccessStore.setState({
@@ -53,43 +59,31 @@ describe("RoutePermissionGuard", () => {
});
it("keeps a minimal identity header and logout action for pending accounts", () => {
- const onLogout = jest.fn();
-
render(
受保护内容
,
);
- expect(screen.getByText("TJWater 智慧水务平台")).toBeInTheDocument();
- expect(screen.getByText("当前登录账号")).toBeInTheDocument();
- expect(screen.getByText("zjb")).toBeInTheDocument();
- fireEvent.click(screen.getByRole("button", { name: "退出登录" }));
- expect(onLogout).toHaveBeenCalledTimes(1);
+ expect(screen.getByTestId("access-header")).toHaveTextContent("account");
expect(screen.queryByText("受保护内容")).not.toBeInTheDocument();
});
it("keeps project recovery available when only the selected project is denied", () => {
- const onChooseAnotherProject = jest.fn();
-
render(
受保护内容
,
);
expect(screen.getByText("无权访问当前项目")).toBeInTheDocument();
- fireEvent.click(screen.getByRole("button", { name: "切换项目" }));
- expect(onChooseAnotherProject).toHaveBeenCalledTimes(1);
+ expect(screen.getByTestId("access-header")).toHaveTextContent("project");
});
it("checks authentication before the expired state effect runs", () => {
diff --git a/src/components/auth/RoutePermissionGuard.tsx b/src/components/auth/RoutePermissionGuard.tsx
index 6e0fd2a..0b1db14 100644
--- a/src/components/auth/RoutePermissionGuard.tsx
+++ b/src/components/auth/RoutePermissionGuard.tsx
@@ -1,23 +1,17 @@
"use client";
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
-import LogoutIcon from "@mui/icons-material/Logout";
-import SwapHorizIcon from "@mui/icons-material/SwapHoriz";
import {
Alert,
- AppBar,
- Avatar,
Box,
- Button,
CircularProgress,
Stack,
- Toolbar,
Typography,
} from "@mui/material";
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";
-import { Title } from "@/components/title";
+import { Header } from "@/components/header";
import { permissionForPath } from "@/lib/permissions";
import { useAccessStore } from "@/store/accessStore";
import { useAuthStore } from "@/store/authStore";
@@ -26,16 +20,10 @@ export const RoutePermissionGuard = ({
children,
authenticated,
accessDeniedReason = null,
- userName,
- onLogout,
- onChooseAnotherProject,
}: {
children: ReactNode;
authenticated: boolean;
accessDeniedReason?: "account" | "project" | null;
- userName?: string | null;
- onLogout?: () => void;
- onChooseAnotherProject?: () => void;
}) => {
const pathname = usePathname();
const permissions = useAccessStore((state) => state.permissions);
@@ -59,70 +47,11 @@ export const RoutePermissionGuard = ({
}
if (accessDeniedReason) {
- const displayName = userName?.trim() || "已登录账号";
const accountPending = accessDeniedReason === "account";
return (
-
-
-
-
-
- {displayName.slice(0, 1).toUpperCase()}
-
-
-
- 当前登录账号
-
-
- {displayName}
-
-
- {!accountPending && (
-
- )}
-
-
-
-
+
= ({
+type HeaderProps = RefineThemedLayoutHeaderProps & {
+ accessMode?: "default" | "account" | "project";
+};
+
+export const Header: React.FC = ({
sticky = true,
+ accessMode = "default",
}) => {
const { mode, setMode } = useContext(ColorModeContext);
const { mutate: logout } = useLogout();
@@ -51,6 +57,7 @@ export const Header: React.FC = ({
);
const { data: user } = useGetIdentity();
+ const restricted = accessMode !== "default";
const handleMenuOpen = (event: React.MouseEvent) => {
setAnchorEl(event.currentTarget);
@@ -92,7 +99,7 @@ export const Header: React.FC = ({
justifyContent="flex-end"
alignItems="center"
>
-
+ {!restricted && }
= ({
{(user?.avatar || user?.name) && (
<>
- setShowChatbox(true)}
- sx={{ mr: 1 }}
- >
-
-
+ {!restricted && (
+ setShowChatbox(true)}
+ sx={{ mr: 1 }}
+ >
+
+
+ )}
= ({
borderColor: "primary.main",
},
}}
- />
+ >
+ {!user?.avatar && }
+
- setShowProjectSelector(false)}
- />
+ {accessMode !== "account" && (
+ setShowProjectSelector(false)}
+ />
+ )}
>
)}
- setShowChatbox(false)}
- />
+ {!restricted && (
+ setShowChatbox(false)}
+ />
+ )}
diff --git a/src/components/project/ProjectSelector.tsx b/src/components/project/ProjectSelector.tsx
index d66755d..cd18576 100644
--- a/src/components/project/ProjectSelector.tsx
+++ b/src/components/project/ProjectSelector.tsx
@@ -132,8 +132,11 @@ export const ProjectSelector: React.FC = ({
paper: {
sx: {
borderRadius: 2,
- padding: 2,
- minWidth: 400,
+ boxSizing: "border-box",
+ margin: { xs: 2, sm: 4 },
+ padding: { xs: 1.5, sm: 2 },
+ width: { xs: "calc(100vw - 32px)", sm: 400 },
+ maxWidth: "calc(100vw - 32px)",
background: "rgba(255, 255, 255, 0.95)",
backdropFilter: "blur(10px)",
position: "relative",