From 80f05e481bc72c61c9d2b5b0ab55b81def62490c Mon Sep 17 00:00:00 2001 From: Huarch Date: Wed, 4 Nov 2026 13:51:05 +0800 Subject: [PATCH] fix(auth): keep logout and project recovery on access denial --- e2e/authenticated.spec.ts | 6 + src/app/RefineContext.test.tsx | 26 ++++ src/app/RefineContext.tsx | 73 +++++++---- .../auth/RoutePermissionGuard.test.tsx | 42 +++++- src/components/auth/RoutePermissionGuard.tsx | 122 ++++++++++++++++-- 5 files changed, 231 insertions(+), 38 deletions(-) diff --git a/e2e/authenticated.spec.ts b/e2e/authenticated.spec.ts index 7f40c11..e66bf17 100644 --- a/e2e/authenticated.spec.ts +++ b/e2e/authenticated.spec.ts @@ -31,6 +31,12 @@ test("已认证但未授权的账号会看到明确说明", async ({ page }) => await expect( page.getByText("身份认证已成功,但当前账号尚未由系统管理员启用"), ).toBeVisible(); + await expect(page.getByText("TJWater 智慧水务平台")).toBeVisible(); + await expect(page.getByRole("button", { name: "退出登录" })).toBeVisible(); + + await page.setViewportSize({ width: 390, height: 844 }); + await expect(page.getByText("账号尚未获得访问权限")).toBeVisible(); + await expect(page.getByRole("button", { name: "退出登录" })).toBeVisible(); }); test("系统管理员可以手动同步 Keycloak 用户目录", async ({ page }) => { diff --git a/src/app/RefineContext.test.tsx b/src/app/RefineContext.test.tsx index 84b540f..7960461 100644 --- a/src/app/RefineContext.test.tsx +++ b/src/app/RefineContext.test.tsx @@ -120,6 +120,7 @@ describe("RefineContext access authentication", () => { ok: false, status: 403, headers: new Headers(), + json: async () => ({ detail: "User authorization pending" }), } as Response); render( @@ -131,6 +132,10 @@ describe("RefineContext access authentication", () => { expect( await screen.findByText("账号尚未获得访问权限"), ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "退出登录" }), + ).toBeInTheDocument(); + expect(screen.getByText("Test User")).toBeInTheDocument(); expect(screen.queryByText("应用内容")).not.toBeInTheDocument(); expect(useAuthStore.getState()).toMatchObject({ sessionExpired: false, @@ -138,6 +143,27 @@ describe("RefineContext access authentication", () => { }); }); + it("keeps project recovery available when the selected project is denied", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 403, + headers: new Headers(), + json: async () => ({ detail: "No access to project" }), + } as Response); + + render( + +
应用内容
+
, + ); + + expect(await screen.findByText("无权访问当前项目")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "切换项目" }), + ).toBeInTheDocument(); + expect(screen.queryByText("应用内容")).not.toBeInTheDocument(); + }); + it("prioritizes an unauthenticated session over route permissions", async () => { mockSessionState = { data: null, status: "unauthenticated" }; diff --git a/src/app/RefineContext.tsx b/src/app/RefineContext.tsx index 1133663..7154a60 100644 --- a/src/app/RefineContext.tsx +++ b/src/app/RefineContext.tsx @@ -68,6 +68,7 @@ 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, ); @@ -75,7 +76,9 @@ export const App = (props: React.PropsWithChildren) => { const setAccessContext = useAccessStore((state) => state.setContext); const setAccessLoading = useAccessStore((state) => state.setLoading); const resetAccess = useAccessStore((state) => state.reset); - const [accessDenied, setAccessDenied] = useState(false); + const [accessDeniedReason, setAccessDeniedReason] = useState< + "account" | "project" | null + >(null); const can = (permission: string) => permissions.includes(permission); useEffect(() => { @@ -104,13 +107,13 @@ export const App = (props: React.PropsWithChildren) => { useEffect(() => { if (status !== "authenticated") { - setAccessDenied(false); + setAccessDeniedReason(null); resetAccess(); return; } let cancelled = false; - setAccessDenied(false); + setAccessDeniedReason(null); setAccessLoading(true); apiFetch(`${config.BACKEND_URL}/api/v1/access-context`, { projectHeaderMode: currentProjectId ? "include" : "omit", @@ -118,7 +121,17 @@ export const App = (props: React.PropsWithChildren) => { .then(async (response) => { if (cancelled) return; if (response.status === 403) { - setAccessDenied(true); + let detail = ""; + try { + const payload = await response.json(); + detail = typeof payload?.detail === "string" ? payload.detail : ""; + } catch { + // Treat an unparseable 403 as a project access failure so the user + // keeps a recovery path instead of being trapped in the account shell. + } + setAccessDeniedReason( + detail === "User authorization pending" ? "account" : "project", + ); resetAccess(); return; } @@ -126,7 +139,7 @@ export const App = (props: React.PropsWithChildren) => { resetAccess(); return; } - setAccessDenied(false); + setAccessDeniedReason(null); setAccessContext(await response.json()); }) .catch(() => { @@ -166,6 +179,32 @@ export const App = (props: React.PropsWithChildren) => { return loading...; } + const handleLogout = () => + completeLogout({ + reportAudit: () => + reportLogoutAudit({ + endpoint: `${config.BACKEND_URL}/api/v1/audit-events`, + accessToken: + typeof data?.accessToken === "string" + ? data.accessToken + : useAuthStore.getState().accessToken, + }), + clearLocalState: () => { + if (data?.user?.id) { + sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`); + } + clearSessionRecoveryDrafts(); + }, + 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", { @@ -174,24 +213,7 @@ export const App = (props: React.PropsWithChildren) => { }); return { success: true }; }, - logout: () => - completeLogout({ - reportAudit: () => - reportLogoutAudit({ - endpoint: `${config.BACKEND_URL}/api/v1/audit-events`, - accessToken: - typeof data?.accessToken === "string" - ? data.accessToken - : useAuthStore.getState().accessToken, - }), - clearLocalState: () => { - if (data?.user?.id) { - sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`); - } - clearSessionRecoveryDrafts(); - }, - navigate: (path) => window.location.assign(path), - }), + logout: handleLogout, onError: async (error) => { return { error }; }, @@ -403,7 +425,10 @@ export const App = (props: React.PropsWithChildren) => { void handleLogout()} + onChooseAnotherProject={handleChooseAnotherProject} > {props.children} diff --git a/src/components/auth/RoutePermissionGuard.test.tsx b/src/components/auth/RoutePermissionGuard.test.tsx index acf5f49..77e5bd6 100644 --- a/src/components/auth/RoutePermissionGuard.test.tsx +++ b/src/components/auth/RoutePermissionGuard.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { useAccessStore } from "@/store/accessStore"; import { useAuthStore } from "@/store/authStore"; @@ -52,6 +52,46 @@ describe("RoutePermissionGuard", () => { expect(screen.getByText(/simulation\.view/)).toBeInTheDocument(); }); + 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.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); + }); + it("checks authentication before the expired state effect runs", () => { render( diff --git a/src/components/auth/RoutePermissionGuard.tsx b/src/components/auth/RoutePermissionGuard.tsx index 3a6fcb1..6e0fd2a 100644 --- a/src/components/auth/RoutePermissionGuard.tsx +++ b/src/components/auth/RoutePermissionGuard.tsx @@ -1,10 +1,23 @@ "use client"; import LockOutlinedIcon from "@mui/icons-material/LockOutlined"; -import { Alert, Box, CircularProgress, Stack, Typography } from "@mui/material"; +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 { permissionForPath } from "@/lib/permissions"; import { useAccessStore } from "@/store/accessStore"; import { useAuthStore } from "@/store/authStore"; @@ -12,11 +25,17 @@ import { useAuthStore } from "@/store/authStore"; export const RoutePermissionGuard = ({ children, authenticated, - accessDenied = false, + accessDeniedReason = null, + userName, + onLogout, + onChooseAnotherProject, }: { children: ReactNode; authenticated: boolean; - accessDenied?: boolean; + accessDeniedReason?: "account" | "project" | null; + userName?: string | null; + onLogout?: () => void; + onChooseAnotherProject?: () => void; }) => { const pathname = usePathname(); const permissions = useAccessStore((state) => state.permissions); @@ -39,17 +58,94 @@ export const RoutePermissionGuard = ({ ); } - if (accessDenied) { + 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> + <Box + component="main" + sx={{ + width: "100%", + maxWidth: 840, + mx: "auto", + px: { xs: 2, sm: 3 }, + py: { xs: 4, md: 8 }, + }} + > + <Alert severity="warning" icon={<LockOutlinedIcon />}> + <Stack spacing={0.5}> + <Typography fontWeight={700}> + {accountPending ? "账号尚未获得访问权限" : "无权访问当前项目"} + </Typography> + <Typography variant="body2"> + {accountPending + ? "身份认证已成功,但当前账号尚未由系统管理员启用。请联系系统管理员完成授权后重试。" + : "当前账号无法访问所选项目。请选择其他已授权项目,或联系系统管理员调整项目权限。"} + </Typography> + </Stack> + </Alert> + </Box> </Box> ); }