Compare commits

...
Author SHA1 Message Date
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
10 changed files with 1077 additions and 143 deletions
+1 -1
View File
@@ -7,7 +7,7 @@
},
"server": {
"file": "server-v1.openapi.json",
"sha256": "b565d841061c9091f48ff3118bcc0cbb1b918b0cb8c2316d177570e8b2d8ba29"
"sha256": "19961572fad04f8545a4f607d4bf7276de64a84b266daecbb21d74e0975f97ef"
}
}
}
File diff suppressed because it is too large Load Diff
+34
View File
@@ -15,3 +15,37 @@ test("已登录管理员可以打开审计日志页面", async ({ page }) => {
await expect(page.getByText("系统管理员权限已验证")).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();
});
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 });
}
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 (
url.pathname === "/api/v1/audit-logs" ||
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, []);
}
+23
View File
@@ -115,6 +115,29 @@ 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(),
} as Response);
render(
<App>
<div>应用内容</div>
</App>,
);
expect(
await screen.findByText("账号尚未获得访问权限"),
).toBeInTheDocument();
expect(screen.queryByText("应用内容")).not.toBeInTheDocument();
expect(useAuthStore.getState()).toMatchObject({
sessionExpired: false,
sessionExpiryReason: null,
});
});
it("prioritizes an unauthenticated session over route permissions", async () => {
mockSessionState = { data: null, status: "unauthenticated" };
+14 -2
View File
@@ -9,7 +9,7 @@ import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
import { RefineSnackbarProvider } from "@refinedev/mui";
import { SessionProvider, signIn, useSession } from "next-auth/react";
import { usePathname } from "next/navigation";
import React, { useEffect } from "react";
import React, { useEffect, useState } from "react";
import routerProvider from "@refinedev/nextjs-router";
import { NavigationLink } from "@components/loading/NavigationLink";
@@ -75,6 +75,7 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
const setAccessContext = useAccessStore((state) => state.setContext);
const setAccessLoading = useAccessStore((state) => state.setLoading);
const resetAccess = useAccessStore((state) => state.reset);
const [accessDenied, setAccessDenied] = useState(false);
const can = (permission: string) => permissions.includes(permission);
useEffect(() => {
@@ -103,21 +104,29 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
useEffect(() => {
if (status !== "authenticated") {
setAccessDenied(false);
resetAccess();
return;
}
let cancelled = false;
setAccessDenied(false);
setAccessLoading(true);
apiFetch(`${config.BACKEND_URL}/api/v1/access-context`, {
projectHeaderMode: currentProjectId ? "include" : "omit",
})
.then(async (response) => {
if (cancelled) return;
if (response.status === 403) {
setAccessDenied(true);
resetAccess();
return;
}
if (!response.ok) {
resetAccess();
return;
}
setAccessDenied(false);
setAccessContext(await response.json());
})
.catch(() => {
@@ -392,7 +401,10 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
}}
>
<SessionExpiryDialog expiresAt={data?.sessionExpiresAt} />
<RoutePermissionGuard authenticated={status === "authenticated"}>
<RoutePermissionGuard
authenticated={status === "authenticated"}
accessDenied={accessDenied}
>
{props.children}
</RoutePermissionGuard>
<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 个。",
);
});
});
+75 -2
View File
@@ -53,6 +53,7 @@ import {
Save as SaveIcon,
Security as SecurityIcon,
Storage as StorageIcon,
Sync as SyncIcon,
} from "@mui/icons-material";
import { useNotification } from "@refinedev/core";
import { config } from "@config/config";
@@ -70,6 +71,17 @@ type MetadataUser = {
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 = {
id: string;
user_id: string;
@@ -469,6 +481,9 @@ export const SystemAdminPanel = () => {
const [adminChecked, setAdminChecked] = useState(false);
const [isAuthorized, setIsAuthorized] = useState(false);
const [metadataConfigAvailable, setMetadataConfigAvailable] = useState(true);
const [syncingKeycloakUsers, setSyncingKeycloakUsers] = useState(false);
const [keycloakSyncResult, setKeycloakSyncResult] =
useState<KeycloakUserSyncResult | null>(null);
const [projectId, setProjectId] = useState(currentProjectId ?? "");
const [memberForm, setMemberForm] = useState({
user_id: "",
@@ -564,6 +579,33 @@ export const SystemAdminPanel = () => {
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) => {
if (!project) {
setProjectForm(defaultProjectForm);
@@ -1238,13 +1280,44 @@ export const SystemAdminPanel = () => {
<Stack spacing={2}>
<SectionHeader
title="系统用户"
description="Keycloak 信息只读展示;这里维护系统角色和启用状态。超级管理员由后台初始化或受控脚本设置,不在页面修改。"
description="从 Keycloak 同步身份后,在这里维护系统角色和启用状态。新用户默认停用,项目权限仍需单独配置。"
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>
</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 }}>
<Table size="small" sx={tableSx}>
<TableHead>
@@ -12,9 +12,11 @@ import { useAuthStore } from "@/store/authStore";
export const RoutePermissionGuard = ({
children,
authenticated,
accessDenied = false,
}: {
children: ReactNode;
authenticated: boolean;
accessDenied?: boolean;
}) => {
const pathname = usePathname();
const permissions = useAccessStore((state) => state.permissions);
@@ -37,6 +39,21 @@ export const RoutePermissionGuard = ({
);
}
if (accessDenied) {
return (
<Box sx={{ p: { xs: 2, md: 4 } }}>
<Alert severity="warning" icon={<LockOutlinedIcon />}>
<Stack spacing={0.5}>
<Typography fontWeight={700}>账号尚未获得访问权限</Typography>
<Typography variant="body2">
身份认证已成功,但当前账号尚未由系统管理员启用,或无权访问所选项目。请联系系统管理员完成授权后重试。
</Typography>
</Stack>
</Alert>
</Box>
);
}
if (requiredPermission && loading) {
return (
<Box sx={{ minHeight: 320, display: "grid", placeItems: "center" }}>
+515 -134
View File
File diff suppressed because it is too large Load Diff