Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80f05e481b | ||
|
|
6ff5322353 | ||
|
|
ca1c41aa9d |
@@ -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,43 @@ 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.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();
|
||||||
|
await expect(page.getByText("TJWater 智慧水务平台")).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "退出登录" })).toBeVisible();
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await expect(page.getByText("账号尚未获得访问权限")).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "退出登录" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("系统管理员可以手动同步 Keycloak 用户目录", async ({ page }) => {
|
||||||
|
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, []);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,6 +115,55 @@ describe("RefineContext access authentication", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows an authorization message when access-context rejects the account", async () => {
|
||||||
|
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.getByRole("button", { name: "退出登录" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Test User")).toBeInTheDocument();
|
||||||
|
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.getByRole("button", { name: "切换项目" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
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" };
|
||||||
|
|
||||||
|
|||||||
+49
-12
@@ -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";
|
||||||
@@ -68,6 +68,7 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
const markSessionExpired = useAuthStore((state) => state.markSessionExpired);
|
const markSessionExpired = useAuthStore((state) => state.markSessionExpired);
|
||||||
const clearSessionExpired = useAuthStore((state) => state.clearSessionExpired);
|
const clearSessionExpired = useAuthStore((state) => state.clearSessionExpired);
|
||||||
const currentProjectId = useProjectStore((state) => state.currentProjectId);
|
const currentProjectId = useProjectStore((state) => state.currentProjectId);
|
||||||
|
const setCurrentProject = useProjectStore((state) => state.setCurrentProject);
|
||||||
const currentProjectCode = useProjectStore(
|
const currentProjectCode = useProjectStore(
|
||||||
(state) => state.currentProjectCode,
|
(state) => state.currentProjectCode,
|
||||||
);
|
);
|
||||||
@@ -75,6 +76,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(() => {
|
||||||
@@ -103,21 +107,39 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
|
|
||||||
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,15 +179,7 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
return <span>loading...</span>;
|
return <span>loading...</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const authProvider: AuthProvider = {
|
const handleLogout = () =>
|
||||||
login: async () => {
|
|
||||||
signIn("keycloak", {
|
|
||||||
callbackUrl: to ? to.toString() : "/",
|
|
||||||
redirect: true,
|
|
||||||
});
|
|
||||||
return { success: true };
|
|
||||||
},
|
|
||||||
logout: () =>
|
|
||||||
completeLogout({
|
completeLogout({
|
||||||
reportAudit: () =>
|
reportAudit: () =>
|
||||||
reportLogoutAudit({
|
reportLogoutAudit({
|
||||||
@@ -182,7 +196,24 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
clearSessionRecoveryDrafts();
|
clearSessionRecoveryDrafts();
|
||||||
},
|
},
|
||||||
navigate: (path) => window.location.assign(path),
|
navigate: (path) => window.location.assign(path),
|
||||||
}),
|
});
|
||||||
|
|
||||||
|
const handleChooseAnotherProject = () => {
|
||||||
|
setCurrentProject(null, null);
|
||||||
|
localStorage.removeItem("MAP_WORKSPACE");
|
||||||
|
localStorage.removeItem("MAP_EXTENT");
|
||||||
|
window.location.assign("/");
|
||||||
|
};
|
||||||
|
|
||||||
|
const authProvider: AuthProvider = {
|
||||||
|
login: async () => {
|
||||||
|
signIn("keycloak", {
|
||||||
|
callbackUrl: to ? to.toString() : "/",
|
||||||
|
redirect: true,
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
logout: handleLogout,
|
||||||
onError: async (error) => {
|
onError: async (error) => {
|
||||||
return { error };
|
return { error };
|
||||||
},
|
},
|
||||||
@@ -392,7 +423,13 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SessionExpiryDialog expiresAt={data?.sessionExpiresAt} />
|
<SessionExpiryDialog expiresAt={data?.sessionExpiresAt} />
|
||||||
<RoutePermissionGuard authenticated={status === "authenticated"}>
|
<RoutePermissionGuard
|
||||||
|
authenticated={status === "authenticated"}
|
||||||
|
accessDeniedReason={accessDeniedReason}
|
||||||
|
userName={data?.user?.name ?? data?.user?.username}
|
||||||
|
onLogout={() => void handleLogout()}
|
||||||
|
onChooseAnotherProject={handleChooseAnotherProject}
|
||||||
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</RoutePermissionGuard>
|
</RoutePermissionGuard>
|
||||||
<RefineKbar />
|
<RefineKbar />
|
||||||
|
|||||||
@@ -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
|
||||||
|
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>
|
</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>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
|
||||||
import { useAccessStore } from "@/store/accessStore";
|
import { useAccessStore } from "@/store/accessStore";
|
||||||
import { useAuthStore } from "@/store/authStore";
|
import { useAuthStore } from "@/store/authStore";
|
||||||
@@ -52,6 +52,46 @@ describe("RoutePermissionGuard", () => {
|
|||||||
expect(screen.getByText(/simulation\.view/)).toBeInTheDocument();
|
expect(screen.getByText(/simulation\.view/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps a minimal identity header and logout action for pending accounts", () => {
|
||||||
|
const onLogout = jest.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<RoutePermissionGuard
|
||||||
|
authenticated
|
||||||
|
accessDeniedReason="account"
|
||||||
|
userName="zjb"
|
||||||
|
onLogout={onLogout}
|
||||||
|
>
|
||||||
|
<div>受保护内容</div>
|
||||||
|
</RoutePermissionGuard>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("TJWater 智慧水务平台")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("当前登录账号")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("zjb")).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "退出登录" }));
|
||||||
|
expect(onLogout).toHaveBeenCalledTimes(1);
|
||||||
|
expect(screen.queryByText("受保护内容")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps project recovery available when only the selected project is denied", () => {
|
||||||
|
const onChooseAnotherProject = jest.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<RoutePermissionGuard
|
||||||
|
authenticated
|
||||||
|
accessDeniedReason="project"
|
||||||
|
onChooseAnotherProject={onChooseAnotherProject}
|
||||||
|
>
|
||||||
|
<div>受保护内容</div>
|
||||||
|
</RoutePermissionGuard>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("无权访问当前项目")).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "切换项目" }));
|
||||||
|
expect(onChooseAnotherProject).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("checks authentication before the expired state effect runs", () => {
|
it("checks authentication before the expired state effect runs", () => {
|
||||||
render(
|
render(
|
||||||
<RoutePermissionGuard authenticated={false}>
|
<RoutePermissionGuard authenticated={false}>
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
"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 LogoutIcon from "@mui/icons-material/Logout";
|
||||||
|
import SwapHorizIcon from "@mui/icons-material/SwapHoriz";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
AppBar,
|
||||||
|
Avatar,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Stack,
|
||||||
|
Toolbar,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { Title } from "@/components/title";
|
||||||
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,9 +25,17 @@ import { useAuthStore } from "@/store/authStore";
|
|||||||
export const RoutePermissionGuard = ({
|
export const RoutePermissionGuard = ({
|
||||||
children,
|
children,
|
||||||
authenticated,
|
authenticated,
|
||||||
|
accessDeniedReason = null,
|
||||||
|
userName,
|
||||||
|
onLogout,
|
||||||
|
onChooseAnotherProject,
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
authenticated: boolean;
|
authenticated: boolean;
|
||||||
|
accessDeniedReason?: "account" | "project" | null;
|
||||||
|
userName?: string | null;
|
||||||
|
onLogout?: () => void;
|
||||||
|
onChooseAnotherProject?: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const permissions = useAccessStore((state) => state.permissions);
|
const permissions = useAccessStore((state) => state.permissions);
|
||||||
@@ -37,6 +58,98 @@ export const RoutePermissionGuard = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (accessDeniedReason) {
|
||||||
|
const displayName = userName?.trim() || "已登录账号";
|
||||||
|
const accountPending = accessDeniedReason === "account";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ minHeight: "100vh", bgcolor: "background.default" }}>
|
||||||
|
<AppBar
|
||||||
|
color="inherit"
|
||||||
|
elevation={0}
|
||||||
|
position="static"
|
||||||
|
sx={{ borderBottom: 1, borderColor: "divider" }}
|
||||||
|
>
|
||||||
|
<Toolbar sx={{ gap: 2, justifyContent: "space-between" }}>
|
||||||
|
<Title />
|
||||||
|
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||||
|
<Avatar sx={{ width: 32, height: 32, bgcolor: "primary.main" }}>
|
||||||
|
{displayName.slice(0, 1).toUpperCase()}
|
||||||
|
</Avatar>
|
||||||
|
<Box sx={{ display: { xs: "none", sm: "block" } }}>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
当前登录账号
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" fontWeight={600}>
|
||||||
|
{displayName}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{!accountPending && (
|
||||||
|
<Button
|
||||||
|
aria-label="切换项目"
|
||||||
|
color="inherit"
|
||||||
|
onClick={onChooseAnotherProject}
|
||||||
|
sx={{
|
||||||
|
gap: { xs: 0, sm: 1 },
|
||||||
|
minWidth: { xs: 40, sm: "auto" },
|
||||||
|
px: { xs: 1, sm: 2 },
|
||||||
|
"&:active": { transform: "translateY(1px)" },
|
||||||
|
}}
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
<SwapHorizIcon fontSize="small" />
|
||||||
|
<Box component="span" sx={{ display: { xs: "none", sm: "inline" } }}>
|
||||||
|
切换项目
|
||||||
|
</Box>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
aria-label="退出登录"
|
||||||
|
color="inherit"
|
||||||
|
onClick={onLogout}
|
||||||
|
sx={{
|
||||||
|
gap: { xs: 0, sm: 1 },
|
||||||
|
minWidth: { xs: 40, sm: "auto" },
|
||||||
|
px: { xs: 1, sm: 2 },
|
||||||
|
"&:active": { transform: "translateY(1px)" },
|
||||||
|
}}
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
<LogoutIcon fontSize="small" />
|
||||||
|
<Box component="span" sx={{ display: { xs: "none", sm: "inline" } }}>
|
||||||
|
退出登录
|
||||||
|
</Box>
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
<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" }}>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user