Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd9d04a370 | ||
|
|
eff59c5a7e | ||
|
|
d98fa359ae | ||
|
|
a191d81c29 | ||
|
|
d4c8fc9db8 | ||
|
|
95ec090f2e | ||
|
|
e3ab82f6df | ||
|
|
5a0bb6864a | ||
|
|
80f05e481b | ||
|
|
6ff5322353 | ||
|
|
ca1c41aa9d | ||
|
|
7f1ab4a79c |
@@ -7,7 +7,7 @@
|
|||||||
},
|
},
|
||||||
"server": {
|
"server": {
|
||||||
"file": "server-v1.openapi.json",
|
"file": "server-v1.openapi.json",
|
||||||
"sha256": "b565d841061c9091f48ff3118bcc0cbb1b918b0cb8c2316d177570e8b2d8ba29"
|
"sha256": "1edfd78405cfe2a50022afe70a4c4f32caa4e98e3b5b54b40841d532058f2fd6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1201,6 +1201,59 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"JsonValue": {},
|
"JsonValue": {},
|
||||||
|
"KeycloakUserSyncIssue": {
|
||||||
|
"properties": {
|
||||||
|
"reason": {
|
||||||
|
"title": "Reason",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"title": "Username",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"username",
|
||||||
|
"reason"
|
||||||
|
],
|
||||||
|
"title": "KeycloakUserSyncIssue",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"KeycloakUserSyncResponse": {
|
||||||
|
"properties": {
|
||||||
|
"created": {
|
||||||
|
"title": "Created",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"issues": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/KeycloakUserSyncIssue"
|
||||||
|
},
|
||||||
|
"title": "Issues",
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"scanned": {
|
||||||
|
"title": "Scanned",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"skipped": {
|
||||||
|
"title": "Skipped",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"updated": {
|
||||||
|
"title": "Updated",
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"scanned",
|
||||||
|
"created",
|
||||||
|
"updated",
|
||||||
|
"skipped"
|
||||||
|
],
|
||||||
|
"title": "KeycloakUserSyncResponse",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"LeakageIdentifyRequestRest": {
|
"LeakageIdentifyRequestRest": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"dma_count": {
|
"dma_count": {
|
||||||
@@ -3201,6 +3254,92 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/admin/keycloak-user-syncs": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "post_admin_keycloak_user_syncs",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/KeycloakUserSyncResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Successful Response"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Authentication required"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Insufficient permission"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Resource not found"
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Resource conflict"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Validation error"
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Dependency unavailable"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"OAuth2PasswordBearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Sync Keycloak User Directory",
|
||||||
|
"tags": [
|
||||||
|
"Metadata Admin"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/admin/project-provisions": {
|
"/api/v1/admin/project-provisions": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "post_admin_project_provisions",
|
"operationId": "post_admin_project_provisions",
|
||||||
@@ -35969,6 +36108,23 @@
|
|||||||
"type": "array"
|
"type": "array"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "事故要素类型;auto优先管段再节点;分析同名节点须指定node,纯管段分析可指定link",
|
||||||
|
"in": "query",
|
||||||
|
"name": "accident_element_type",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"default": "auto",
|
||||||
|
"description": "事故要素类型;auto优先管段再节点;分析同名节点须指定node,纯管段分析可指定link",
|
||||||
|
"enum": [
|
||||||
|
"auto",
|
||||||
|
"link",
|
||||||
|
"node"
|
||||||
|
],
|
||||||
|
"title": "Accident Element Type",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"in": "header",
|
"in": "header",
|
||||||
"name": "X-Project-Id",
|
"name": "X-Project-Id",
|
||||||
|
|||||||
@@ -15,3 +15,76 @@ test("已登录管理员可以打开审计日志页面", async ({ page }) => {
|
|||||||
await expect(page.getByText("系统管理员权限已验证")).toBeVisible();
|
await expect(page.getByText("系统管理员权限已验证")).toBeVisible();
|
||||||
await expect(page.getByRole("heading", { name: "查询结果" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "查询结果" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("已认证但未授权的账号会看到明确说明", async ({ page }) => {
|
||||||
|
await page.addInitScript(() => window.localStorage.clear());
|
||||||
|
await page.route("**/api/v1/access-context", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 403,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ detail: "User authorization pending" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
await expect(page.getByText("账号尚未获得访问权限")).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByText("身份认证已成功,但当前账号尚未由系统管理员启用"),
|
||||||
|
).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 }) => {
|
||||||
|
await page.goto("/system-admin");
|
||||||
|
await page.getByRole("tab", { name: "系统用户" }).click();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "同步 Keycloak 用户" }).click();
|
||||||
|
await expect(page.getByRole("button", { name: "正在同步" })).toBeDisabled();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByText("已扫描 3 个 Keycloak 身份,新增 1 个,更新 1 个,跳过 1 个。"),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByText("跳过项包括服务账号、缺少邮箱或身份冲突")).toBeVisible();
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await expect(page.getByRole("button", { name: "同步 Keycloak 用户" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "刷新", exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|||||||
@@ -50,10 +50,27 @@ export const mockBackend = async (page: Page) => {
|
|||||||
return json(route, { count: 0 });
|
return json(route, { count: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
url.pathname === "/api/v1/admin/keycloak-user-syncs" &&
|
||||||
|
route.request().method() === "POST"
|
||||||
|
) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||||
|
return json(route, {
|
||||||
|
scanned: 3,
|
||||||
|
created: 1,
|
||||||
|
updated: 1,
|
||||||
|
skipped: 1,
|
||||||
|
issues: [{ username: "no-email", reason: "missing_email" }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
url.pathname === "/api/v1/audit-logs" ||
|
url.pathname === "/api/v1/audit-logs" ||
|
||||||
url.pathname === "/api/v1/admin/users" ||
|
url.pathname === "/api/v1/admin/users" ||
|
||||||
url.pathname === "/api/v1/admin/projects"
|
url.pathname === "/api/v1/admin/projects" ||
|
||||||
|
/^\/api\/v1\/admin\/projects\/[^/]+\/(members|databases)$/.test(
|
||||||
|
url.pathname,
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
return json(route, []);
|
return json(route, []);
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
@@ -1,14 +1,20 @@
|
|||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { signIn } from "next-auth/react";
|
||||||
import type { ReactNode } from "react";
|
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 +24,10 @@ let mockSessionState: {
|
|||||||
},
|
},
|
||||||
status: "authenticated",
|
status: "authenticated",
|
||||||
};
|
};
|
||||||
|
let mockPathname = "/network-simulation";
|
||||||
|
let mockProjectProviderBlocks = false;
|
||||||
|
let capturedLogin: (() => Promise<unknown>) | null = null;
|
||||||
|
const replaceMock = jest.fn();
|
||||||
|
|
||||||
jest.mock("next-auth/react", () => ({
|
jest.mock("next-auth/react", () => ({
|
||||||
SessionProvider: ({ children }: { children: ReactNode }) => children,
|
SessionProvider: ({ children }: { children: ReactNode }) => children,
|
||||||
@@ -26,11 +36,21 @@ jest.mock("next-auth/react", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock("next/navigation", () => ({
|
jest.mock("next/navigation", () => ({
|
||||||
usePathname: () => "/network-simulation",
|
usePathname: () => mockPathname,
|
||||||
|
useRouter: () => ({ replace: replaceMock }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock("@refinedev/core", () => ({
|
jest.mock("@refinedev/core", () => ({
|
||||||
Refine: ({ children }: { children: ReactNode }) => children,
|
Refine: ({
|
||||||
|
children,
|
||||||
|
authProvider,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
authProvider: { login: (params: Record<string, never>) => Promise<unknown> };
|
||||||
|
}) => {
|
||||||
|
capturedLogin = () => authProvider.login({});
|
||||||
|
return children;
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock("@refinedev/kbar", () => ({
|
jest.mock("@refinedev/kbar", () => ({
|
||||||
@@ -43,6 +63,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 +76,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 +88,12 @@ describe("RefineContext access authentication", () => {
|
|||||||
const originalRequest = global.Request;
|
const originalRequest = global.Request;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
clearIntentionalLogout();
|
||||||
|
mockPathname = "/network-simulation";
|
||||||
|
mockProjectProviderBlocks = false;
|
||||||
|
capturedLogin = null;
|
||||||
|
replaceMock.mockReset();
|
||||||
|
jest.mocked(signIn).mockClear();
|
||||||
mockSessionState = {
|
mockSessionState = {
|
||||||
data: {
|
data: {
|
||||||
accessToken: "expired-access-token",
|
accessToken: "expired-access-token",
|
||||||
@@ -115,6 +147,54 @@ 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,
|
||||||
|
headers: new Headers(),
|
||||||
|
json: async () => ({ detail: "User authorization pending" }),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<App>
|
||||||
|
<div>应用内容</div>
|
||||||
|
</App>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText("账号尚未获得访问权限"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("access-header")).toHaveTextContent("account");
|
||||||
|
expect(screen.queryByText("应用内容")).not.toBeInTheDocument();
|
||||||
|
expect(useAuthStore.getState()).toMatchObject({
|
||||||
|
sessionExpired: false,
|
||||||
|
sessionExpiryReason: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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" };
|
||||||
|
|
||||||
@@ -134,4 +214,100 @@ 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("starts login with a safe post-authentication callback from the login route", async () => {
|
||||||
|
mockPathname = "/login";
|
||||||
|
mockSessionState = { data: null, status: "unauthenticated" };
|
||||||
|
|
||||||
|
render(
|
||||||
|
<App>
|
||||||
|
<div>登录页面</div>
|
||||||
|
</App>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(capturedLogin).not.toBeNull();
|
||||||
|
await capturedLogin!();
|
||||||
|
|
||||||
|
expect(signIn).toHaveBeenCalledWith("keycloak", {
|
||||||
|
callbackUrl: "/",
|
||||||
|
redirect: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+85
-45
@@ -9,7 +9,7 @@ import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
|
|||||||
import { RefineSnackbarProvider } from "@refinedev/mui";
|
import { RefineSnackbarProvider } from "@refinedev/mui";
|
||||||
import { SessionProvider, signIn, useSession } from "next-auth/react";
|
import { SessionProvider, signIn, useSession } from "next-auth/react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
import routerProvider from "@refinedev/nextjs-router";
|
import routerProvider from "@refinedev/nextjs-router";
|
||||||
import { NavigationLink } from "@components/loading/NavigationLink";
|
import { NavigationLink } from "@components/loading/NavigationLink";
|
||||||
@@ -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,6 +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 [accessDeniedReason, setAccessDeniedReason] = useState<
|
||||||
|
"account" | "project" | null
|
||||||
|
>(null);
|
||||||
const can = (permission: string) => permissions.includes(permission);
|
const can = (permission: string) => permissions.includes(permission);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -85,39 +92,64 @@ 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") {
|
||||||
|
setAccessDeniedReason(null);
|
||||||
resetAccess();
|
resetAccess();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = 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",
|
||||||
})
|
})
|
||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
if (response.status === 403) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
resetAccess();
|
resetAccess();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setAccessDeniedReason(null);
|
||||||
setAccessContext(await response.json());
|
setAccessContext(await response.json());
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -157,32 +189,37 @@ 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", {
|
||||||
callbackUrl: to ? to.toString() : "/",
|
callbackUrl: to && to !== "/login" ? to.toString() : "/",
|
||||||
redirect: true,
|
redirect: true,
|
||||||
});
|
});
|
||||||
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 };
|
||||||
},
|
},
|
||||||
@@ -375,31 +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"}>
|
|
||||||
{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 = {
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
|
import { SystemAdminPanel } from "./SystemAdminPanel";
|
||||||
|
|
||||||
|
const openNotification = jest.fn();
|
||||||
|
|
||||||
|
jest.mock("@refinedev/core", () => ({
|
||||||
|
useNotification: () => ({ open: openNotification }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@config/config", () => ({
|
||||||
|
config: { BACKEND_URL: "https://server.example.com" },
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/lib/apiFetch", () => ({
|
||||||
|
apiFetch: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/lib/sessionRecoveryDraft", () => ({
|
||||||
|
useSessionRecoveryDraft: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/store/projectStore", () => ({
|
||||||
|
useProjectStore: (selector: (state: { currentProjectId: null }) => unknown) =>
|
||||||
|
selector({ currentProjectId: null }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const jsonResponse = (body: unknown) =>
|
||||||
|
({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => body,
|
||||||
|
text: async () => JSON.stringify(body),
|
||||||
|
}) as Response;
|
||||||
|
|
||||||
|
describe("SystemAdminPanel Keycloak synchronization", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
(apiFetch as jest.Mock).mockImplementation((input: string) => {
|
||||||
|
if (input.endsWith("/api/v1/admin/users/me")) {
|
||||||
|
return Promise.resolve(
|
||||||
|
jsonResponse({
|
||||||
|
id: "admin-id",
|
||||||
|
keycloak_id: "admin-keycloak-id",
|
||||||
|
username: "admin",
|
||||||
|
email: "admin@example.com",
|
||||||
|
role: "admin",
|
||||||
|
is_active: true,
|
||||||
|
is_superuser: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (input.endsWith("/api/v1/admin/keycloak-user-syncs")) {
|
||||||
|
return Promise.resolve(
|
||||||
|
jsonResponse({
|
||||||
|
scanned: 3,
|
||||||
|
created: 1,
|
||||||
|
updated: 1,
|
||||||
|
skipped: 1,
|
||||||
|
issues: [{ username: "no-email", reason: "missing_email" }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Promise.resolve(jsonResponse([]));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("syncs the Keycloak directory and shows the result", async () => {
|
||||||
|
render(<SystemAdminPanel />);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole("tab", { name: "系统用户" }));
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "同步 Keycloak 用户" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(apiFetch).toHaveBeenCalledWith(
|
||||||
|
"https://server.example.com/api/v1/admin/keycloak-user-syncs",
|
||||||
|
expect.objectContaining({ method: "POST" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||||
|
"已扫描 3 个 Keycloak 身份,新增 1 个,更新 1 个,跳过 1 个。",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -53,6 +53,7 @@ import {
|
|||||||
Save as SaveIcon,
|
Save as SaveIcon,
|
||||||
Security as SecurityIcon,
|
Security as SecurityIcon,
|
||||||
Storage as StorageIcon,
|
Storage as StorageIcon,
|
||||||
|
Sync as SyncIcon,
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
import { useNotification } from "@refinedev/core";
|
import { useNotification } from "@refinedev/core";
|
||||||
import { config } from "@config/config";
|
import { config } from "@config/config";
|
||||||
@@ -70,6 +71,17 @@ type MetadataUser = {
|
|||||||
is_superuser: boolean;
|
is_superuser: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type KeycloakUserSyncResult = {
|
||||||
|
scanned: number;
|
||||||
|
created: number;
|
||||||
|
updated: number;
|
||||||
|
skipped: number;
|
||||||
|
issues: Array<{
|
||||||
|
username: string;
|
||||||
|
reason: "missing_email" | "service_account" | "identity_conflict" | string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
type ProjectMember = {
|
type ProjectMember = {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
@@ -469,6 +481,9 @@ export const SystemAdminPanel = () => {
|
|||||||
const [adminChecked, setAdminChecked] = useState(false);
|
const [adminChecked, setAdminChecked] = useState(false);
|
||||||
const [isAuthorized, setIsAuthorized] = useState(false);
|
const [isAuthorized, setIsAuthorized] = useState(false);
|
||||||
const [metadataConfigAvailable, setMetadataConfigAvailable] = useState(true);
|
const [metadataConfigAvailable, setMetadataConfigAvailable] = useState(true);
|
||||||
|
const [syncingKeycloakUsers, setSyncingKeycloakUsers] = useState(false);
|
||||||
|
const [keycloakSyncResult, setKeycloakSyncResult] =
|
||||||
|
useState<KeycloakUserSyncResult | null>(null);
|
||||||
const [projectId, setProjectId] = useState(currentProjectId ?? "");
|
const [projectId, setProjectId] = useState(currentProjectId ?? "");
|
||||||
const [memberForm, setMemberForm] = useState({
|
const [memberForm, setMemberForm] = useState({
|
||||||
user_id: "",
|
user_id: "",
|
||||||
@@ -564,6 +579,33 @@ export const SystemAdminPanel = () => {
|
|||||||
setUsers(await response.json());
|
setUsers(await response.json());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const syncKeycloakUsers = useCallback(async () => {
|
||||||
|
if (syncingKeycloakUsers) return;
|
||||||
|
setSyncingKeycloakUsers(true);
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${config.BACKEND_URL}/api/v1/admin/keycloak-user-syncs`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
projectHeaderMode: "omit",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readErrorText(response));
|
||||||
|
}
|
||||||
|
const result = (await response.json()) as KeycloakUserSyncResult;
|
||||||
|
setKeycloakSyncResult(result);
|
||||||
|
await loadUsers();
|
||||||
|
notifySuccess(
|
||||||
|
`Keycloak 用户同步完成:新增 ${result.created},更新 ${result.updated},跳过 ${result.skipped}`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
notifyError(error instanceof Error ? error.message : String(error));
|
||||||
|
} finally {
|
||||||
|
setSyncingKeycloakUsers(false);
|
||||||
|
}
|
||||||
|
}, [loadUsers, notifyError, notifySuccess, syncingKeycloakUsers]);
|
||||||
|
|
||||||
const applyProjectForm = useCallback((project: AdminProject | null) => {
|
const applyProjectForm = useCallback((project: AdminProject | null) => {
|
||||||
if (!project) {
|
if (!project) {
|
||||||
setProjectForm(defaultProjectForm);
|
setProjectForm(defaultProjectForm);
|
||||||
@@ -1238,13 +1280,44 @@ export const SystemAdminPanel = () => {
|
|||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="系统用户"
|
title="系统用户"
|
||||||
description="Keycloak 信息只读展示;这里维护系统角色和启用状态。超级管理员由后台初始化或受控脚本设置,不在页面修改。"
|
description="从 Keycloak 同步身份后,在这里维护系统角色和启用状态。新用户默认停用,项目权限仍需单独配置。"
|
||||||
action={
|
action={
|
||||||
<Button startIcon={<RefreshIcon />} variant="outlined" onClick={loadUsers}>
|
<Stack direction="row" spacing={1} useFlexGap flexWrap="wrap">
|
||||||
刷新
|
<Button
|
||||||
</Button>
|
startIcon={
|
||||||
|
syncingKeycloakUsers ? (
|
||||||
|
<CircularProgress color="inherit" size={16} />
|
||||||
|
) : (
|
||||||
|
<SyncIcon />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
variant="contained"
|
||||||
|
disabled={syncingKeycloakUsers}
|
||||||
|
onClick={() => void syncKeycloakUsers()}
|
||||||
|
>
|
||||||
|
{syncingKeycloakUsers ? "正在同步" : "同步 Keycloak 用户"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
startIcon={<RefreshIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
disabled={syncingKeycloakUsers}
|
||||||
|
onClick={() => void loadUsers()}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{keycloakSyncResult && (
|
||||||
|
<Alert
|
||||||
|
severity={keycloakSyncResult.skipped > 0 ? "warning" : "success"}
|
||||||
|
sx={{ borderRadius: 2 }}
|
||||||
|
>
|
||||||
|
{`已扫描 ${keycloakSyncResult.scanned} 个 Keycloak 身份,新增 ${keycloakSyncResult.created} 个,更新 ${keycloakSyncResult.updated} 个,跳过 ${keycloakSyncResult.skipped} 个。`}
|
||||||
|
{keycloakSyncResult.skipped > 0 &&
|
||||||
|
" 跳过项包括服务账号、缺少邮箱或身份冲突,请检查 Keycloak 用户资料。"}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
<TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 2 }}>
|
<TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 2 }}>
|
||||||
<Table size="small" sx={tableSx}>
|
<Table size="small" sx={tableSx}>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
|
|||||||
@@ -4,12 +4,24 @@ import { useAccessStore } from "@/store/accessStore";
|
|||||||
import { useAuthStore } from "@/store/authStore";
|
import { useAuthStore } from "@/store/authStore";
|
||||||
import { RoutePermissionGuard } from "./RoutePermissionGuard";
|
import { RoutePermissionGuard } from "./RoutePermissionGuard";
|
||||||
|
|
||||||
|
const replaceMock = jest.fn();
|
||||||
|
let mockPathname = "/network-simulation";
|
||||||
|
|
||||||
jest.mock("next/navigation", () => ({
|
jest.mock("next/navigation", () => ({
|
||||||
usePathname: () => "/network-simulation",
|
usePathname: () => mockPathname,
|
||||||
|
useRouter: () => ({ replace: replaceMock }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/components/header", () => ({
|
||||||
|
Header: ({ accessMode }: { accessMode: string }) => (
|
||||||
|
<div data-testid="access-header">{accessMode}</div>
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("RoutePermissionGuard", () => {
|
describe("RoutePermissionGuard", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
replaceMock.mockReset();
|
||||||
|
mockPathname = "/network-simulation";
|
||||||
useAccessStore.setState({
|
useAccessStore.setState({
|
||||||
context: null,
|
context: null,
|
||||||
permissions: [],
|
permissions: [],
|
||||||
@@ -52,7 +64,35 @@ describe("RoutePermissionGuard", () => {
|
|||||||
expect(screen.getByText(/simulation\.view/)).toBeInTheDocument();
|
expect(screen.getByText(/simulation\.view/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("checks authentication before the expired state effect runs", () => {
|
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("redirects an unauthenticated protected route to login", () => {
|
||||||
render(
|
render(
|
||||||
<RoutePermissionGuard authenticated={false}>
|
<RoutePermissionGuard authenticated={false}>
|
||||||
<div>受保护内容</div>
|
<div>受保护内容</div>
|
||||||
@@ -61,5 +101,20 @@ describe("RoutePermissionGuard", () => {
|
|||||||
|
|
||||||
expect(screen.getByText("登录状态已失效")).toBeInTheDocument();
|
expect(screen.getByText("登录状态已失效")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("无权访问此功能")).not.toBeInTheDocument();
|
expect(screen.queryByText("无权访问此功能")).not.toBeInTheDocument();
|
||||||
|
expect(replaceMock).toHaveBeenCalledWith("/login");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows the login route to render while unauthenticated", () => {
|
||||||
|
mockPathname = "/login";
|
||||||
|
|
||||||
|
render(
|
||||||
|
<RoutePermissionGuard authenticated={false}>
|
||||||
|
<div>登录页面</div>
|
||||||
|
</RoutePermissionGuard>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("登录页面")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("登录状态已失效")).not.toBeInTheDocument();
|
||||||
|
expect(replaceMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
import { usePathname } from "next/navigation";
|
Alert,
|
||||||
import type { ReactNode } from "react";
|
Box,
|
||||||
|
CircularProgress,
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import { type ReactNode, useEffect } 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,15 +19,29 @@ import { useAuthStore } from "@/store/authStore";
|
|||||||
export const RoutePermissionGuard = ({
|
export const RoutePermissionGuard = ({
|
||||||
children,
|
children,
|
||||||
authenticated,
|
authenticated,
|
||||||
|
accessDeniedReason = null,
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
authenticated: boolean;
|
authenticated: boolean;
|
||||||
|
accessDeniedReason?: "account" | "project" | null;
|
||||||
}) => {
|
}) => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
const permissions = useAccessStore((state) => state.permissions);
|
const permissions = useAccessStore((state) => state.permissions);
|
||||||
const loading = useAccessStore((state) => state.loading);
|
const loading = useAccessStore((state) => state.loading);
|
||||||
const sessionExpired = useAuthStore((state) => state.sessionExpired);
|
const sessionExpired = useAuthStore((state) => state.sessionExpired);
|
||||||
const requiredPermission = permissionForPath(pathname);
|
const requiredPermission = permissionForPath(pathname);
|
||||||
|
const isLoginRoute = pathname === "/login";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authenticated && !isLoginRoute) {
|
||||||
|
router.replace("/login");
|
||||||
|
}
|
||||||
|
}, [authenticated, isLoginRoute, router]);
|
||||||
|
|
||||||
|
if (isLoginRoute) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
if (!authenticated || sessionExpired) {
|
if (!authenticated || sessionExpired) {
|
||||||
return (
|
return (
|
||||||
@@ -37,6 +58,39 @@ export const RoutePermissionGuard = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (accessDeniedReason) {
|
||||||
|
const accountPending = accessDeniedReason === "account";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ minHeight: "100vh", bgcolor: "background.default" }}>
|
||||||
|
<Header accessMode={accessDeniedReason} />
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (requiredPermission && loading) {
|
if (requiredPermission && loading) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ minHeight: 320, display: "grid", placeItems: "center" }}>
|
<Box sx={{ minHeight: 320, display: "grid", placeItems: "center" }}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -126,10 +126,10 @@ describe("fetchHistoryData", () => {
|
|||||||
] });
|
] });
|
||||||
});
|
});
|
||||||
const result = await fetchHistoryData(targets, range, "scheme", "run-1");
|
const result = await fetchHistoryData(targets, range, "scheme", "run-1");
|
||||||
expect(result.series).toHaveLength(4);
|
expect(result.series).toHaveLength(3);
|
||||||
expect(result.points[0].values).toEqual({ '["pipe","P-1","realtime_simulation"]': 36, '["junction","J-1","realtime_simulation"]': 30 });
|
expect(result.points[0].values).toEqual({ '["pipe","P-1","event_simulation"]': 36, '["junction","J-1","event_simulation"]': 30 });
|
||||||
expect(result.points[1].values['["pipe","P-1","analysis_simulation"]']).toBe(72);
|
expect(result.points[1].values['["pipe","P-1","event_simulation"]']).toBe(72);
|
||||||
expect(result.points[2].values['["pipe","P-1","realtime_simulation"]']).toBeCloseTo(43.2);
|
expect(result.points[2].values['["pipe","P-1","event_simulation"]']).toBeCloseTo(43.2);
|
||||||
expect(apiFetch).toHaveBeenCalledTimes(2);
|
expect(apiFetch).toHaveBeenCalledTimes(2);
|
||||||
for (const [, init] of jest.mocked(apiFetch).mock.calls) {
|
for (const [, init] of jest.mocked(apiFetch).mock.calls) {
|
||||||
expect(JSON.parse(String(init?.body)).elements).toHaveLength(2);
|
expect(JSON.parse(String(init?.body)).elements).toHaveLength(2);
|
||||||
@@ -317,7 +317,7 @@ describe("HistoryDataPanel requests", () => {
|
|||||||
expect(getAnalysisScheme).toHaveBeenCalledTimes(2);
|
expect(getAnalysisScheme).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("plots native timestamps and explicit gaps with the event interval marked", async () => {
|
it("plots a single interpolated event curve with the event interval marked", async () => {
|
||||||
const series = {
|
const series = {
|
||||||
element_id: "P-1", element_type: "pipe", device_id: null, metric: "flow",
|
element_id: "P-1", element_type: "pipe", device_id: null, metric: "flow",
|
||||||
source_unit: "m3/h", display_unit: "m³/h", unit_inferred: false,
|
source_unit: "m3/h", display_unit: "m³/h", unit_inferred: false,
|
||||||
@@ -340,16 +340,21 @@ describe("HistoryDataPanel requests", () => {
|
|||||||
const option = jest.mocked(ReactECharts).mock.calls.at(-1)![0].option as any;
|
const option = jest.mocked(ReactECharts).mock.calls.at(-1)![0].option as any;
|
||||||
expect(option.xAxis).toMatchObject({ type: "time", min: Date.parse("2026-08-17T08:00:00Z"), max: Date.parse("2026-08-17T11:00:00Z") });
|
expect(option.xAxis).toMatchObject({ type: "time", min: Date.parse("2026-08-17T08:00:00Z"), max: Date.parse("2026-08-17T11:00:00Z") });
|
||||||
expect(option.series[0].connectNulls).toBe(false);
|
expect(option.series[0].connectNulls).toBe(false);
|
||||||
expect(option.series[0].data).toEqual([
|
expect(option.series).toHaveLength(1);
|
||||||
[Date.parse("2026-08-17T08:00:00Z"), 10],
|
expect(option.series[0].name).toBe("管道 P-1 (事件模拟) [m³/h]");
|
||||||
[Date.parse("2026-08-17T09:00:00Z"), null],
|
expect(option.series[0].data).toContainEqual([Date.parse("2026-08-17T08:00:00Z"), 10]);
|
||||||
[Date.parse("2026-08-17T11:00:00Z"), 12],
|
expect(option.series[0].data).toContainEqual([Date.parse("2026-08-17T09:00:00Z"), 8.4]);
|
||||||
]);
|
expect(option.series[0].data).toContainEqual([Date.parse("2026-08-17T09:15:00Z"), 8]);
|
||||||
|
expect(option.series[0].data).toContainEqual([Date.parse("2026-08-17T11:00:00Z"), 12]);
|
||||||
expect(option.series[0].markArea.data[0]).toEqual([
|
expect(option.series[0].markArea.data[0]).toEqual([
|
||||||
{ name: "事件方案时段", xAxis: Date.parse("2026-08-17T09:00:00Z") },
|
{ name: "事件方案时段", xAxis: Date.parse("2026-08-17T09:00:00Z") },
|
||||||
{ xAxis: Date.parse("2026-08-17T10:00:00Z") },
|
{ xAxis: Date.parse("2026-08-17T10:00:00Z") },
|
||||||
]);
|
]);
|
||||||
expect(option.series[1].data).toEqual([[Date.parse("2026-08-17T09:15:00Z"), 8]]);
|
expect(screen.queryByText(/当前时段暂无在线模拟数据/)).not.toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: "表格" }));
|
||||||
|
expect(await screen.findByRole("columnheader", { name: "管道 P-1 (事件模拟) [m³/h]" })).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByRole("columnheader")).toHaveLength(2);
|
||||||
|
expect(screen.getByRole("gridcell", { name: "8.40" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("snaps off-step event context without changing the actual event marker", async () => {
|
it("snaps off-step event context without changing the actual event marker", async () => {
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import { Close, PlayArrow, Refresh, ShowChart, TableChart } from "@mui/icons-mat
|
|||||||
import { DataGrid, GridColDef } from "@mui/x-data-grid";
|
import { DataGrid, GridColDef } from "@mui/x-data-grid";
|
||||||
import { zhCN } from "@mui/x-data-grid/locales";
|
import { zhCN } from "@mui/x-data-grid/locales";
|
||||||
import ReactECharts from "echarts-for-react";
|
import ReactECharts from "echarts-for-react";
|
||||||
import * as echarts from "echarts";
|
|
||||||
import "dayjs/locale/zh-cn"; // 引入中文包
|
import "dayjs/locale/zh-cn"; // 引入中文包
|
||||||
import dayjs, { Dayjs } from "dayjs";
|
import dayjs, { Dayjs } from "dayjs";
|
||||||
import utc from "dayjs/plugin/utc";
|
import utc from "dayjs/plugin/utc";
|
||||||
@@ -41,9 +40,9 @@ import {
|
|||||||
fetchElementHistory,
|
fetchElementHistory,
|
||||||
historySeriesKey,
|
historySeriesKey,
|
||||||
historySeriesLabel,
|
historySeriesLabel,
|
||||||
toTimeSeriesPoints,
|
|
||||||
TimeSeriesPoint,
|
TimeSeriesPoint,
|
||||||
} from "@/lib/elementHistory";
|
} from "@/lib/elementHistory";
|
||||||
|
import { mergeEventHistory, type EventHistoryOptions } from "@/lib/eventHistory";
|
||||||
import {
|
import {
|
||||||
type AnalysisHistoryContext,
|
type AnalysisHistoryContext,
|
||||||
getAnalysisHistoryContext,
|
getAnalysisHistoryContext,
|
||||||
@@ -107,6 +106,7 @@ export const fetchHistoryData = async (
|
|||||||
type: "realtime" | "scheme" | "none",
|
type: "realtime" | "scheme" | "none",
|
||||||
schemeRunId?: string,
|
schemeRunId?: string,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
|
eventOptions?: EventHistoryOptions,
|
||||||
): Promise<ElementHistoryResult> => {
|
): Promise<ElementHistoryResult> => {
|
||||||
if (featureInfos.length === 0) {
|
if (featureInfos.length === 0) {
|
||||||
return { points: [], series: [] };
|
return { points: [], series: [] };
|
||||||
@@ -158,7 +158,9 @@ export const fetchHistoryData = async (
|
|||||||
...online.series,
|
...online.series,
|
||||||
...analysis.series.filter((item) => item.source === "analysis_simulation"),
|
...analysis.series.filter((item) => item.source === "analysis_simulation"),
|
||||||
];
|
];
|
||||||
return { series, points: toTimeSeriesPoints(series) };
|
return mergeEventHistory(series, range, eventOptions ?? {
|
||||||
|
eventRange: null, stepSeconds: 0,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return fetchElementHistory(
|
return fetchElementHistory(
|
||||||
elements,
|
elements,
|
||||||
@@ -309,6 +311,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab);
|
const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab);
|
||||||
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]);
|
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]);
|
||||||
const [historySeries, setHistorySeries] = useState<ElementHistorySeries[]>([]);
|
const [historySeries, setHistorySeries] = useState<ElementHistorySeries[]>([]);
|
||||||
|
const [hasOnlineSimulationData, setHasOnlineSimulationData] = useState(false);
|
||||||
const [loadingState, setLoadingState] = useState<LoadingState>("idle");
|
const [loadingState, setLoadingState] = useState<LoadingState>("idle");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isSimulating, setIsSimulating] = useState(false);
|
const [isSimulating, setIsSimulating] = useState(false);
|
||||||
@@ -403,6 +406,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
requestControllerRef.current = null;
|
requestControllerRef.current = null;
|
||||||
setTimeSeries([]);
|
setTimeSeries([]);
|
||||||
setHistorySeries([]);
|
setHistorySeries([]);
|
||||||
|
setHasOnlineSimulationData(false);
|
||||||
setLoadingState("idle");
|
setLoadingState("idle");
|
||||||
setError(null);
|
setError(null);
|
||||||
return;
|
return;
|
||||||
@@ -426,6 +430,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
: type,
|
: type,
|
||||||
schemeContext?.simulationRunId,
|
schemeContext?.simulationRunId,
|
||||||
requestController.signal,
|
requestController.signal,
|
||||||
|
{ eventRange: eventRange ?? null, stepSeconds },
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
requestController.signal.aborted ||
|
requestController.signal.aborted ||
|
||||||
@@ -433,6 +438,10 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
) return;
|
) return;
|
||||||
setTimeSeries(result.points);
|
setTimeSeries(result.points);
|
||||||
setHistorySeries(result.series);
|
setHistorySeries(result.series);
|
||||||
|
setHasOnlineSimulationData(result.hasOnlineSimulationData ?? result.series.some(
|
||||||
|
(series) => series.source === "realtime_simulation"
|
||||||
|
&& series.points.some((point) => point.value !== null && Number.isFinite(point.value)),
|
||||||
|
));
|
||||||
setLoadingState("success");
|
setLoadingState("success");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (
|
if (
|
||||||
@@ -444,13 +453,14 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
}
|
}
|
||||||
setTimeSeries([]);
|
setTimeSeries([]);
|
||||||
setHistorySeries([]);
|
setHistorySeries([]);
|
||||||
|
setHasOnlineSimulationData(false);
|
||||||
setError(err instanceof Error ? err.message : "未知错误");
|
setError(err instanceof Error ? err.message : "未知错误");
|
||||||
setLoadingState("error");
|
setLoadingState("error");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
requestedFeatureInfos, hasDevices, normalizedRange,
|
requestedFeatureInfos, hasDevices, normalizedRange,
|
||||||
schemeReady, schemeError, schemeContext, type,
|
schemeReady, schemeError, schemeContext, type, eventRange, stepSeconds,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -587,8 +597,8 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
? "circle"
|
? "circle"
|
||||||
: metadata.source === "scada_raw"
|
: metadata.source === "scada_raw"
|
||||||
? "diamond"
|
? "diamond"
|
||||||
: "none",
|
: "circle",
|
||||||
symbolSize: isObserved ? 7 : 0,
|
symbolSize: isObserved ? 7 : 5,
|
||||||
showSymbol: isObserved,
|
showSymbol: isObserved,
|
||||||
sampling: "lttb",
|
sampling: "lttb",
|
||||||
connectNulls: false,
|
connectNulls: false,
|
||||||
@@ -602,22 +612,10 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
.map((point) => [
|
.map((point) => [
|
||||||
new Date(point.timestamp).getTime(), point.values[key] ?? null,
|
new Date(point.timestamp).getTime(), point.values[key] ?? null,
|
||||||
]),
|
]),
|
||||||
lineStyle: isObserved ? { width: 0 } : undefined,
|
lineStyle: {
|
||||||
areaStyle: isObserved
|
width: isObserved ? 0 : 2,
|
||||||
? undefined
|
type: metadata.source === "analysis_simulation" ? "dashed" : "solid",
|
||||||
: {
|
},
|
||||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
||||||
{
|
|
||||||
offset: 0,
|
|
||||||
color: colors[index % colors.length],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
offset: 1,
|
|
||||||
color: "rgba(255, 255, 255, 0)",
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
opacity: 0.3,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
@@ -628,20 +626,34 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: "axis",
|
trigger: "axis",
|
||||||
confine: true,
|
confine: true,
|
||||||
position: function (pt: any[]) {
|
axisPointer: { type: "line", lineStyle: { color: "#94a3b8", type: "dashed" } },
|
||||||
return [pt[0], "10%"];
|
valueFormatter: (value: number | string) => typeof value === "number"
|
||||||
},
|
? value.toLocaleString("zh-CN", { maximumSignificantDigits: 6, useGrouping: false })
|
||||||
|
: String(value ?? "--"),
|
||||||
},
|
},
|
||||||
legend: {
|
legend: {
|
||||||
top: "top",
|
type: "scroll",
|
||||||
|
top: 8,
|
||||||
|
left: 20,
|
||||||
|
width: "90%",
|
||||||
|
itemWidth: 20,
|
||||||
|
itemHeight: 8,
|
||||||
|
itemGap: 20,
|
||||||
|
textStyle: { color: "#475569", fontSize: 12 },
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
left: "5%",
|
top: 120,
|
||||||
right: "5%",
|
left: 20,
|
||||||
bottom: "11%",
|
right: 24,
|
||||||
|
bottom: 76,
|
||||||
containLabel: true,
|
containLabel: true,
|
||||||
},
|
},
|
||||||
toolbox: {
|
toolbox: {
|
||||||
|
top: 36,
|
||||||
|
right: 16,
|
||||||
|
itemSize: 15,
|
||||||
|
itemGap: 16,
|
||||||
|
iconStyle: { borderColor: "#64748b" },
|
||||||
feature: {
|
feature: {
|
||||||
dataZoom: {
|
dataZoom: {
|
||||||
yAxisIndex: "none",
|
yAxisIndex: "none",
|
||||||
@@ -659,7 +671,14 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
axisLabel: {
|
axisLabel: {
|
||||||
formatter: (value: number) => dayjs(value).tz("Asia/Shanghai").format("MM-DD\nHH:mm"),
|
formatter: (value: number) => dayjs(value).tz("Asia/Shanghai").format("MM-DD\nHH:mm"),
|
||||||
hideOverlap: true,
|
hideOverlap: true,
|
||||||
|
color: "#64748b",
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 16,
|
||||||
|
margin: 12,
|
||||||
},
|
},
|
||||||
|
splitNumber: 6,
|
||||||
|
axisLine: { lineStyle: { color: "#cbd5e1" } },
|
||||||
|
axisTick: { show: false },
|
||||||
},
|
},
|
||||||
yAxis: [
|
yAxis: [
|
||||||
...(hasFlowSeries
|
...(hasFlowSeries
|
||||||
@@ -668,6 +687,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
type: "value",
|
type: "value",
|
||||||
scale: true,
|
scale: true,
|
||||||
name: `流量 (${FLOW_DISPLAY_UNIT})`,
|
name: `流量 (${FLOW_DISPLAY_UNIT})`,
|
||||||
|
nameTextStyle: { color: "#64748b", fontSize: 12 },
|
||||||
|
axisLabel: { color: "#64748b", fontSize: 12 },
|
||||||
|
splitLine: { lineStyle: { color: "#e2e8f0", type: "dashed" } },
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
@@ -677,6 +699,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
type: "value",
|
type: "value",
|
||||||
scale: true,
|
scale: true,
|
||||||
name: `压力 (${PRESSURE_DISPLAY_UNIT})`,
|
name: `压力 (${PRESSURE_DISPLAY_UNIT})`,
|
||||||
|
nameTextStyle: { color: "#64748b", fontSize: 12 },
|
||||||
|
axisLabel: { color: "#64748b", fontSize: 12 },
|
||||||
|
splitLine: { show: !hasFlowSeries, lineStyle: { color: "#e2e8f0", type: "dashed" } },
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
@@ -688,8 +713,23 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
end: 100,
|
end: 100,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
type: "slider",
|
||||||
start: 0,
|
start: 0,
|
||||||
end: 100,
|
end: 100,
|
||||||
|
bottom: 12,
|
||||||
|
height: 24,
|
||||||
|
showDetail: false,
|
||||||
|
borderColor: "transparent",
|
||||||
|
backgroundColor: "#f5f7fa",
|
||||||
|
fillerColor: "rgba(25, 118, 210, 0.08)",
|
||||||
|
dataBackground: {
|
||||||
|
lineStyle: { color: "#94a3b8" },
|
||||||
|
areaStyle: { opacity: 0 },
|
||||||
|
},
|
||||||
|
selectedDataBackground: {
|
||||||
|
lineStyle: { color: "#1976d2" },
|
||||||
|
areaStyle: { opacity: 0 },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
series: getSeries().map((series, index) => ({
|
series: getSeries().map((series, index) => ({
|
||||||
@@ -698,7 +738,13 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
? {
|
? {
|
||||||
markArea: {
|
markArea: {
|
||||||
silent: true,
|
silent: true,
|
||||||
itemStyle: { color: "rgba(255, 152, 0, 0.10)" },
|
itemStyle: { color: "rgba(255, 152, 0, 0.06)" },
|
||||||
|
label: {
|
||||||
|
position: "insideTop",
|
||||||
|
distance: 10,
|
||||||
|
color: "#996515",
|
||||||
|
fontSize: 11,
|
||||||
|
},
|
||||||
data: [[
|
data: [[
|
||||||
{ name: "事件方案时段", xAxis: Date.parse(eventRange.start) },
|
{ name: "事件方案时段", xAxis: Date.parse(eventRange.start) },
|
||||||
{ xAxis: Date.parse(eventRange.end) },
|
{ xAxis: Date.parse(eventRange.end) },
|
||||||
@@ -982,7 +1028,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
{!isSimulating && type !== "none" && hasDevices && loadingState === "success" &&
|
{!isSimulating && type !== "none" && hasDevices && loadingState === "success" &&
|
||||||
!historySeries.some((series) => series.source === "realtime_simulation" && series.points.some((point) => point.value !== null)) && (
|
!hasOnlineSimulationData && (
|
||||||
<Typography variant="caption" sx={{ mt: 1, display: "block" }}>
|
<Typography variant="caption" sx={{ mt: 1, display: "block" }}>
|
||||||
当前时段暂无在线模拟数据。{canRunSimulation ? "可点击“在线模拟”计算。" : ""}
|
当前时段暂无在线模拟数据。{canRunSimulation ? "可点击“在线模拟”计算。" : ""}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|||||||
@@ -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: "设备样件" },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -21,6 +21,23 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/admin/keycloak-user-syncs": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Sync Keycloak User Directory */
|
||||||
|
post: operations["post_admin_keycloak_user_syncs"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/v1/admin/project-provisions": {
|
"/api/v1/admin/project-provisions": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -5149,6 +5166,26 @@ export interface components {
|
|||||||
*/
|
*/
|
||||||
HistorySource: "scada_raw" | "scada_cleaned" | "realtime_simulation" | "analysis_simulation";
|
HistorySource: "scada_raw" | "scada_cleaned" | "realtime_simulation" | "analysis_simulation";
|
||||||
JsonValue: unknown;
|
JsonValue: unknown;
|
||||||
|
/** KeycloakUserSyncIssue */
|
||||||
|
KeycloakUserSyncIssue: {
|
||||||
|
/** Reason */
|
||||||
|
reason: string;
|
||||||
|
/** Username */
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
/** KeycloakUserSyncResponse */
|
||||||
|
KeycloakUserSyncResponse: {
|
||||||
|
/** Created */
|
||||||
|
created: number;
|
||||||
|
/** Issues */
|
||||||
|
issues?: components["schemas"]["KeycloakUserSyncIssue"][];
|
||||||
|
/** Scanned */
|
||||||
|
scanned: number;
|
||||||
|
/** Skipped */
|
||||||
|
skipped: number;
|
||||||
|
/** Updated */
|
||||||
|
updated: number;
|
||||||
|
};
|
||||||
/** LeakageIdentifyRequestRest */
|
/** LeakageIdentifyRequestRest */
|
||||||
LeakageIdentifyRequestRest: {
|
LeakageIdentifyRequestRest: {
|
||||||
/**
|
/**
|
||||||
@@ -5995,6 +6032,80 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
post_admin_keycloak_user_syncs: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["KeycloakUserSyncResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Authentication required */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ProblemDetails"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Insufficient permission */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ProblemDetails"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Resource not found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ProblemDetails"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Resource conflict */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ProblemDetails"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ProblemDetails"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Dependency unavailable */
|
||||||
|
503: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ProblemDetails"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
post_admin_project_provisions: {
|
post_admin_project_provisions: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -28768,6 +28879,8 @@ export interface operations {
|
|||||||
accident_element: string[];
|
accident_element: string[];
|
||||||
/** @description 已故障的阀门ID列表(可选) */
|
/** @description 已故障的阀门ID列表(可选) */
|
||||||
disabled_valves?: string[];
|
disabled_valves?: string[];
|
||||||
|
/** @description 事故要素类型;auto优先管段再节点;分析同名节点须指定node,纯管段分析可指定link */
|
||||||
|
accident_element_type?: "auto" | "link" | "node";
|
||||||
};
|
};
|
||||||
header: {
|
header: {
|
||||||
"X-Project-Id": string;
|
"X-Project-Id": string;
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ export type HistorySource =
|
|||||||
| "scada_raw"
|
| "scada_raw"
|
||||||
| "scada_cleaned"
|
| "scada_cleaned"
|
||||||
| "realtime_simulation"
|
| "realtime_simulation"
|
||||||
| "analysis_simulation";
|
| "analysis_simulation"
|
||||||
|
// 前端按事件时段拼接历史在线结果与方案结果。
|
||||||
|
| "event_simulation";
|
||||||
|
|
||||||
export interface ElementHistoryTarget {
|
export interface ElementHistoryTarget {
|
||||||
element_id: string;
|
element_id: string;
|
||||||
@@ -40,6 +42,8 @@ export interface TimeSeriesPoint {
|
|||||||
export interface ElementHistoryResult {
|
export interface ElementHistoryResult {
|
||||||
points: TimeSeriesPoint[];
|
points: TimeSeriesPoint[];
|
||||||
series: ElementHistorySeries[];
|
series: ElementHistorySeries[];
|
||||||
|
/** 事件序列合并后仍保留在线历史是否存在的信息。 */
|
||||||
|
hasOnlineSimulationData?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SOURCE_SUFFIX: Record<HistorySource, string> = {
|
const SOURCE_SUFFIX: Record<HistorySource, string> = {
|
||||||
@@ -47,6 +51,7 @@ const SOURCE_SUFFIX: Record<HistorySource, string> = {
|
|||||||
scada_cleaned: "clean",
|
scada_cleaned: "clean",
|
||||||
realtime_simulation: "sim",
|
realtime_simulation: "sim",
|
||||||
analysis_simulation: "scheme_sim",
|
analysis_simulation: "scheme_sim",
|
||||||
|
event_simulation: "event_sim",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const historySeriesKey = (series: ElementHistorySeries) => {
|
export const historySeriesKey = (series: ElementHistorySeries) => {
|
||||||
@@ -64,6 +69,8 @@ export const historySourceLabel = (source: HistorySource) => {
|
|||||||
return "实时模拟";
|
return "实时模拟";
|
||||||
case "analysis_simulation":
|
case "analysis_simulation":
|
||||||
return "方案模拟";
|
return "方案模拟";
|
||||||
|
case "event_simulation":
|
||||||
|
return "事件模拟";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { type ElementHistorySeries, historySeriesKey } from "./elementHistory";
|
||||||
|
import { mergeEventHistory } from "./eventHistory";
|
||||||
|
|
||||||
|
const time = (minutes: number) => new Date(Date.UTC(2026, 7, 17, 8, minutes)).toISOString();
|
||||||
|
const series = (
|
||||||
|
source: ElementHistorySeries["source"],
|
||||||
|
points: Array<[number, number | null]>,
|
||||||
|
overrides: Partial<ElementHistorySeries> = {},
|
||||||
|
): ElementHistorySeries => ({
|
||||||
|
element_id: "P-1", element_type: "pipe", device_id: null, metric: "flow",
|
||||||
|
source, source_unit: "m3/h", display_unit: "m³/h", unit_inferred: false,
|
||||||
|
points: points.map(([minutes, value]) => ({ time: time(minutes), value })),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
const range = { from: new Date(time(0)), to: new Date(time(120)) };
|
||||||
|
const options = { eventRange: { start: time(38), end: time(83) }, stepSeconds: 900 };
|
||||||
|
const values = (item: ElementHistorySeries) => Object.fromEntries(
|
||||||
|
item.points.map((point) => [point.time, point.value]),
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("mergeEventHistory", () => {
|
||||||
|
it("uses event values only in the event window and interpolates mismatched times and transitions", () => {
|
||||||
|
const result = mergeEventHistory([
|
||||||
|
series("realtime_simulation", [[0, 100], [30, 100], [45, 999], [60, 999], [90, 100], [120, 100]]),
|
||||||
|
series("analysis_simulation", [[23, 999], [38, 80], [53, 60], [68, 40], [83, 80], [98, 999]]),
|
||||||
|
], range, options);
|
||||||
|
expect(result.series).toHaveLength(1);
|
||||||
|
expect(result.series[0].source).toBe("event_simulation");
|
||||||
|
const merged = values(result.series[0]);
|
||||||
|
expect(merged[time(15)]).toBe(100);
|
||||||
|
expect(merged[time(38)]).toBe(80);
|
||||||
|
expect(merged[time(45)]).toBeCloseTo(70.6666667);
|
||||||
|
expect(merged[time(60)]).toBeCloseTo(50.6666667);
|
||||||
|
expect(merged[time(83)]).toBe(80);
|
||||||
|
expect(merged[time(90)]).toBe(100);
|
||||||
|
expect(merged[time(105)]).toBe(100);
|
||||||
|
expect(merged[time(23)]).toBeUndefined();
|
||||||
|
expect(merged[time(98)]).toBeUndefined();
|
||||||
|
expect(result.points.find((point) => point.timestamp === time(45))?.values[
|
||||||
|
historySeriesKey(result.series[0])
|
||||||
|
]).toBeCloseTo(70.6666667);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("interpolates across the seam when the first event sample starts after the event boundary", () => {
|
||||||
|
const result = mergeEventHistory([
|
||||||
|
series("realtime_simulation", [[30, 100], [45, 999], [90, 100]]),
|
||||||
|
series("analysis_simulation", [[53, 54], [68, 24]]),
|
||||||
|
], range, options);
|
||||||
|
const merged = values(result.series[0]);
|
||||||
|
expect(merged[time(38)]).toBe(84);
|
||||||
|
expect(merged[time(45)]).toBe(70);
|
||||||
|
expect(merged[time(75)]).toBeCloseTo(48.181818);
|
||||||
|
expect(merged[time(0)]).toBeNull();
|
||||||
|
expect(merged[time(120)]).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts different source units before merging and preserves SCADA samples", () => {
|
||||||
|
const observed = series("scada_raw", [[40, 12], [50, null]], { device_id: "F-1", source_unit: "L/s" });
|
||||||
|
const result = mergeEventHistory([
|
||||||
|
observed,
|
||||||
|
series("realtime_simulation", [[0, 10], [90, 10]], { source_unit: "L/s" }),
|
||||||
|
series("analysis_simulation", [[38, 20], [83, 20]]),
|
||||||
|
], range, options);
|
||||||
|
expect(result.series[0]).toBe(observed);
|
||||||
|
expect(result.points.find((point) => point.timestamp === time(40))?.values["F-1_raw"]).toBe(43.2);
|
||||||
|
const merged = values(result.series[1]);
|
||||||
|
expect(merged[time(0)]).toBe(36);
|
||||||
|
expect(merged[time(38)]).toBe(20);
|
||||||
|
expect(merged[time(90)]).toBe(36);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves explicit missing samples and never interpolates through them", () => {
|
||||||
|
const result = mergeEventHistory([
|
||||||
|
series("realtime_simulation", [[0, 100], [30, null], [90, 100], [120, 100]]),
|
||||||
|
series("analysis_simulation", [[38, 80], [53, null], [68, 40], [83, 80]]),
|
||||||
|
], range, options);
|
||||||
|
const merged = values(result.series[0]);
|
||||||
|
expect(merged[time(15)]).toBeNull();
|
||||||
|
expect(merged[time(30)]).toBeNull();
|
||||||
|
expect(merged[time(45)]).toBeNull();
|
||||||
|
expect(merged[time(53)]).toBeNull();
|
||||||
|
expect(merged[time(60)]).toBeNull();
|
||||||
|
expect(merged[time(75)]).toBeCloseTo(58.6666667);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps missing event results as gaps instead of inserting normal baseline values", () => {
|
||||||
|
const result = mergeEventHistory([
|
||||||
|
series("realtime_simulation", [[0, 100], [30, 100], [45, 999], [90, 100], [120, 100]]),
|
||||||
|
], range, options);
|
||||||
|
expect(values(result.series[0])[time(45)]).toBeNull();
|
||||||
|
expect(values(result.series[0])[time(90)]).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps same-name pipes and junctions separate and supports analysis-only results", () => {
|
||||||
|
const result = mergeEventHistory([
|
||||||
|
series("analysis_simulation", [[38, 80], [83, 80]]),
|
||||||
|
series("analysis_simulation", [[38, 30], [83, 30]], {
|
||||||
|
element_type: "junction", metric: "pressure", source_unit: "m", display_unit: "m",
|
||||||
|
}),
|
||||||
|
], range, options);
|
||||||
|
expect(result.series).toHaveLength(2);
|
||||||
|
const point = result.points.find((item) => item.timestamp === time(45))!;
|
||||||
|
expect(Object.values(point.values)).toEqual([80, 30]);
|
||||||
|
expect(Object.values(result.points[0].values)).toEqual([null, null]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import {
|
||||||
|
type ElementHistoryResult,
|
||||||
|
type ElementHistorySeries,
|
||||||
|
toTimeSeriesPoints,
|
||||||
|
} from "./elementHistory";
|
||||||
|
import { FLOW_DISPLAY_UNIT, PRESSURE_DISPLAY_UNIT, toDisplayValue } from "@/utils/units";
|
||||||
|
|
||||||
|
export interface EventHistoryOptions {
|
||||||
|
eventRange: { start: string; end: string } | null;
|
||||||
|
stepSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Sample = { time: number; value: number | null };
|
||||||
|
|
||||||
|
const samples = (series: ElementHistorySeries | undefined): Sample[] => {
|
||||||
|
const values = new Map<number, number | null>();
|
||||||
|
series?.points.forEach((point) => {
|
||||||
|
const time = Date.parse(point.time);
|
||||||
|
if (!Number.isFinite(time)) return;
|
||||||
|
const value = toDisplayValue(point.value, series.metric, series.source_unit);
|
||||||
|
values.set(time, value !== null && Number.isFinite(value) ? value : null);
|
||||||
|
});
|
||||||
|
return Array.from(values, ([time, value]) => ({ time, value }))
|
||||||
|
.sort((left, right) => left.time - right.time);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 拼接已保存的模拟结果;仅在相邻有效样本之间插值,保留显式缺测,不外推。 */
|
||||||
|
export const mergeEventHistory = (
|
||||||
|
series: ElementHistorySeries[],
|
||||||
|
range: { from: Date; to: Date },
|
||||||
|
{ eventRange, stepSeconds }: EventHistoryOptions,
|
||||||
|
): ElementHistoryResult => {
|
||||||
|
const groups = new Map<string, {
|
||||||
|
online?: ElementHistorySeries;
|
||||||
|
analysis?: ElementHistorySeries;
|
||||||
|
}>();
|
||||||
|
const observed = series.filter((item) => item.source.startsWith("scada_"));
|
||||||
|
series.forEach((item) => {
|
||||||
|
if (item.source !== "realtime_simulation" && item.source !== "analysis_simulation") return;
|
||||||
|
const key = JSON.stringify([item.element_type, item.element_id, item.metric]);
|
||||||
|
const group = groups.get(key) ?? {};
|
||||||
|
if (item.source === "realtime_simulation") group.online = item;
|
||||||
|
else group.analysis = item;
|
||||||
|
groups.set(key, group);
|
||||||
|
});
|
||||||
|
|
||||||
|
const merged = Array.from(groups.values(), ({ online, analysis }) => {
|
||||||
|
const baseline = samples(online);
|
||||||
|
const event = samples(analysis);
|
||||||
|
const start = eventRange ? Date.parse(eventRange.start) : event[0]?.time ?? NaN;
|
||||||
|
const end = eventRange ? Date.parse(eventRange.end) : event.at(-1)?.time ?? NaN;
|
||||||
|
const hasEventRange = Number.isFinite(start) && Number.isFinite(end) && end >= start;
|
||||||
|
const anchors = new Map<number, number | null>();
|
||||||
|
baseline.forEach((point) => {
|
||||||
|
if (!hasEventRange || point.time < start || point.time > end) {
|
||||||
|
anchors.set(point.time, point.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
event.forEach((point) => {
|
||||||
|
if (hasEventRange && point.time >= start && point.time <= end) {
|
||||||
|
anchors.set(point.time, point.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (hasEventRange && !event.some((point) => point.time >= start && point.time <= end)) {
|
||||||
|
anchors.set(start, null);
|
||||||
|
anchors.set(end, null);
|
||||||
|
}
|
||||||
|
const ordered = Array.from(anchors, ([time, value]) => ({ time, value }))
|
||||||
|
.sort((left, right) => left.time - right.time);
|
||||||
|
const from = range.from.getTime();
|
||||||
|
const to = range.to.getTime();
|
||||||
|
const times = new Set([
|
||||||
|
...baseline.map((point) => point.time),
|
||||||
|
...ordered.map((point) => point.time),
|
||||||
|
from, to,
|
||||||
|
]);
|
||||||
|
if (hasEventRange) {
|
||||||
|
times.add(start);
|
||||||
|
times.add(end);
|
||||||
|
}
|
||||||
|
const step = stepSeconds * 1000;
|
||||||
|
if (Number.isFinite(step) && step > 0 && ordered.length) {
|
||||||
|
const first = Math.max(from, ordered[0].time);
|
||||||
|
const last = Math.min(to, ordered[ordered.length - 1].time);
|
||||||
|
for (let time = Math.ceil(first / step) * step; time <= last; time += step) {
|
||||||
|
times.add(time);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let index = 0;
|
||||||
|
const points = Array.from(times).filter((time) => time >= from && time <= to)
|
||||||
|
.sort((left, right) => left - right).map((time) => {
|
||||||
|
while (index < ordered.length && ordered[index].time < time) index += 1;
|
||||||
|
const right = ordered[index];
|
||||||
|
const left = ordered[index - 1];
|
||||||
|
let value: number | null = null;
|
||||||
|
if (right?.time === time) value = right.value;
|
||||||
|
else if (left?.value != null && right?.value != null) {
|
||||||
|
value = left.value + (right.value - left.value)
|
||||||
|
* (time - left.time) / (right.time - left.time);
|
||||||
|
}
|
||||||
|
return { time: new Date(time).toISOString(), value };
|
||||||
|
});
|
||||||
|
const metadata = analysis ?? online!;
|
||||||
|
const unit = metadata.metric === "flow" ? FLOW_DISPLAY_UNIT : PRESSURE_DISPLAY_UNIT;
|
||||||
|
return {
|
||||||
|
...metadata,
|
||||||
|
device_id: null,
|
||||||
|
source: "event_simulation" as const,
|
||||||
|
source_unit: unit,
|
||||||
|
display_unit: unit,
|
||||||
|
unit_inferred: Boolean(online?.unit_inferred || analysis?.unit_inferred),
|
||||||
|
points,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const result = [...observed, ...merged];
|
||||||
|
return {
|
||||||
|
series: result,
|
||||||
|
points: toTimeSeriesPoints(result),
|
||||||
|
hasOnlineSimulationData: series.some((item) =>
|
||||||
|
item.source === "realtime_simulation" && item.points.some((point) =>
|
||||||
|
point.value !== null && Number.isFinite(point.value)
|
||||||
|
&& Date.parse(point.time) >= range.from.getTime()
|
||||||
|
&& Date.parse(point.time) <= range.to.getTime(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 { 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,
|
||||||
|
|||||||
Reference in New Issue
Block a user