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 (
-
- }>
-
- 账号尚未获得访问权限
-
- 身份认证已成功,但当前账号尚未由系统管理员启用,或无权访问所选项目。请联系系统管理员完成授权后重试。
-
-
-
+
+
+
+
+
+
+ {displayName.slice(0, 1).toUpperCase()}
+
+
+
+ 当前登录账号
+
+
+ {displayName}
+
+
+ {!accountPending && (
+
+ )}
+
+
+
+
+
+ }>
+
+
+ {accountPending ? "账号尚未获得访问权限" : "无权访问当前项目"}
+
+
+ {accountPending
+ ? "身份认证已成功,但当前账号尚未由系统管理员启用。请联系系统管理员完成授权后重试。"
+ : "当前账号无法访问所选项目。请选择其他已授权项目,或联系系统管理员调整项目权限。"}
+
+
+
+
);
}