Compare commits

..
Author SHA1 Message Date
jiang 80f05e481b fix(auth): keep logout and project recovery on access denial
Generic Container CI/CD / test-build-publish (push) Successful in 1m3s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m3s
2026-11-04 13:51:05 +08:00
jiang 6ff5322353 fix(api): align generated server contract
Generic Container CI/CD / test-build-publish (push) Successful in 1m4s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m4s
2026-11-04 13:38:21 +08:00
jiang ca1c41aa9d feat(admin): sync and authorize Keycloak users
Generic Container CI/CD / test-build-publish (push) Successful in 1m5s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m5s
2026-11-04 13:35:29 +08:00
11 changed files with 753 additions and 28 deletions
+1 -1
View File
@@ -7,7 +7,7 @@
}, },
"server": { "server": {
"file": "server-v1.openapi.json", "file": "server-v1.openapi.json",
"sha256": "b565d841061c9091f48ff3118bcc0cbb1b918b0cb8c2316d177570e8b2d8ba29" "sha256": "1edfd78405cfe2a50022afe70a4c4f32caa4e98e3b5b54b40841d532058f2fd6"
} }
} }
} }
+156
View File
@@ -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",
+40
View File
@@ -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();
});
+18 -1
View File
@@ -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, []);
} }
+49
View File
@@ -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" };
+57 -20
View File
@@ -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,6 +179,32 @@ 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: () => {
if (data?.user?.id) {
sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`);
}
clearSessionRecoveryDrafts();
},
navigate: (path) => window.location.assign(path),
});
const handleChooseAnotherProject = () => {
setCurrentProject(null, null);
localStorage.removeItem("MAP_WORKSPACE");
localStorage.removeItem("MAP_EXTENT");
window.location.assign("/");
};
const authProvider: AuthProvider = { const authProvider: AuthProvider = {
login: async () => { login: async () => {
signIn("keycloak", { signIn("keycloak", {
@@ -165,24 +213,7 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
}); });
return { success: true }; return { success: true };
}, },
logout: () => logout: handleLogout,
completeLogout({
reportAudit: () =>
reportLogoutAudit({
endpoint: `${config.BACKEND_URL}/api/v1/audit-events`,
accessToken:
typeof data?.accessToken === "string"
? data.accessToken
: useAuthStore.getState().accessToken,
}),
clearLocalState: () => {
if (data?.user?.id) {
sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`);
}
clearSessionRecoveryDrafts();
},
navigate: (path) => window.location.assign(path),
}),
onError: async (error) => { onError: async (error) => {
return { error }; return { error };
}, },
@@ -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 个。",
);
});
});
+77 -4
View File
@@ -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>
@@ -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}>
+114 -1
View File
@@ -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" }}>
+113
View File
@@ -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;