Compare commits

...
Author SHA1 Message Date
jiang a191d81c29 fix(ui): remove avatar hover scaling
Generic Container CI/CD / test-build-publish (push) Successful in 59s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 59s
2026-11-04 14:39:16 +08:00
jiang d4c8fc9db8 fix(auth): preserve intentional logout across browser return
Generic Container CI/CD / test-build-publish (push) Successful in 1m4s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m4s
Keep a bounded per-tab logout intent and broadcast it to already open tabs so back navigation after a normal logout is not mistaken for renewal failure. Preserve real refresh and session expiry signals, and keep IdP navigation reachable when local cleanup fails.
2026-11-04 14:34:18 +08:00
jiang 95ec090f2e fix(ui): keep account avatar static 2026-11-04 14:34:01 +08:00
jiang e3ab82f6df fix(3d): unify station area mode labels
Generic Container CI/CD / test-build-publish (push) Successful in 1m0s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m0s
2026-11-04 14:12:49 +08:00
jiang 5a0bb6864a fix(auth): align restricted header and logout state 2026-11-04 14:12:44 +08:00
jiang 80f05e481b fix(auth): keep logout and project recovery on access denial
Generic Container CI/CD / test-build-publish (push) Successful in 1m3s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m3s
2026-11-04 13:51:05 +08:00
17 changed files with 635 additions and 106 deletions
+39
View File
@@ -17,6 +17,7 @@ test("已登录管理员可以打开审计日志页面", async ({ page }) => {
}); });
test("已认证但未授权的账号会看到明确说明", async ({ page }) => { test("已认证但未授权的账号会看到明确说明", async ({ page }) => {
await page.addInitScript(() => window.localStorage.clear());
await page.route("**/api/v1/access-context", (route) => await page.route("**/api/v1/access-context", (route) =>
route.fulfill({ route.fulfill({
status: 403, status: 403,
@@ -31,6 +32,44 @@ test("已认证但未授权的账号会看到明确说明", async ({ page }) =>
await expect( await expect(
page.getByText("身份认证已成功,但当前账号尚未由系统管理员启用"), page.getByText("身份认证已成功,但当前账号尚未由系统管理员启用"),
).toBeVisible(); ).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(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 }) => { test("系统管理员可以手动同步 Keycloak 用户目录", async ({ page }) => {
+120 -2
View File
@@ -3,12 +3,17 @@ import type { ReactNode } from "react";
import { useAccessStore } from "@/store/accessStore"; import { useAccessStore } from "@/store/accessStore";
import { useAuthStore } from "@/store/authStore"; import { useAuthStore } from "@/store/authStore";
import {
clearIntentionalLogout,
markIntentionalLogout,
} from "@/lib/logoutIntent";
import { App } from "./RefineContext"; import { App } from "./RefineContext";
let mockSessionState: { let mockSessionState: {
data: { data: {
accessToken: string; accessToken: string;
user: { id: string; name: string }; user: { id: string; name: string };
error?: "RefreshAccessTokenError" | "SessionExpired";
} | null; } | null;
status: "authenticated" | "unauthenticated"; status: "authenticated" | "unauthenticated";
} = { } = {
@@ -18,6 +23,8 @@ let mockSessionState: {
}, },
status: "authenticated", status: "authenticated",
}; };
let mockPathname = "/network-simulation";
let mockProjectProviderBlocks = false;
jest.mock("next-auth/react", () => ({ jest.mock("next-auth/react", () => ({
SessionProvider: ({ children }: { children: ReactNode }) => children, SessionProvider: ({ children }: { children: ReactNode }) => children,
@@ -26,7 +33,7 @@ jest.mock("next-auth/react", () => ({
})); }));
jest.mock("next/navigation", () => ({ jest.mock("next/navigation", () => ({
usePathname: () => "/network-simulation", usePathname: () => mockPathname,
})); }));
jest.mock("@refinedev/core", () => ({ jest.mock("@refinedev/core", () => ({
@@ -43,6 +50,11 @@ jest.mock("@refinedev/mui", () => ({
})); }));
jest.mock("@refinedev/nextjs-router", () => ({})); 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/data-provider", () => ({ dataProvider: {} }));
jest.mock("@/providers/notification-provider/useAppNotificationProvider", () => ({ jest.mock("@/providers/notification-provider/useAppNotificationProvider", () => ({
useAppNotificationProvider: {}, useAppNotificationProvider: {},
@@ -51,7 +63,8 @@ jest.mock("@contexts/color-mode", () => ({
ColorModeContextProvider: ({ children }: { children: ReactNode }) => children, ColorModeContextProvider: ({ children }: { children: ReactNode }) => children,
})); }));
jest.mock("@/contexts/ProjectContext", () => ({ jest.mock("@/contexts/ProjectContext", () => ({
ProjectProvider: ({ children }: { children: ReactNode }) => children, ProjectProvider: ({ children }: { children: ReactNode }) =>
mockProjectProviderBlocks ? <div>请选择项目环境</div> : children,
})); }));
jest.mock("@/lib/authToken", () => ({ jest.mock("@/lib/authToken", () => ({
getAccessToken: jest.fn().mockResolvedValue("expired-access-token"), getAccessToken: jest.fn().mockResolvedValue("expired-access-token"),
@@ -62,6 +75,9 @@ describe("RefineContext access authentication", () => {
const originalRequest = global.Request; const originalRequest = global.Request;
beforeEach(() => { beforeEach(() => {
clearIntentionalLogout();
mockPathname = "/network-simulation";
mockProjectProviderBlocks = false;
mockSessionState = { mockSessionState = {
data: { data: {
accessToken: "expired-access-token", accessToken: "expired-access-token",
@@ -116,10 +132,12 @@ describe("RefineContext access authentication", () => {
}); });
it("shows an authorization message when access-context rejects the account", async () => { it("shows an authorization message when access-context rejects the account", async () => {
mockProjectProviderBlocks = true;
global.fetch = jest.fn().mockResolvedValue({ global.fetch = jest.fn().mockResolvedValue({
ok: false, ok: false,
status: 403, status: 403,
headers: new Headers(), headers: new Headers(),
json: async () => ({ detail: "User authorization pending" }),
} as Response); } as Response);
render( render(
@@ -131,6 +149,7 @@ describe("RefineContext access authentication", () => {
expect( expect(
await screen.findByText("账号尚未获得访问权限"), await screen.findByText("账号尚未获得访问权限"),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByTestId("access-header")).toHaveTextContent("account");
expect(screen.queryByText("应用内容")).not.toBeInTheDocument(); expect(screen.queryByText("应用内容")).not.toBeInTheDocument();
expect(useAuthStore.getState()).toMatchObject({ expect(useAuthStore.getState()).toMatchObject({
sessionExpired: false, sessionExpired: false,
@@ -138,6 +157,28 @@ 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(
<App>
<div>应用内容</div>
</App>,
);
expect(await screen.findByText("无权访问当前项目")).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "切换项目" }),
).not.toBeInTheDocument();
expect(screen.getByTestId("access-header")).toHaveTextContent("project");
expect(screen.queryByText("应用内容")).not.toBeInTheDocument();
});
it("prioritizes an unauthenticated session over route permissions", async () => { it("prioritizes an unauthenticated session over route permissions", async () => {
mockSessionState = { data: null, status: "unauthenticated" }; mockSessionState = { data: null, status: "unauthenticated" };
@@ -157,4 +198,81 @@ 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,
});
});
});
it("does not treat returning to a protected route after logout as session expiry", async () => {
markIntentionalLogout();
mockSessionState = { data: null, status: "unauthenticated" };
useAuthStore.setState({
accessToken: null,
sessionExpired: true,
sessionExpiryReason: "refresh_failed",
});
render(
<App>
<div>应用内容</div>
</App>,
);
await waitFor(() => {
expect(useAuthStore.getState()).toMatchObject({
sessionExpired: false,
sessionExpiryReason: null,
});
});
});
it("does not hide a real refresh failure after an intentional logout", async () => {
markIntentionalLogout();
mockSessionState = {
data: {
accessToken: "expired-access-token",
user: { id: "user-1", name: "Test User" },
error: "RefreshAccessTokenError",
},
status: "authenticated",
};
const { rerender } = render(
<App>
<div>应用内容</div>
</App>,
);
await waitFor(() => {
expect(useAuthStore.getState()).toMatchObject({
sessionExpired: true,
sessionExpiryReason: "refresh_failed",
});
});
mockSessionState = { data: null, status: "unauthenticated" };
rerender(
<App>
<div>应用内容</div>
</App>,
);
await waitFor(() => {
expect(useAuthStore.getState().sessionExpired).toBe(true);
});
});
}); });
+79 -51
View File
@@ -24,6 +24,10 @@ import { useAccessStore } from "@/store/accessStore";
import { useProjectStore } from "@/store/projectStore"; import { useProjectStore } from "@/store/projectStore";
import { apiFetch } from "@/lib/apiFetch"; import { apiFetch } from "@/lib/apiFetch";
import { completeLogout, reportLogoutAudit } from "@/lib/logoutFlow"; import { completeLogout, reportLogoutAudit } from "@/lib/logoutFlow";
import {
clearIntentionalLogout,
isIntentionalLogout,
} from "@/lib/logoutIntent";
import { clearSessionRecoveryDrafts } from "@/lib/sessionRecoveryDraft"; import { clearSessionRecoveryDrafts } from "@/lib/sessionRecoveryDraft";
import { permissionCodes, resourcePermissions } from "@/lib/permissions"; import { permissionCodes, resourcePermissions } from "@/lib/permissions";
import { config } from "@config/config"; import { config } from "@config/config";
@@ -75,7 +79,9 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
const setAccessContext = useAccessStore((state) => state.setContext); const setAccessContext = useAccessStore((state) => state.setContext);
const setAccessLoading = useAccessStore((state) => state.setLoading); const setAccessLoading = useAccessStore((state) => state.setLoading);
const resetAccess = useAccessStore((state) => state.reset); 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); const can = (permission: string) => permissions.includes(permission);
useEffect(() => { useEffect(() => {
@@ -86,31 +92,38 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
useEffect(() => { useEffect(() => {
if (data?.error === "SessionExpired") { if (data?.error === "SessionExpired") {
clearIntentionalLogout();
markSessionExpired("session_max_age"); markSessionExpired("session_max_age");
return; return;
} }
if (data?.error === "RefreshAccessTokenError") { if (data?.error === "RefreshAccessTokenError") {
clearIntentionalLogout();
markSessionExpired("refresh_failed"); markSessionExpired("refresh_failed");
return; return;
} }
if (status === "unauthenticated") { if (status === "unauthenticated") {
if (to === "/login" || isIntentionalLogout()) {
clearSessionExpired();
return;
}
markSessionExpired("unauthorized"); markSessionExpired("unauthorized");
return; return;
} }
if (status === "authenticated") { if (status === "authenticated") {
clearIntentionalLogout();
clearSessionExpired(); clearSessionExpired();
} }
}, [clearSessionExpired, data?.error, markSessionExpired, status]); }, [clearSessionExpired, data?.error, markSessionExpired, status, to]);
useEffect(() => { useEffect(() => {
if (status !== "authenticated") { if (status !== "authenticated") {
setAccessDenied(false); setAccessDeniedReason(null);
resetAccess(); resetAccess();
return; return;
} }
let cancelled = false; let cancelled = false;
setAccessDenied(false); setAccessDeniedReason(null);
setAccessLoading(true); setAccessLoading(true);
apiFetch(`${config.BACKEND_URL}/api/v1/access-context`, { apiFetch(`${config.BACKEND_URL}/api/v1/access-context`, {
projectHeaderMode: currentProjectId ? "include" : "omit", projectHeaderMode: currentProjectId ? "include" : "omit",
@@ -118,7 +131,17 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
.then(async (response) => { .then(async (response) => {
if (cancelled) return; if (cancelled) return;
if (response.status === 403) { 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(); resetAccess();
return; return;
} }
@@ -126,7 +149,7 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
resetAccess(); resetAccess();
return; return;
} }
setAccessDenied(false); setAccessDeniedReason(null);
setAccessContext(await response.json()); setAccessContext(await response.json());
}) })
.catch(() => { .catch(() => {
@@ -166,6 +189,28 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
return <span>loading...</span>; return <span>loading...</span>;
} }
const handleLogout = () =>
completeLogout({
reportAudit: () =>
reportLogoutAudit({
endpoint: `${config.BACKEND_URL}/api/v1/audit-events`,
accessToken:
typeof data?.accessToken === "string"
? data.accessToken
: 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}`);
}
clearSessionRecoveryDrafts();
},
navigate: (path) => window.location.assign(path),
});
const authProvider: AuthProvider = { const authProvider: AuthProvider = {
login: async () => { login: async () => {
signIn("keycloak", { signIn("keycloak", {
@@ -174,24 +219,7 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
}); });
return { success: true }; return { success: true };
}, },
logout: () => logout: 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),
}),
onError: async (error) => { onError: async (error) => {
return { error }; return { error };
}, },
@@ -384,34 +412,34 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
]; ];
return ( return (
<ProjectProvider> <RefineKbarProvider>
<RefineKbarProvider> <ColorModeContextProvider defaultMode={props.defaultMode}>
<ColorModeContextProvider defaultMode={props.defaultMode}> <RefineSnackbarProvider>
<RefineSnackbarProvider> <Refine
<Refine routerProvider={appRouterProvider}
routerProvider={appRouterProvider} dataProvider={dataProvider}
dataProvider={dataProvider} notificationProvider={useAppNotificationProvider}
notificationProvider={useAppNotificationProvider} authProvider={authProvider}
authProvider={authProvider} accessControlProvider={accessControlProvider}
accessControlProvider={accessControlProvider} resources={resources}
resources={resources} options={{
options={{ syncWithLocation: true,
syncWithLocation: true, warnWhenUnsavedChanges: true,
warnWhenUnsavedChanges: true, }}
}} >
<SessionExpiryDialog expiresAt={data?.sessionExpiresAt} />
<RoutePermissionGuard
authenticated={status === "authenticated"}
accessDeniedReason={accessDeniedReason}
> >
<SessionExpiryDialog expiresAt={data?.sessionExpiresAt} /> <ProjectProvider>
<RoutePermissionGuard
authenticated={status === "authenticated"}
accessDenied={accessDenied}
>
{props.children} {props.children}
</RoutePermissionGuard> </ProjectProvider>
<RefineKbar /> </RoutePermissionGuard>
</Refine> <RefineKbar />
</RefineSnackbarProvider> </Refine>
</ColorModeContextProvider> </RefineSnackbarProvider>
</RefineKbarProvider> </ColorModeContextProvider>
</ProjectProvider> </RefineKbarProvider>
); );
}; };
@@ -1,5 +1,36 @@
import authOptions from "./options"; 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", () => { describe("NextAuth access token refresh", () => {
it("forces a Keycloak refresh for an agent credential request", async () => { it("forces a Keycloak refresh for an agent credential request", async () => {
const previousEnv = { const previousEnv = {
+5 -2
View File
@@ -1,7 +1,6 @@
import { NextAuthOptions } from "next-auth"; import { NextAuthOptions } from "next-auth";
import { JWT } from "next-auth/jwt"; import { JWT } from "next-auth/jwt";
import KeycloakProvider from "next-auth/providers/keycloak"; import KeycloakProvider from "next-auth/providers/keycloak";
import Avatar from "@assets/avatar/avatar-small.jpeg";
const SESSION_MAX_AGE_SECONDS = 12 * 60 * 60; const SESSION_MAX_AGE_SECONDS = 12 * 60 * 60;
const ACCESS_TOKEN_REFRESH_SKEW_MS = 30_000; const ACCESS_TOKEN_REFRESH_SKEW_MS = 30_000;
@@ -86,12 +85,16 @@ const authOptions: NextAuthOptions = {
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET ?? "", clientSecret: process.env.KEYCLOAK_CLIENT_SECRET ?? "",
issuer: process.env.KEYCLOAK_ISSUER ?? "", issuer: process.env.KEYCLOAK_ISSUER ?? "",
profile(profile) { profile(profile) {
const picture =
typeof (profile as { picture?: unknown }).picture === "string"
? String((profile as { picture: string }).picture).trim()
: "";
return { return {
id: profile.sub, id: profile.sub,
username: profile.preferred_username, username: profile.preferred_username,
name: profile.name ?? profile.preferred_username, name: profile.name ?? profile.preferred_username,
email: profile.email, email: profile.email,
image: Avatar.src, image: picture || null,
}; };
}, },
}), }),
@@ -8,6 +8,12 @@ jest.mock("next/navigation", () => ({
usePathname: () => "/network-simulation", usePathname: () => "/network-simulation",
})); }));
jest.mock("@/components/header", () => ({
Header: ({ accessMode }: { accessMode: string }) => (
<div data-testid="access-header">{accessMode}</div>
),
}));
describe("RoutePermissionGuard", () => { describe("RoutePermissionGuard", () => {
beforeEach(() => { beforeEach(() => {
useAccessStore.setState({ useAccessStore.setState({
@@ -52,6 +58,34 @@ describe("RoutePermissionGuard", () => {
expect(screen.getByText(/simulation\.view/)).toBeInTheDocument(); expect(screen.getByText(/simulation\.view/)).toBeInTheDocument();
}); });
it("keeps a minimal identity header and logout action for pending accounts", () => {
render(
<RoutePermissionGuard
authenticated
accessDeniedReason="account"
>
<div>受保护内容</div>
</RoutePermissionGuard>,
);
expect(screen.getByTestId("access-header")).toHaveTextContent("account");
expect(screen.queryByText("受保护内容")).not.toBeInTheDocument();
});
it("keeps project recovery available when only the selected project is denied", () => {
render(
<RoutePermissionGuard
authenticated
accessDeniedReason="project"
>
<div>受保护内容</div>
</RoutePermissionGuard>,
);
expect(screen.getByText("无权访问当前项目")).toBeInTheDocument();
expect(screen.getByTestId("access-header")).toHaveTextContent("project");
});
it("checks authentication before the expired state effect runs", () => { it("checks authentication before the expired state effect runs", () => {
render( render(
<RoutePermissionGuard authenticated={false}> <RoutePermissionGuard authenticated={false}>
+38 -13
View File
@@ -1,10 +1,17 @@
"use client"; "use client";
import LockOutlinedIcon from "@mui/icons-material/LockOutlined"; import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
import { Alert, Box, CircularProgress, Stack, Typography } from "@mui/material"; import {
Alert,
Box,
CircularProgress,
Stack,
Typography,
} from "@mui/material";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Header } from "@/components/header";
import { permissionForPath } from "@/lib/permissions"; import { permissionForPath } from "@/lib/permissions";
import { useAccessStore } from "@/store/accessStore"; import { useAccessStore } from "@/store/accessStore";
import { useAuthStore } from "@/store/authStore"; import { useAuthStore } from "@/store/authStore";
@@ -12,11 +19,11 @@ import { useAuthStore } from "@/store/authStore";
export const RoutePermissionGuard = ({ export const RoutePermissionGuard = ({
children, children,
authenticated, authenticated,
accessDenied = false, accessDeniedReason = null,
}: { }: {
children: ReactNode; children: ReactNode;
authenticated: boolean; authenticated: boolean;
accessDenied?: boolean; accessDeniedReason?: "account" | "project" | null;
}) => { }) => {
const pathname = usePathname(); const pathname = usePathname();
const permissions = useAccessStore((state) => state.permissions); const permissions = useAccessStore((state) => state.permissions);
@@ -39,17 +46,35 @@ export const RoutePermissionGuard = ({
); );
} }
if (accessDenied) { if (accessDeniedReason) {
const accountPending = accessDeniedReason === "account";
return ( return (
<Box sx={{ p: { xs: 2, md: 4 } }}> <Box sx={{ minHeight: "100vh", bgcolor: "background.default" }}>
<Alert severity="warning" icon={<LockOutlinedIcon />}> <Header accessMode={accessDeniedReason} />
<Stack spacing={0.5}> <Box
<Typography fontWeight={700}>账号尚未获得访问权限</Typography> component="main"
<Typography variant="body2"> sx={{
身份认证已成功,但当前账号尚未由系统管理员启用,或无权访问所选项目。请联系系统管理员完成授权后重试。 width: "100%",
</Typography> maxWidth: 840,
</Stack> mx: "auto",
</Alert> 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> </Box>
); );
} }
+49 -28
View File
@@ -3,6 +3,7 @@
import { ColorModeContext } from "@contexts/color-mode"; import { ColorModeContext } from "@contexts/color-mode";
import DarkModeOutlined from "@mui/icons-material/DarkModeOutlined"; import DarkModeOutlined from "@mui/icons-material/DarkModeOutlined";
import LightModeOutlined from "@mui/icons-material/LightModeOutlined"; import LightModeOutlined from "@mui/icons-material/LightModeOutlined";
import { FiUser } from "react-icons/fi";
import { IoChatbubbleEllipsesOutline } from "react-icons/io5"; import { IoChatbubbleEllipsesOutline } from "react-icons/io5";
import Logout from "@mui/icons-material/Logout"; import Logout from "@mui/icons-material/Logout";
import SwapHoriz from "@mui/icons-material/SwapHoriz"; import SwapHoriz from "@mui/icons-material/SwapHoriz";
@@ -37,8 +38,13 @@ type IUser = {
avatar?: string; avatar?: string;
}; };
export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({ type HeaderProps = RefineThemedLayoutHeaderProps & {
accessMode?: "default" | "account" | "project";
};
export const Header: React.FC<HeaderProps> = ({
sticky = true, sticky = true,
accessMode = "default",
}) => { }) => {
const { mode, setMode } = useContext(ColorModeContext); const { mode, setMode } = useContext(ColorModeContext);
const { mutate: logout } = useLogout(); const { mutate: logout } = useLogout();
@@ -51,6 +57,7 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
); );
const { data: user } = useGetIdentity<IUser>(); const { data: user } = useGetIdentity<IUser>();
const restricted = accessMode !== "default";
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => { const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget); setAnchorEl(event.currentTarget);
@@ -92,7 +99,7 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
justifyContent="flex-end" justifyContent="flex-end"
alignItems="center" alignItems="center"
> >
<HamburgerMenu /> {!restricted && <HamburgerMenu />}
<Stack <Stack
direction="row" direction="row"
width="100%" width="100%"
@@ -110,20 +117,26 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
{(user?.avatar || user?.name) && ( {(user?.avatar || user?.name) && (
<> <>
<IconButton {!restricted && (
color="inherit" <IconButton
onClick={() => setShowChatbox(true)} color="inherit"
sx={{ mr: 1 }} onClick={() => setShowChatbox(true)}
> sx={{ mr: 1 }}
<IoChatbubbleEllipsesOutline /> >
</IconButton> <IoChatbubbleEllipsesOutline />
</IconButton>
)}
<ButtonBase <ButtonBase
aria-label={
user?.name ? `${user.name} 账号菜单` : "账号菜单"
}
onClick={handleMenuOpen} onClick={handleMenuOpen}
sx={{ sx={{
borderRadius: "30px", borderRadius: "30px",
padding: "6px 12px", padding: "6px 12px",
marginLeft: "8px", 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", border: "1px solid transparent",
"&:hover": { "&:hover": {
backgroundColor: backgroundColor:
@@ -178,11 +191,13 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
}`, }`,
transition: "transform 0.3s ease", transition: "transform 0.3s ease",
".MuiButtonBase-root:hover &": { ".MuiButtonBase-root:hover &": {
transform: "rotate(5deg) scale(1.05)", transform: "rotate(5deg)",
borderColor: "primary.main", borderColor: "primary.main",
}, },
}} }}
/> >
{!user?.avatar && <FiUser aria-hidden="true" />}
</Avatar>
</Stack> </Stack>
</ButtonBase> </ButtonBase>
<Menu <Menu
@@ -208,13 +223,15 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
}, },
}} }}
> >
<MenuItem onClick={handleSwitchProjectClick}> {accessMode !== "account" && (
<ListItemIcon> <MenuItem onClick={handleSwitchProjectClick}>
<SwapHoriz fontSize="small" /> <ListItemIcon>
</ListItemIcon> <SwapHoriz fontSize="small" />
<ListItemText>切换项目</ListItemText> </ListItemIcon>
</MenuItem> <ListItemText>切换项目</ListItemText>
<Divider /> </MenuItem>
)}
{accessMode !== "account" && <Divider />}
<MenuItem onClick={() => logout()}> <MenuItem onClick={() => logout()}>
<ListItemIcon> <ListItemIcon>
<Logout fontSize="small" /> <Logout fontSize="small" />
@@ -222,17 +239,21 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
<ListItemText>登出</ListItemText> <ListItemText>登出</ListItemText>
</MenuItem> </MenuItem>
</Menu> </Menu>
<ProjectSelector {accessMode !== "account" && (
open={showProjectSelector} <ProjectSelector
onSelect={handleProjectSelect} open={showProjectSelector}
onClose={() => setShowProjectSelector(false)} onSelect={handleProjectSelect}
/> onClose={() => setShowProjectSelector(false)}
/>
)}
</> </>
)} )}
<GlobalChatbox {!restricted && (
open={showChatbox} <GlobalChatbox
onClose={() => setShowChatbox(false)} open={showChatbox}
/> onClose={() => setShowChatbox(false)}
/>
)}
</Stack> </Stack>
</Stack> </Stack>
</Toolbar> </Toolbar>
+5 -2
View File
@@ -132,8 +132,11 @@ export const ProjectSelector: React.FC<ProjectSelectorProps> = ({
paper: { paper: {
sx: { sx: {
borderRadius: 2, borderRadius: 2,
padding: 2, boxSizing: "border-box",
minWidth: 400, 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)", background: "rgba(255, 255, 255, 0.95)",
backdropFilter: "blur(10px)", backdropFilter: "blur(10px)",
position: "relative", position: "relative",
@@ -110,7 +110,7 @@ describe("ThreeDimensionalControls", () => {
/>, />,
); );
fireEvent.click(screen.getByRole("button", { name: "站房精细版" })); fireEvent.click(screen.getByRole("button", { name: "站区精细版" }));
expect(onCommand).toHaveBeenCalledWith({ name: "set-mode", mode: "detail" }); expect(onCommand).toHaveBeenCalledWith({ name: "set-mode", mode: "detail" });
fireEvent.change(screen.getByRole("slider", { name: "建筑背景透明度 60%" }), { fireEvent.change(screen.getByRole("slider", { name: "建筑背景透明度 60%" }), {
target: { value: "0.35" }, target: { value: "0.35" },
@@ -35,7 +35,7 @@ const sceneModes: Array<{ mode: SceneMode; label: string }> = [
{ mode: "network", label: "供水管网" }, { mode: "network", label: "供水管网" },
{ mode: "hydraulic", label: "泵组与管网" }, { mode: "hydraulic", label: "泵组与管网" },
{ mode: "map", label: "站区轻量版" }, { mode: "map", label: "站区轻量版" },
{ mode: "detail", label: "站房精细版" }, { mode: "detail", label: "站区精细版" },
{ mode: "pump", label: "CAD 泵房参考" }, { mode: "pump", label: "CAD 泵房参考" },
{ mode: "meters", label: "设备样件" }, { mode: "meters", label: "设备样件" },
]; ];
+25
View File
@@ -1,4 +1,8 @@
import { completeLogout, reportLogoutAudit } from "./logoutFlow"; import { completeLogout, reportLogoutAudit } from "./logoutFlow";
import {
clearIntentionalLogout,
isIntentionalLogout,
} from "./logoutIntent";
describe("reportLogoutAudit", () => { describe("reportLogoutAudit", () => {
it("starts an authenticated keepalive request", async () => { it("starts an authenticated keepalive request", async () => {
@@ -28,6 +32,10 @@ describe("reportLogoutAudit", () => {
}); });
describe("completeLogout", () => { describe("completeLogout", () => {
beforeEach(() => {
clearIntentionalLogout();
});
it("navigates immediately without waiting for audit reporting", async () => { it("navigates immediately without waiting for audit reporting", async () => {
const reportAudit = jest.fn( const reportAudit = jest.fn(
() => new Promise<unknown>(() => undefined), () => new Promise<unknown>(() => undefined),
@@ -44,6 +52,7 @@ describe("completeLogout", () => {
await Promise.resolve(); await Promise.resolve();
expect(reportAudit).toHaveBeenCalledTimes(1); expect(reportAudit).toHaveBeenCalledTimes(1);
expect(isIntentionalLogout()).toBe(true);
expect(clearLocalState).toHaveBeenCalledTimes(1); expect(clearLocalState).toHaveBeenCalledTimes(1);
expect(navigate).toHaveBeenCalledWith("/api/auth/keycloak-logout"); expect(navigate).toHaveBeenCalledWith("/api/auth/keycloak-logout");
await expect(result).resolves.toEqual({ success: true }); await expect(result).resolves.toEqual({ success: true });
@@ -79,4 +88,20 @@ describe("completeLogout", () => {
expect(clearLocalState).toHaveBeenCalledTimes(1); expect(clearLocalState).toHaveBeenCalledTimes(1);
expect(navigate).toHaveBeenCalledWith("/api/auth/keycloak-logout"); expect(navigate).toHaveBeenCalledWith("/api/auth/keycloak-logout");
}); });
it("still navigates when local cleanup throws", async () => {
const navigate = jest.fn();
await expect(
completeLogout({
reportAudit: jest.fn().mockResolvedValue(undefined),
clearLocalState: () => {
throw new DOMException("Storage unavailable", "SecurityError");
},
navigate,
}),
).resolves.toEqual({ success: true });
expect(navigate).toHaveBeenCalledWith("/api/auth/keycloak-logout");
});
}); });
+8 -1
View File
@@ -1,3 +1,5 @@
import { markIntentionalLogout } from "@/lib/logoutIntent";
const KEYCLOAK_LOGOUT_PATH = "/api/auth/keycloak-logout"; const KEYCLOAK_LOGOUT_PATH = "/api/auth/keycloak-logout";
type CompleteLogoutOptions = { type CompleteLogoutOptions = {
@@ -41,7 +43,12 @@ export const completeLogout = async ({
// Synchronous reporting errors must not block logout either. // Synchronous reporting errors must not block logout either.
} }
clearLocalState(); markIntentionalLogout();
try {
clearLocalState();
} catch {
// Browser storage cleanup failures must not prevent the IdP logout.
}
navigate(KEYCLOAK_LOGOUT_PATH); navigate(KEYCLOAK_LOGOUT_PATH);
return { success: true as const }; return { success: true as const };
}; };
+57
View File
@@ -0,0 +1,57 @@
import {
clearIntentionalLogout,
isIntentionalLogout,
markIntentionalLogout,
} from "./logoutIntent";
describe("intentional logout marker", () => {
afterEach(() => {
jest.restoreAllMocks();
clearIntentionalLogout();
});
it("stores a timestamp only for the current tab", () => {
jest.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
markIntentionalLogout();
expect(sessionStorage.getItem("tjwater:intentional-logout")).toBe(
"1700000000000",
);
expect(localStorage.getItem("tjwater:intentional-logout")).toBeNull();
expect(isIntentionalLogout()).toBe(true);
});
it("accepts an intentional logout event from another open tab", () => {
window.dispatchEvent(
new StorageEvent("storage", {
key: "tjwater:intentional-logout-event",
newValue: JSON.stringify({
type: "mark",
markedAt: Date.now(),
}),
}),
);
expect(isIntentionalLogout()).toBe(true);
});
it("does not broadcast a clear event when the current tab has no marker", () => {
clearIntentionalLogout();
const setItem = jest.spyOn(Storage.prototype, "setItem");
expect(isIntentionalLogout()).toBe(false);
expect(setItem).not.toHaveBeenCalled();
});
it("expires a stale marker", () => {
const now = 1_700_000_000_000;
const nowSpy = jest.spyOn(Date, "now").mockReturnValue(now);
markIntentionalLogout();
nowSpy.mockReturnValue(now + 30 * 60 * 1000 + 1);
expect(isIntentionalLogout()).toBe(false);
expect(sessionStorage.getItem("tjwater:intentional-logout")).toBeNull();
});
});
+81
View File
@@ -0,0 +1,81 @@
const INTENTIONAL_LOGOUT_STORAGE_KEY = "tjwater:intentional-logout";
const INTENTIONAL_LOGOUT_EVENT_KEY = "tjwater:intentional-logout-event";
const INTENTIONAL_LOGOUT_TTL_MS = 30 * 60 * 1000;
let memoryMarkedAt: number | null = null;
const withStorage = (
storageName: "localStorage" | "sessionStorage",
callback: (storage: Storage) => void,
) => {
if (typeof window === "undefined") return;
try {
callback(window[storageName]);
} catch {
// Logout must still proceed when browser storage is unavailable.
}
};
const setLocalMarker = (markedAt: number | null) => {
memoryMarkedAt = markedAt;
withStorage("sessionStorage", (storage) => {
if (markedAt === null) {
storage.removeItem(INTENTIONAL_LOGOUT_STORAGE_KEY);
} else {
storage.setItem(INTENTIONAL_LOGOUT_STORAGE_KEY, String(markedAt));
}
});
};
const broadcastIntent = (
payload: { type: "mark"; markedAt: number } | { type: "clear" },
) => {
withStorage("localStorage", (storage) => {
storage.setItem(INTENTIONAL_LOGOUT_EVENT_KEY, JSON.stringify(payload));
storage.removeItem(INTENTIONAL_LOGOUT_EVENT_KEY);
});
};
if (typeof window !== "undefined") {
window.addEventListener("storage", (event) => {
if (event.key !== INTENTIONAL_LOGOUT_EVENT_KEY || !event.newValue) return;
try {
const payload = JSON.parse(event.newValue) as {
type?: unknown;
markedAt?: unknown;
};
if (payload.type === "mark" && typeof payload.markedAt === "number") {
setLocalMarker(payload.markedAt);
} else if (payload.type === "clear") {
setLocalMarker(null);
}
} catch {
// Ignore malformed cross-tab events.
}
});
}
export const markIntentionalLogout = () => {
const markedAt = Date.now();
setLocalMarker(markedAt);
broadcastIntent({ type: "mark", markedAt });
};
export const clearIntentionalLogout = () => {
setLocalMarker(null);
broadcastIntent({ type: "clear" });
};
export const isIntentionalLogout = () => {
let markedAt = memoryMarkedAt;
withStorage("sessionStorage", (storage) => {
const stored = Number(storage.getItem(INTENTIONAL_LOGOUT_STORAGE_KEY));
if (Number.isFinite(stored) && stored > 0) markedAt = stored;
});
if (markedAt === null) return false;
const age = Date.now() - markedAt;
if (age < 0 || age > INTENTIONAL_LOGOUT_TTL_MS) {
setLocalMarker(null);
return false;
}
return true;
};
+45
View File
@@ -0,0 +1,45 @@
import {
clearIntentionalLogout,
markIntentionalLogout,
} from "@/lib/logoutIntent";
import { useAuthStore } from "./authStore";
describe("authStore intentional logout boundary", () => {
beforeEach(() => {
clearIntentionalLogout();
useAuthStore.setState({
accessToken: "access-token",
sessionExpired: false,
sessionExpiryReason: null,
});
});
afterEach(() => {
clearIntentionalLogout();
});
it("ignores late expiry signals after an intentional logout", () => {
markIntentionalLogout();
useAuthStore.getState().markSessionExpired("unauthorized");
expect(useAuthStore.getState()).toMatchObject({
accessToken: null,
sessionExpired: false,
sessionExpiryReason: null,
});
});
it("keeps real refresh failures visible after an intentional logout", () => {
markIntentionalLogout();
useAuthStore.getState().markSessionExpired("refresh_failed");
expect(useAuthStore.getState()).toMatchObject({
accessToken: null,
sessionExpired: true,
sessionExpiryReason: "refresh_failed",
});
});
});
+17 -5
View File
@@ -1,5 +1,7 @@
import { create } from "zustand"; import { create } from "zustand";
import { isIntentionalLogout } from "@/lib/logoutIntent";
interface AuthState { interface AuthState {
accessToken: string | null; accessToken: string | null;
sessionExpired: boolean; sessionExpired: boolean;
@@ -16,11 +18,21 @@ export const useAuthStore = create<AuthState>((set) => ({
sessionExpired: false, sessionExpired: false,
sessionExpiryReason: null, sessionExpiryReason: null,
setAccessToken: (token) => set({ accessToken: token }), setAccessToken: (token) => set({ accessToken: token }),
markSessionExpired: (reason) => set({ markSessionExpired: (reason) => {
accessToken: null, if (reason === "unauthorized" && isIntentionalLogout()) {
sessionExpired: true, set({
sessionExpiryReason: reason, accessToken: null,
}), sessionExpired: false,
sessionExpiryReason: null,
});
return;
}
set({
accessToken: null,
sessionExpired: true,
sessionExpiryReason: reason,
});
},
clearSessionExpired: () => set({ clearSessionExpired: () => set({
sessionExpired: false, sessionExpired: false,
sessionExpiryReason: null, sessionExpiryReason: null,