Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4c8fc9db8 | ||
|
|
95ec090f2e | ||
|
|
e3ab82f6df | ||
|
|
5a0bb6864a |
@@ -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 }) => {
|
||||
|
||||
@@ -3,12 +3,17 @@ import type { ReactNode } from "react";
|
||||
|
||||
import { useAccessStore } from "@/store/accessStore";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
import {
|
||||
clearIntentionalLogout,
|
||||
markIntentionalLogout,
|
||||
} from "@/lib/logoutIntent";
|
||||
import { App } from "./RefineContext";
|
||||
|
||||
let mockSessionState: {
|
||||
data: {
|
||||
accessToken: string;
|
||||
user: { id: string; name: string };
|
||||
error?: "RefreshAccessTokenError" | "SessionExpired";
|
||||
} | null;
|
||||
status: "authenticated" | "unauthenticated";
|
||||
} = {
|
||||
@@ -18,6 +23,8 @@ let mockSessionState: {
|
||||
},
|
||||
status: "authenticated",
|
||||
};
|
||||
let mockPathname = "/network-simulation";
|
||||
let mockProjectProviderBlocks = false;
|
||||
|
||||
jest.mock("next-auth/react", () => ({
|
||||
SessionProvider: ({ children }: { children: ReactNode }) => children,
|
||||
@@ -26,7 +33,7 @@ jest.mock("next-auth/react", () => ({
|
||||
}));
|
||||
|
||||
jest.mock("next/navigation", () => ({
|
||||
usePathname: () => "/network-simulation",
|
||||
usePathname: () => mockPathname,
|
||||
}));
|
||||
|
||||
jest.mock("@refinedev/core", () => ({
|
||||
@@ -43,6 +50,11 @@ jest.mock("@refinedev/mui", () => ({
|
||||
}));
|
||||
|
||||
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/notification-provider/useAppNotificationProvider", () => ({
|
||||
useAppNotificationProvider: {},
|
||||
@@ -51,7 +63,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 ? <div>请选择项目环境</div> : children,
|
||||
}));
|
||||
jest.mock("@/lib/authToken", () => ({
|
||||
getAccessToken: jest.fn().mockResolvedValue("expired-access-token"),
|
||||
@@ -62,6 +75,9 @@ describe("RefineContext access authentication", () => {
|
||||
const originalRequest = global.Request;
|
||||
|
||||
beforeEach(() => {
|
||||
clearIntentionalLogout();
|
||||
mockPathname = "/network-simulation";
|
||||
mockProjectProviderBlocks = false;
|
||||
mockSessionState = {
|
||||
data: {
|
||||
accessToken: "expired-access-token",
|
||||
@@ -116,6 +132,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 +149,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 +173,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 +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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+42
-39
@@ -24,6 +24,10 @@ import { useAccessStore } from "@/store/accessStore";
|
||||
import { useProjectStore } from "@/store/projectStore";
|
||||
import { apiFetch } from "@/lib/apiFetch";
|
||||
import { completeLogout, reportLogoutAudit } from "@/lib/logoutFlow";
|
||||
import {
|
||||
clearIntentionalLogout,
|
||||
isIntentionalLogout,
|
||||
} from "@/lib/logoutIntent";
|
||||
import { clearSessionRecoveryDrafts } from "@/lib/sessionRecoveryDraft";
|
||||
import { permissionCodes, resourcePermissions } from "@/lib/permissions";
|
||||
import { config } from "@config/config";
|
||||
@@ -68,7 +72,6 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
|
||||
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,
|
||||
);
|
||||
@@ -89,21 +92,28 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.error === "SessionExpired") {
|
||||
clearIntentionalLogout();
|
||||
markSessionExpired("session_max_age");
|
||||
return;
|
||||
}
|
||||
if (data?.error === "RefreshAccessTokenError") {
|
||||
clearIntentionalLogout();
|
||||
markSessionExpired("refresh_failed");
|
||||
return;
|
||||
}
|
||||
if (status === "unauthenticated") {
|
||||
if (to === "/login" || isIntentionalLogout()) {
|
||||
clearSessionExpired();
|
||||
return;
|
||||
}
|
||||
markSessionExpired("unauthorized");
|
||||
return;
|
||||
}
|
||||
if (status === "authenticated") {
|
||||
clearIntentionalLogout();
|
||||
clearSessionExpired();
|
||||
}
|
||||
}, [clearSessionExpired, data?.error, markSessionExpired, status]);
|
||||
}, [clearSessionExpired, data?.error, markSessionExpired, status, to]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== "authenticated") {
|
||||
@@ -190,6 +200,9 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
|
||||
: 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 +211,6 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
|
||||
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 +412,34 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
|
||||
];
|
||||
|
||||
return (
|
||||
<ProjectProvider>
|
||||
<RefineKbarProvider>
|
||||
<ColorModeContextProvider defaultMode={props.defaultMode}>
|
||||
<RefineSnackbarProvider>
|
||||
<Refine
|
||||
routerProvider={appRouterProvider}
|
||||
dataProvider={dataProvider}
|
||||
notificationProvider={useAppNotificationProvider}
|
||||
authProvider={authProvider}
|
||||
accessControlProvider={accessControlProvider}
|
||||
resources={resources}
|
||||
options={{
|
||||
syncWithLocation: true,
|
||||
warnWhenUnsavedChanges: true,
|
||||
}}
|
||||
<RefineKbarProvider>
|
||||
<ColorModeContextProvider defaultMode={props.defaultMode}>
|
||||
<RefineSnackbarProvider>
|
||||
<Refine
|
||||
routerProvider={appRouterProvider}
|
||||
dataProvider={dataProvider}
|
||||
notificationProvider={useAppNotificationProvider}
|
||||
authProvider={authProvider}
|
||||
accessControlProvider={accessControlProvider}
|
||||
resources={resources}
|
||||
options={{
|
||||
syncWithLocation: true,
|
||||
warnWhenUnsavedChanges: true,
|
||||
}}
|
||||
>
|
||||
<SessionExpiryDialog expiresAt={data?.sessionExpiresAt} />
|
||||
<RoutePermissionGuard
|
||||
authenticated={status === "authenticated"}
|
||||
accessDeniedReason={accessDeniedReason}
|
||||
>
|
||||
<SessionExpiryDialog expiresAt={data?.sessionExpiresAt} />
|
||||
<RoutePermissionGuard
|
||||
authenticated={status === "authenticated"}
|
||||
accessDeniedReason={accessDeniedReason}
|
||||
userName={data?.user?.name ?? data?.user?.username}
|
||||
onLogout={() => void handleLogout()}
|
||||
onChooseAnotherProject={handleChooseAnotherProject}
|
||||
>
|
||||
<ProjectProvider>
|
||||
{props.children}
|
||||
</RoutePermissionGuard>
|
||||
<RefineKbar />
|
||||
</Refine>
|
||||
</RefineSnackbarProvider>
|
||||
</ColorModeContextProvider>
|
||||
</RefineKbarProvider>
|
||||
</ProjectProvider>
|
||||
</ProjectProvider>
|
||||
</RoutePermissionGuard>
|
||||
<RefineKbar />
|
||||
</Refine>
|
||||
</RefineSnackbarProvider>
|
||||
</ColorModeContextProvider>
|
||||
</RefineKbarProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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 }) => (
|
||||
<div data-testid="access-header">{accessMode}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
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(
|
||||
<RoutePermissionGuard
|
||||
authenticated
|
||||
accessDeniedReason="account"
|
||||
userName="zjb"
|
||||
onLogout={onLogout}
|
||||
>
|
||||
<div>受保护内容</div>
|
||||
</RoutePermissionGuard>,
|
||||
);
|
||||
|
||||
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(
|
||||
<RoutePermissionGuard
|
||||
authenticated
|
||||
accessDeniedReason="project"
|
||||
onChooseAnotherProject={onChooseAnotherProject}
|
||||
>
|
||||
<div>受保护内容</div>
|
||||
</RoutePermissionGuard>,
|
||||
);
|
||||
|
||||
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", () => {
|
||||
|
||||
@@ -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 (
|
||||
<Box sx={{ minHeight: "100vh", bgcolor: "background.default" }}>
|
||||
<AppBar
|
||||
color="inherit"
|
||||
elevation={0}
|
||||
position="static"
|
||||
sx={{ borderBottom: 1, borderColor: "divider" }}
|
||||
>
|
||||
<Toolbar sx={{ gap: 2, justifyContent: "space-between" }}>
|
||||
<Title />
|
||||
<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={{
|
||||
|
||||
@@ -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,27 +117,32 @@ 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",
|
||||
border: "1px solid transparent",
|
||||
"&:hover": {
|
||||
backgroundColor:
|
||||
mode === "dark"
|
||||
? "rgba(255, 255, 255, 0.05)"
|
||||
: "rgba(0, 0, 0, 0.04)",
|
||||
transform: "translateY(-1px)",
|
||||
border: `1px solid ${mode === "dark"
|
||||
? "rgba(255, 255, 255, 0.2)"
|
||||
: "rgba(0, 0, 0, 0.1)"
|
||||
@@ -141,7 +153,6 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
|
||||
: "0 4px 12px rgba(0,0,0,0.08)",
|
||||
},
|
||||
"&:active": {
|
||||
transform: "translateY(0px)",
|
||||
boxShadow: "none",
|
||||
},
|
||||
}}
|
||||
@@ -176,13 +187,13 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
|
||||
? "rgba(255,255,255,0.2)"
|
||||
: "rgba(0,0,0,0.1)"
|
||||
}`,
|
||||
transition: "transform 0.3s ease",
|
||||
".MuiButtonBase-root:hover &": {
|
||||
transform: "rotate(5deg) scale(1.05)",
|
||||
borderColor: "primary.main",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{!user?.avatar && <FiUser aria-hidden="true" />}
|
||||
</Avatar>
|
||||
</Stack>
|
||||
</ButtonBase>
|
||||
<Menu
|
||||
@@ -208,13 +219,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 +235,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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" });
|
||||
fireEvent.change(screen.getByRole("slider", { name: "建筑背景透明度 60%" }), {
|
||||
target: { value: "0.35" },
|
||||
|
||||
@@ -35,7 +35,7 @@ const sceneModes: Array<{ mode: SceneMode; label: string }> = [
|
||||
{ mode: "network", label: "供水管网" },
|
||||
{ mode: "hydraulic", label: "泵组与管网" },
|
||||
{ mode: "map", label: "站区轻量版" },
|
||||
{ mode: "detail", label: "站房精细版" },
|
||||
{ mode: "detail", label: "站区精细版" },
|
||||
{ mode: "pump", label: "CAD 泵房参考" },
|
||||
{ mode: "meters", label: "设备样件" },
|
||||
];
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { completeLogout, reportLogoutAudit } from "./logoutFlow";
|
||||
import {
|
||||
clearIntentionalLogout,
|
||||
isIntentionalLogout,
|
||||
} from "./logoutIntent";
|
||||
|
||||
describe("reportLogoutAudit", () => {
|
||||
it("starts an authenticated keepalive request", async () => {
|
||||
@@ -28,6 +32,10 @@ describe("reportLogoutAudit", () => {
|
||||
});
|
||||
|
||||
describe("completeLogout", () => {
|
||||
beforeEach(() => {
|
||||
clearIntentionalLogout();
|
||||
});
|
||||
|
||||
it("navigates immediately without waiting for audit reporting", async () => {
|
||||
const reportAudit = jest.fn(
|
||||
() => new Promise<unknown>(() => undefined),
|
||||
@@ -44,6 +52,7 @@ describe("completeLogout", () => {
|
||||
await Promise.resolve();
|
||||
|
||||
expect(reportAudit).toHaveBeenCalledTimes(1);
|
||||
expect(isIntentionalLogout()).toBe(true);
|
||||
expect(clearLocalState).toHaveBeenCalledTimes(1);
|
||||
expect(navigate).toHaveBeenCalledWith("/api/auth/keycloak-logout");
|
||||
await expect(result).resolves.toEqual({ success: true });
|
||||
@@ -79,4 +88,20 @@ describe("completeLogout", () => {
|
||||
expect(clearLocalState).toHaveBeenCalledTimes(1);
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { markIntentionalLogout } from "@/lib/logoutIntent";
|
||||
|
||||
const KEYCLOAK_LOGOUT_PATH = "/api/auth/keycloak-logout";
|
||||
|
||||
type CompleteLogoutOptions = {
|
||||
@@ -41,7 +43,12 @@ export const completeLogout = async ({
|
||||
// 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);
|
||||
return { success: true as const };
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
@@ -1,5 +1,7 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
import { isIntentionalLogout } from "@/lib/logoutIntent";
|
||||
|
||||
interface AuthState {
|
||||
accessToken: string | null;
|
||||
sessionExpired: boolean;
|
||||
@@ -16,11 +18,21 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
sessionExpired: false,
|
||||
sessionExpiryReason: null,
|
||||
setAccessToken: (token) => set({ accessToken: token }),
|
||||
markSessionExpired: (reason) => set({
|
||||
accessToken: null,
|
||||
sessionExpired: true,
|
||||
sessionExpiryReason: reason,
|
||||
}),
|
||||
markSessionExpired: (reason) => {
|
||||
if (reason === "unauthorized" && isIntentionalLogout()) {
|
||||
set({
|
||||
accessToken: null,
|
||||
sessionExpired: false,
|
||||
sessionExpiryReason: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
set({
|
||||
accessToken: null,
|
||||
sessionExpired: true,
|
||||
sessionExpiryReason: reason,
|
||||
});
|
||||
},
|
||||
clearSessionExpired: () => set({
|
||||
sessionExpired: false,
|
||||
sessionExpiryReason: null,
|
||||
|
||||
Reference in New Issue
Block a user