Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca1c41aa9d |
@@ -7,7 +7,7 @@
|
||||
},
|
||||
"server": {
|
||||
"file": "server-v1.openapi.json",
|
||||
"sha256": "b565d841061c9091f48ff3118bcc0cbb1b918b0cb8c2316d177570e8b2d8ba29"
|
||||
"sha256": "19961572fad04f8545a4f607d4bf7276de64a84b266daecbb21d74e0975f97ef"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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, []);
|
||||
}
|
||||
|
||||
@@ -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" };
|
||||
|
||||
|
||||
@@ -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 个。",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user