From 5a0bb6864a94264851bb13cd3c1f83b7c9f32ebe Mon Sep 17 00:00:00 2001 From: Huarch Date: Wed, 4 Nov 2026 14:12:44 +0800 Subject: [PATCH] fix(auth): align restricted header and logout state --- e2e/authenticated.spec.ts | 39 +++++++++- src/app/RefineContext.test.tsx | 43 +++++++++-- src/app/RefineContext.tsx | 74 +++++++++--------- .../api/auth/[...nextauth]/options.test.ts | 31 ++++++++ src/app/api/auth/[...nextauth]/options.ts | 7 +- .../auth/RoutePermissionGuard.test.tsx | 24 +++--- src/components/auth/RoutePermissionGuard.tsx | 75 +------------------ src/components/header/index.tsx | 75 ++++++++++++------- src/components/project/ProjectSelector.tsx | 7 +- 9 files changed, 206 insertions(+), 169 deletions(-) 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 ( - - - - <Stack direction="row" spacing={1.5} alignItems="center"> - <Avatar sx={{ width: 32, height: 32, bgcolor: "primary.main" }}> - {displayName.slice(0, 1).toUpperCase()} - </Avatar> - <Box sx={{ display: { xs: "none", sm: "block" } }}> - <Typography variant="caption" color="text.secondary"> - 当前登录账号 - </Typography> - <Typography variant="body2" fontWeight={600}> - {displayName} - </Typography> - </Box> - {!accountPending && ( - <Button - aria-label="切换项目" - color="inherit" - onClick={onChooseAnotherProject} - sx={{ - gap: { xs: 0, sm: 1 }, - minWidth: { xs: 40, sm: "auto" }, - px: { xs: 1, sm: 2 }, - "&:active": { transform: "translateY(1px)" }, - }} - variant="outlined" - > - <SwapHorizIcon fontSize="small" /> - <Box component="span" sx={{ display: { xs: "none", sm: "inline" } }}> - 切换项目 - </Box> - </Button> - )} - <Button - aria-label="退出登录" - color="inherit" - onClick={onLogout} - sx={{ - gap: { xs: 0, sm: 1 }, - minWidth: { xs: 40, sm: "auto" }, - px: { xs: 1, sm: 2 }, - "&:active": { transform: "translateY(1px)" }, - }} - variant="outlined" - > - <LogoutIcon fontSize="small" /> - <Box component="span" sx={{ display: { xs: "none", sm: "inline" } }}> - 退出登录 - </Box> - </Button> - </Stack> - </Toolbar> - </AppBar> + <Header accessMode={accessDeniedReason} /> <Box component="main" sx={{ diff --git a/src/components/header/index.tsx b/src/components/header/index.tsx index 19982cb..8c83682 100644 --- a/src/components/header/index.tsx +++ b/src/components/header/index.tsx @@ -3,6 +3,7 @@ import { ColorModeContext } from "@contexts/color-mode"; import DarkModeOutlined from "@mui/icons-material/DarkModeOutlined"; import LightModeOutlined from "@mui/icons-material/LightModeOutlined"; +import { FiUser } from "react-icons/fi"; import { IoChatbubbleEllipsesOutline } from "react-icons/io5"; import Logout from "@mui/icons-material/Logout"; import SwapHoriz from "@mui/icons-material/SwapHoriz"; @@ -37,8 +38,13 @@ type IUser = { avatar?: string; }; -export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({ +type HeaderProps = RefineThemedLayoutHeaderProps & { + accessMode?: "default" | "account" | "project"; +}; + +export const Header: React.FC<HeaderProps> = ({ sticky = true, + accessMode = "default", }) => { const { mode, setMode } = useContext(ColorModeContext); const { mutate: logout } = useLogout(); @@ -51,6 +57,7 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({ ); const { data: user } = useGetIdentity<IUser>(); + const restricted = accessMode !== "default"; const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => { setAnchorEl(event.currentTarget); @@ -92,7 +99,7 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({ justifyContent="flex-end" alignItems="center" > - <HamburgerMenu /> + {!restricted && <HamburgerMenu />} <Stack direction="row" width="100%" @@ -110,20 +117,26 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({ {(user?.avatar || user?.name) && ( <> - <IconButton - color="inherit" - onClick={() => setShowChatbox(true)} - sx={{ mr: 1 }} - > - <IoChatbubbleEllipsesOutline /> - </IconButton> + {!restricted && ( + <IconButton + color="inherit" + onClick={() => setShowChatbox(true)} + sx={{ mr: 1 }} + > + <IoChatbubbleEllipsesOutline /> + </IconButton> + )} <ButtonBase + aria-label={ + user?.name ? `${user.name} 账号菜单` : "账号菜单" + } onClick={handleMenuOpen} sx={{ borderRadius: "30px", padding: "6px 12px", marginLeft: "8px", - transition: "all 0.3s ease", + transition: + "background-color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease, transform 0.3s ease", border: "1px solid transparent", "&:hover": { backgroundColor: @@ -182,7 +195,9 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({ borderColor: "primary.main", }, }} - /> + > + {!user?.avatar && <FiUser aria-hidden="true" />} + </Avatar> </Stack> </ButtonBase> <Menu @@ -208,13 +223,15 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({ }, }} > - <MenuItem onClick={handleSwitchProjectClick}> - <ListItemIcon> - <SwapHoriz fontSize="small" /> - </ListItemIcon> - <ListItemText>切换项目</ListItemText> - </MenuItem> - <Divider /> + {accessMode !== "account" && ( + <MenuItem onClick={handleSwitchProjectClick}> + <ListItemIcon> + <SwapHoriz fontSize="small" /> + </ListItemIcon> + <ListItemText>切换项目</ListItemText> + </MenuItem> + )} + {accessMode !== "account" && <Divider />} <MenuItem onClick={() => logout()}> <ListItemIcon> <Logout fontSize="small" /> @@ -222,17 +239,21 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({ <ListItemText>登出</ListItemText> </MenuItem> </Menu> - <ProjectSelector - open={showProjectSelector} - onSelect={handleProjectSelect} - onClose={() => setShowProjectSelector(false)} - /> + {accessMode !== "account" && ( + <ProjectSelector + open={showProjectSelector} + onSelect={handleProjectSelect} + onClose={() => setShowProjectSelector(false)} + /> + )} </> )} - <GlobalChatbox - open={showChatbox} - onClose={() => setShowChatbox(false)} - /> + {!restricted && ( + <GlobalChatbox + open={showChatbox} + onClose={() => setShowChatbox(false)} + /> + )} </Stack> </Stack> </Toolbar> 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<ProjectSelectorProps> = ({ 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",