feat(external-admin): configure account permissions
This commit is contained in:
parent
490e36b2cf
commit
e9c4595f02
@ -6160,7 +6160,22 @@
|
||||
}
|
||||
},
|
||||
"x-permission": "external-admin-user:create",
|
||||
"x-permissions": ["external-admin-user:create"]
|
||||
"x-permissions": ["external-admin-user:create", "external-admin-user:permissions"]
|
||||
}
|
||||
},
|
||||
"/admin/external-admin-users/permission-catalog": {
|
||||
"get": {
|
||||
"operationId": "listExternalAdminPermissionCatalog",
|
||||
"parameters": [
|
||||
{ "$ref": "#/components/parameters/AppCodeHeader" }
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/ExternalAdminPermissionCatalogResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "external-admin-user:permissions",
|
||||
"x-permissions": ["external-admin-user:permissions"]
|
||||
}
|
||||
},
|
||||
"/admin/external-admin-users/target": {
|
||||
@ -6240,6 +6255,46 @@
|
||||
"x-permissions": ["external-admin-user:reset-password"]
|
||||
}
|
||||
},
|
||||
"/admin/external-admin-users/{id}/permissions": {
|
||||
"get": {
|
||||
"operationId": "getExternalAdminUserPermissions",
|
||||
"parameters": [
|
||||
{ "$ref": "#/components/parameters/AppCodeHeader" },
|
||||
{ "$ref": "#/components/parameters/Id" }
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/ExternalAdminUserPermissionsResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "external-admin-user:permissions",
|
||||
"x-permissions": ["external-admin-user:permissions"]
|
||||
},
|
||||
"put": {
|
||||
"operationId": "updateExternalAdminUserPermissions",
|
||||
"parameters": [
|
||||
{ "$ref": "#/components/parameters/AppCodeHeader" },
|
||||
{ "$ref": "#/components/parameters/Id" }
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ExternalAdminUserPermissionsInput"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/ExternalAdminUserResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "external-admin-user:permissions",
|
||||
"x-permissions": ["external-admin-user:permissions"]
|
||||
}
|
||||
},
|
||||
"/exports/app-users": {
|
||||
"post": {
|
||||
"operationId": "appExportUsers",
|
||||
@ -8693,6 +8748,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ExternalAdminPermissionCatalogResponse": {
|
||||
"description": "External admin permission catalog",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/ApiResponseExternalAdminPermissionCatalog" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"ExternalAdminUserPermissionsResponse": {
|
||||
"description": "External admin account permissions",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/ApiResponseExternalAdminUserPermissions" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"AgencyPageResponse": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
@ -9569,6 +9640,28 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"ApiResponseExternalAdminPermissionCatalog": {
|
||||
"allOf": [
|
||||
{ "$ref": "#/components/schemas/Envelope" },
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": { "$ref": "#/components/schemas/ExternalAdminPermissionCatalog" }
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ApiResponseExternalAdminUserPermissions": {
|
||||
"allOf": [
|
||||
{ "$ref": "#/components/schemas/Envelope" },
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": { "$ref": "#/components/schemas/ExternalAdminUserPermissions" }
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ApiPageExternalAdminUser": {
|
||||
"type": "object",
|
||||
"required": ["items", "page", "pageSize", "total"],
|
||||
@ -9606,7 +9699,7 @@
|
||||
},
|
||||
"ExternalAdminUser": {
|
||||
"type": "object",
|
||||
"required": ["id", "appCode", "username", "status", "linkedUser", "permissions", "createdAtMs"],
|
||||
"required": ["id", "appCode", "username", "status", "linkedUser", "permissions", "permissionRevision", "createdAtMs"],
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"appCode": { "type": "string" },
|
||||
@ -9617,6 +9710,7 @@
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"permissionRevision": { "type": "integer", "format": "int64", "minimum": 1 },
|
||||
"createdByAdminId": { "type": "integer" },
|
||||
"lastLoginAtMs": { "type": "integer", "format": "int64", "nullable": true },
|
||||
"createdAtMs": { "type": "integer", "format": "int64" },
|
||||
@ -9635,7 +9729,72 @@
|
||||
"maxLength": 64,
|
||||
"pattern": "^[A-Za-z0-9._-]+$"
|
||||
},
|
||||
"password": { "type": "string", "minLength": 8, "maxLength": 72 }
|
||||
"password": { "type": "string", "minLength": 8, "maxLength": 72 },
|
||||
"permissions": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 },
|
||||
"uniqueItems": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"ExternalAdminPermissionCatalogItem": {
|
||||
"type": "object",
|
||||
"required": ["code", "label", "group", "dependencies", "capabilities", "defaultGranted"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"code": { "type": "string", "minLength": 1 },
|
||||
"label": { "type": "string", "minLength": 1 },
|
||||
"group": { "type": "string", "minLength": 1 },
|
||||
"dependencies": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 },
|
||||
"uniqueItems": true
|
||||
},
|
||||
"capabilities": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 },
|
||||
"uniqueItems": true
|
||||
},
|
||||
"defaultGranted": { "type": "boolean" }
|
||||
}
|
||||
},
|
||||
"ExternalAdminPermissionCatalog": {
|
||||
"type": "object",
|
||||
"required": ["items", "total"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/components/schemas/ExternalAdminPermissionCatalogItem" }
|
||||
},
|
||||
"total": { "type": "integer", "minimum": 0 }
|
||||
}
|
||||
},
|
||||
"ExternalAdminUserPermissions": {
|
||||
"type": "object",
|
||||
"required": ["accountId", "permissions", "revision"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"accountId": { "type": "string", "minLength": 1 },
|
||||
"permissions": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 },
|
||||
"uniqueItems": true
|
||||
},
|
||||
"revision": { "type": "integer", "format": "int64", "minimum": 1 }
|
||||
}
|
||||
},
|
||||
"ExternalAdminUserPermissionsInput": {
|
||||
"type": "object",
|
||||
"required": ["permissions", "expectedRevision"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"permissions": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 },
|
||||
"uniqueItems": true
|
||||
},
|
||||
"expectedRevision": { "type": "integer", "format": "int64", "minimum": 1 }
|
||||
}
|
||||
},
|
||||
"ExternalAdminUserStatusInput": {
|
||||
|
||||
@ -63,6 +63,7 @@ export const PERMISSIONS = {
|
||||
coinAdjustmentCreate: "coin-adjustment:create",
|
||||
externalAdminUserView: "external-admin-user:view",
|
||||
externalAdminUserCreate: "external-admin-user:create",
|
||||
externalAdminUserPermissions: "external-admin-user:permissions",
|
||||
externalAdminUserStatus: "external-admin-user:status",
|
||||
externalAdminUserResetPassword: "external-admin-user:reset-password",
|
||||
reportView: "report:view",
|
||||
|
||||
@ -2,10 +2,13 @@ import { afterEach, expect, test, vi } from "vitest";
|
||||
import { setAccessToken, setSelectedAppCode } from "@/shared/api/request";
|
||||
import {
|
||||
createExternalAdminUser,
|
||||
getExternalAdminUserPermissions,
|
||||
listExternalAdminUsers,
|
||||
listExternalAdminPermissionCatalog,
|
||||
lookupExternalAdminUserTarget,
|
||||
resetExternalAdminUserPassword,
|
||||
updateExternalAdminUserStatus,
|
||||
updateExternalAdminUserPermissions,
|
||||
} from "./api";
|
||||
|
||||
afterEach(() => {
|
||||
@ -14,6 +17,56 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("loads the backend-driven permission catalog and sends an explicit permission snapshot", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn()
|
||||
.mockResolvedValueOnce(
|
||||
envelope({
|
||||
items: [
|
||||
{
|
||||
capabilities: ["user:list"],
|
||||
code: "user:list",
|
||||
defaultGranted: true,
|
||||
dependencies: [],
|
||||
group: "用户管理",
|
||||
label: "用户列表",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(envelope({ accountId: "71", permissions: ["user:list"], revision: 4 }))
|
||||
.mockResolvedValueOnce(envelope({ ...externalUserFixture(), permissions: [] })),
|
||||
);
|
||||
|
||||
const catalog = await listExternalAdminPermissionCatalog("fami");
|
||||
const current = await getExternalAdminUserPermissions("fami", 71);
|
||||
const updated = await updateExternalAdminUserPermissions("fami", 71, [], 4);
|
||||
|
||||
const calls = vi.mocked(fetch).mock.calls;
|
||||
expect(calls.map(([, init]) => init?.method)).toEqual(["GET", "GET", "PUT"]);
|
||||
expect(String(calls[0][0])).toContain("/api/v1/admin/external-admin-users/permission-catalog");
|
||||
expect(String(calls[1][0])).toContain("/api/v1/admin/external-admin-users/71/permissions");
|
||||
expect(String(calls[2][0])).toContain("/api/v1/admin/external-admin-users/71/permissions");
|
||||
expect(requestBody(calls[2][1])).toEqual({ expectedRevision: 4, permissions: [] });
|
||||
expect(catalog).toEqual({
|
||||
items: [
|
||||
{
|
||||
capabilities: ["user:list"],
|
||||
code: "user:list",
|
||||
defaultGranted: true,
|
||||
dependencies: [],
|
||||
group: "用户管理",
|
||||
label: "用户列表",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
});
|
||||
expect(current).toEqual({ accountId: "71", permissions: ["user:list"], revision: 4 });
|
||||
expect(updated.permissions).toEqual([]);
|
||||
});
|
||||
|
||||
test("external admin APIs pin every read and write to the explicit App scope", async () => {
|
||||
setSelectedAppCode("lalu");
|
||||
const externalUser = externalUserFixture();
|
||||
@ -76,6 +129,7 @@ test("external admin APIs pin every read and write to the explicit App scope", a
|
||||
userId: "user-1001",
|
||||
},
|
||||
permissions: ["user:list", "room:edit"],
|
||||
permissionRevision: 3,
|
||||
status: "active",
|
||||
username: "ops.fami",
|
||||
},
|
||||
@ -114,6 +168,7 @@ function externalUserFixture() {
|
||||
username: "Fami user",
|
||||
},
|
||||
permissions: ["user:list", "room:edit"],
|
||||
permission_revision: 3,
|
||||
status: "active",
|
||||
updated_at_ms: "1784077200000",
|
||||
username: "ops.fami",
|
||||
|
||||
@ -17,6 +17,7 @@ export interface ExternalAdminUserDto {
|
||||
lastLoginAtMs?: number;
|
||||
linkedUser: ExternalAdminLinkedUserDto;
|
||||
permissions: string[];
|
||||
permissionRevision: number;
|
||||
status: ExternalAdminUserStatus;
|
||||
updatedAtMs?: number;
|
||||
username: string;
|
||||
@ -26,8 +27,29 @@ export interface ExternalAdminUserTargetDto extends ExternalAdminLinkedUserDto {
|
||||
existingExternalAdminUser?: ExternalAdminUserDto;
|
||||
}
|
||||
|
||||
export interface ExternalAdminPermissionCatalogItemDto {
|
||||
capabilities: string[];
|
||||
code: string;
|
||||
defaultGranted: boolean;
|
||||
dependencies: string[];
|
||||
group: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ExternalAdminPermissionCatalogDto {
|
||||
items: ExternalAdminPermissionCatalogItemDto[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ExternalAdminUserPermissionsDto {
|
||||
accountId: string;
|
||||
permissions: string[];
|
||||
revision: number;
|
||||
}
|
||||
|
||||
export interface CreateExternalAdminUserPayload {
|
||||
password: string;
|
||||
permissions?: string[];
|
||||
targetUserId: string;
|
||||
username: string;
|
||||
}
|
||||
@ -58,6 +80,32 @@ export async function lookupExternalAdminUserTarget(
|
||||
return normalizeTarget(data);
|
||||
}
|
||||
|
||||
export async function listExternalAdminPermissionCatalog(
|
||||
appCode: string,
|
||||
): Promise<ExternalAdminPermissionCatalogDto> {
|
||||
const endpoint = API_ENDPOINTS.listExternalAdminPermissionCatalog;
|
||||
const data = await apiRequest<RawRecord>(apiEndpointPath(API_OPERATIONS.listExternalAdminPermissionCatalog), {
|
||||
headers: appScopeHeaders(appCode),
|
||||
method: endpoint.method,
|
||||
});
|
||||
return normalizePermissionCatalog(data);
|
||||
}
|
||||
|
||||
export async function getExternalAdminUserPermissions(
|
||||
appCode: string,
|
||||
id: EntityId,
|
||||
): Promise<ExternalAdminUserPermissionsDto> {
|
||||
const endpoint = API_ENDPOINTS.getExternalAdminUserPermissions;
|
||||
const data = await apiRequest<RawRecord>(
|
||||
apiEndpointPath(API_OPERATIONS.getExternalAdminUserPermissions, { id }),
|
||||
{
|
||||
headers: appScopeHeaders(appCode),
|
||||
method: endpoint.method,
|
||||
},
|
||||
);
|
||||
return normalizeUserPermissions(data);
|
||||
}
|
||||
|
||||
export async function createExternalAdminUser(
|
||||
appCode: string,
|
||||
payload: CreateExternalAdminUserPayload,
|
||||
@ -108,6 +156,24 @@ export async function resetExternalAdminUserPassword(
|
||||
return normalizeExternalAdminUser(data);
|
||||
}
|
||||
|
||||
export async function updateExternalAdminUserPermissions(
|
||||
appCode: string,
|
||||
id: EntityId,
|
||||
permissions: string[],
|
||||
expectedRevision: number,
|
||||
): Promise<ExternalAdminUserDto> {
|
||||
const endpoint = API_ENDPOINTS.updateExternalAdminUserPermissions;
|
||||
const data = await apiRequest<RawRecord, { expectedRevision: number; permissions: string[] }>(
|
||||
apiEndpointPath(API_OPERATIONS.updateExternalAdminUserPermissions, { id }),
|
||||
{
|
||||
body: { expectedRevision, permissions },
|
||||
headers: appScopeHeaders(appCode),
|
||||
method: endpoint.method,
|
||||
},
|
||||
);
|
||||
return normalizeExternalAdminUser(data);
|
||||
}
|
||||
|
||||
function appScopeHeaders(appCode: string) {
|
||||
// 外管账号是强 App 隔离数据;显式固定请求头,避免切换 App 后全局请求上下文把写操作送到另一租户。
|
||||
return { "X-App-Code": String(appCode || "").trim().toLowerCase() };
|
||||
@ -151,6 +217,7 @@ function normalizeExternalAdminUser(raw: RawRecord): ExternalAdminUserDto {
|
||||
lastLoginAtMs: optionalNumber(item.lastLoginAtMs ?? item.last_login_at_ms),
|
||||
linkedUser,
|
||||
permissions: stringArray(item.permissions),
|
||||
permissionRevision: numberValue(item.permissionRevision ?? item.permission_revision),
|
||||
status: status === "disabled" ? "disabled" : "active",
|
||||
updatedAtMs: optionalNumber(item.updatedAtMs ?? item.updated_at_ms),
|
||||
username: stringValue(item.username ?? item.account ?? item.accountName ?? item.account_name),
|
||||
@ -178,6 +245,34 @@ function normalizeTarget(raw: RawRecord): ExternalAdminUserTargetDto {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePermissionCatalog(raw: RawRecord): ExternalAdminPermissionCatalogDto {
|
||||
const item = asRecord(raw) || {};
|
||||
const items = (Array.isArray(item.items) ? item.items : [])
|
||||
.map((entry) => {
|
||||
const permission = asRecord(entry) || {};
|
||||
return {
|
||||
capabilities: stringArray(permission.capabilities),
|
||||
code: stringValue(permission.code),
|
||||
defaultGranted: Boolean(permission.defaultGranted ?? permission.default_granted),
|
||||
dependencies: stringArray(permission.dependencies),
|
||||
group: stringValue(permission.group),
|
||||
label: stringValue(permission.label),
|
||||
};
|
||||
})
|
||||
// 空编码无法用于提交,直接从可选目录剔除,避免 UI 生成不可保存的勾选项。
|
||||
.filter((permission) => permission.code);
|
||||
return { items, total: numberValue(item.total, items.length) };
|
||||
}
|
||||
|
||||
function normalizeUserPermissions(raw: RawRecord): ExternalAdminUserPermissionsDto {
|
||||
const item = asRecord(raw) || {};
|
||||
return {
|
||||
accountId: stringValue(item.accountId ?? item.account_id),
|
||||
permissions: stringArray(item.permissions),
|
||||
revision: numberValue(item.revision),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeUser(raw: RawRecord): ExternalAdminLinkedUserDto {
|
||||
const user = asRecord(raw) || {};
|
||||
return {
|
||||
|
||||
@ -0,0 +1,61 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { ExternalAdminPermissionDrawer } from "./ExternalAdminPermissionDrawer.jsx";
|
||||
import { ExternalAdminUserFormDialog } from "./ExternalAdminUserFormDialog.jsx";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const abilities = { canCreate: true, canManagePermissions: true };
|
||||
|
||||
test("disables account creation when a successful permission catalog is empty", () => {
|
||||
render(
|
||||
<ExternalAdminUserFormDialog
|
||||
abilities={abilities}
|
||||
catalog={[]}
|
||||
catalogError=""
|
||||
catalogLoading={false}
|
||||
form={{
|
||||
confirmPassword: "StrongPass123!",
|
||||
displayUserId: "1001",
|
||||
password: "StrongPass123!",
|
||||
permissions: [],
|
||||
username: "ops.fami",
|
||||
}}
|
||||
loading={false}
|
||||
lookupLoading={false}
|
||||
open
|
||||
targetUser={{ displayUserId: "1001", userId: "user-1", username: "Fami user" }}
|
||||
onClose={vi.fn()}
|
||||
onDisplayUserIdChange={vi.fn()}
|
||||
onFormChange={vi.fn()}
|
||||
onLookup={vi.fn()}
|
||||
onReloadCatalog={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "创建" })).toBeDisabled();
|
||||
});
|
||||
|
||||
test("disables permission updates when a successful catalog is empty", () => {
|
||||
render(
|
||||
<ExternalAdminPermissionDrawer
|
||||
abilities={abilities}
|
||||
catalog={[]}
|
||||
catalogError=""
|
||||
catalogLoading={false}
|
||||
error=""
|
||||
loading={false}
|
||||
permissions={[]}
|
||||
permissionsLoading={false}
|
||||
user={{ id: 71, username: "ops.fami" }}
|
||||
onChange={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onReloadCatalog={vi.fn()}
|
||||
onRetry={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "保存权限" })).toBeDisabled();
|
||||
});
|
||||
@ -0,0 +1,60 @@
|
||||
import { ExternalAdminPermissionSelector } from "@/features/external-admin-users/components/ExternalAdminPermissionSelector.jsx";
|
||||
import styles from "@/features/external-admin-users/external-admin-users.module.css";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
|
||||
|
||||
export function ExternalAdminPermissionDrawer({
|
||||
abilities,
|
||||
catalog,
|
||||
catalogError,
|
||||
catalogLoading,
|
||||
error,
|
||||
loading,
|
||||
onChange,
|
||||
onClose,
|
||||
onReloadCatalog,
|
||||
onRetry,
|
||||
onSubmit,
|
||||
permissions,
|
||||
permissionsLoading,
|
||||
user,
|
||||
}) {
|
||||
const open = Boolean(user);
|
||||
const unavailable = Boolean(
|
||||
!catalog.length || catalogError || error || catalogLoading || permissionsLoading || loading,
|
||||
);
|
||||
|
||||
return (
|
||||
<SideDrawer
|
||||
actions={
|
||||
<>
|
||||
<Button disabled={loading} onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!abilities.canManagePermissions || unavailable}
|
||||
variant="primary"
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{loading ? "保存中" : "保存权限"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
contentClassName={styles.permissionDrawerBody}
|
||||
open={open}
|
||||
title={`配置权限${user?.username ? ` · ${user.username}` : ""}`}
|
||||
width="detail"
|
||||
onClose={onClose}
|
||||
>
|
||||
<ExternalAdminPermissionSelector
|
||||
catalog={catalog}
|
||||
disabled={!abilities.canManagePermissions || loading}
|
||||
error={catalogError || error}
|
||||
loading={catalogLoading || permissionsLoading}
|
||||
value={permissions}
|
||||
onChange={onChange}
|
||||
onRetry={catalogError ? onReloadCatalog : error ? onRetry : undefined}
|
||||
/>
|
||||
</SideDrawer>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,144 @@
|
||||
import Checkbox from "@mui/material/Checkbox";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import { useMemo } from "react";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import styles from "@/features/external-admin-users/external-admin-users.module.css";
|
||||
import {
|
||||
updatePermissionGroup,
|
||||
updatePermissionSelection,
|
||||
} from "@/features/external-admin-users/permissionSelection.js";
|
||||
|
||||
export { updatePermissionSelection } from "@/features/external-admin-users/permissionSelection.js";
|
||||
|
||||
export function ExternalAdminPermissionSelector({
|
||||
catalog = [],
|
||||
disabled = false,
|
||||
error = "",
|
||||
loading = false,
|
||||
onChange,
|
||||
onRetry,
|
||||
value = [],
|
||||
}) {
|
||||
const groups = useMemo(() => groupCatalog(catalog), [catalog]);
|
||||
const knownCodes = useMemo(() => catalog.map((permission) => permission.code), [catalog]);
|
||||
const selected = useMemo(() => new Set(value), [value]);
|
||||
const selectedCount = knownCodes.filter((code) => selected.has(code)).length;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.permissionState} role="status">
|
||||
<CircularProgress size={22} />
|
||||
<span>加载权限目录</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.permissionState} role="alert">
|
||||
<span>{error}</span>
|
||||
{onRetry ? <Button onClick={onRetry}>重新加载</Button> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.permissionSelector}>
|
||||
<div className={styles.permissionToolbar}>
|
||||
<strong>
|
||||
已选 {selectedCount} / {knownCodes.length} 项
|
||||
</strong>
|
||||
<div className={styles.permissionToolbarActions}>
|
||||
<Button
|
||||
disabled={disabled || selectedCount === knownCodes.length}
|
||||
onClick={() => onChange?.(knownCodes)}
|
||||
>
|
||||
全选
|
||||
</Button>
|
||||
<Button disabled={disabled || selectedCount === 0} onClick={() => onChange?.([])}>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{groups.length ? (
|
||||
<div className={styles.permissionGroups}>
|
||||
{groups.map((group) => {
|
||||
const groupCodes = group.items.map((permission) => permission.code);
|
||||
const groupSelectedCount = groupCodes.filter((code) => selected.has(code)).length;
|
||||
return (
|
||||
<section className={styles.permissionGroup} key={group.name}>
|
||||
<header className={styles.permissionGroupHeader}>
|
||||
<strong>{group.name}</strong>
|
||||
<label>
|
||||
<Checkbox
|
||||
checked={groupSelectedCount === groupCodes.length}
|
||||
disabled={disabled}
|
||||
indeterminate={groupSelectedCount > 0 && groupSelectedCount < groupCodes.length}
|
||||
slotProps={{ input: { "aria-label": `${group.name}全选` } }}
|
||||
size="small"
|
||||
onChange={(event) =>
|
||||
onChange?.(
|
||||
updatePermissionGroup(
|
||||
catalog,
|
||||
value,
|
||||
groupCodes,
|
||||
event.target.checked,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
{groupSelectedCount}/{groupCodes.length}
|
||||
</span>
|
||||
</label>
|
||||
</header>
|
||||
<div className={styles.permissionOptions}>
|
||||
{group.items.map((permission) => {
|
||||
return (
|
||||
<label className={styles.permissionOption} key={permission.code}>
|
||||
<Checkbox
|
||||
checked={selected.has(permission.code)}
|
||||
disabled={disabled}
|
||||
slotProps={{ input: { "aria-label": permission.label } }}
|
||||
size="small"
|
||||
onChange={(event) =>
|
||||
onChange?.(
|
||||
updatePermissionSelection(
|
||||
catalog,
|
||||
value,
|
||||
permission.code,
|
||||
event.target.checked,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
<strong>{permission.label}</strong>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.permissionState}>当前无可配置权限</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function groupCatalog(catalog) {
|
||||
const groups = new Map();
|
||||
catalog.forEach((permission) => {
|
||||
const groupName = permission.group || "其他";
|
||||
if (!groups.has(groupName)) {
|
||||
groups.set(groupName, []);
|
||||
}
|
||||
groups.get(groupName).push(permission);
|
||||
});
|
||||
return [...groups].map(([name, items]) => ({ items, name }));
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
ExternalAdminPermissionSelector,
|
||||
updatePermissionSelection,
|
||||
} from "./ExternalAdminPermissionSelector.jsx";
|
||||
import { defaultPermissionSelection } from "@/features/external-admin-users/permissionSelection.js";
|
||||
|
||||
const catalog = [
|
||||
{ code: "user:list", dependencies: [], group: "用户管理", label: "用户列表" },
|
||||
{ code: "user:update", dependencies: ["user:list"], group: "用户管理", label: "编辑用户" },
|
||||
{ code: "room:list", dependencies: [], group: "房间管理", label: "房间列表" },
|
||||
];
|
||||
|
||||
describe("ExternalAdminPermissionSelector", () => {
|
||||
test("adds required permissions and removes actions whose dependency is cleared", () => {
|
||||
expect(updatePermissionSelection(catalog, [], "user:update", true)).toEqual(["user:list", "user:update"]);
|
||||
expect(
|
||||
updatePermissionSelection(catalog, ["user:list", "user:update", "room:list"], "user:list", false),
|
||||
).toEqual(["room:list"]);
|
||||
});
|
||||
|
||||
test("expands dependencies declared by default-granted catalog entries", () => {
|
||||
expect(
|
||||
defaultPermissionSelection([
|
||||
{ code: "user:list", defaultGranted: false, dependencies: [] },
|
||||
{ code: "user:update", defaultGranted: true, dependencies: ["user:list"] },
|
||||
]),
|
||||
).toEqual(["user:list", "user:update"]);
|
||||
});
|
||||
|
||||
test("resolves cyclic dependencies without recursion or removal loops", () => {
|
||||
const cyclicCatalog = [
|
||||
{ code: "level:view", dependencies: ["vip:view"], group: "等级", label: "等级" },
|
||||
{ code: "vip:view", dependencies: ["level:view"], group: "等级", label: "VIP" },
|
||||
];
|
||||
|
||||
expect(updatePermissionSelection(cyclicCatalog, [], "level:view", true)).toEqual([
|
||||
"level:view",
|
||||
"vip:view",
|
||||
]);
|
||||
expect(updatePermissionSelection(cyclicCatalog, ["level:view", "vip:view"], "level:view", false)).toEqual([]);
|
||||
});
|
||||
|
||||
test("renders backend groups and supports explicit select all and clear", () => {
|
||||
const onChange = vi.fn();
|
||||
const { rerender } = render(
|
||||
<ExternalAdminPermissionSelector catalog={catalog} onChange={onChange} value={["user:list"]} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText("已选 1 / 3 项")).toBeInTheDocument();
|
||||
expect(screen.getByText("用户管理")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "全选" }));
|
||||
expect(onChange).toHaveBeenLastCalledWith(["user:list", "user:update", "room:list"]);
|
||||
|
||||
rerender(<ExternalAdminPermissionSelector catalog={catalog} onChange={onChange} value={catalog.map((item) => item.code)} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "清空" }));
|
||||
expect(onChange).toHaveBeenLastCalledWith([]);
|
||||
});
|
||||
});
|
||||
@ -8,9 +8,13 @@ import {
|
||||
} from "@/shared/ui/AdminFormDialog.jsx";
|
||||
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||||
import styles from "@/features/external-admin-users/external-admin-users.module.css";
|
||||
import { ExternalAdminPermissionSelector } from "@/features/external-admin-users/components/ExternalAdminPermissionSelector.jsx";
|
||||
|
||||
export function ExternalAdminUserFormDialog({
|
||||
abilities,
|
||||
catalog,
|
||||
catalogError,
|
||||
catalogLoading,
|
||||
form,
|
||||
loading,
|
||||
lookupLoading,
|
||||
@ -18,6 +22,7 @@ export function ExternalAdminUserFormDialog({
|
||||
onDisplayUserIdChange,
|
||||
onFormChange,
|
||||
onLookup,
|
||||
onReloadCatalog,
|
||||
onSubmit,
|
||||
open,
|
||||
targetUser,
|
||||
@ -30,7 +35,14 @@ export function ExternalAdminUserFormDialog({
|
||||
loading={loading}
|
||||
open={open}
|
||||
size="large"
|
||||
submitDisabled={!abilities.canCreate || loading || !targetUser?.userId || Boolean(existingAccount)}
|
||||
submitDisabled={
|
||||
!abilities.canCreate ||
|
||||
loading ||
|
||||
!targetUser?.userId ||
|
||||
Boolean(existingAccount) ||
|
||||
(abilities.canManagePermissions &&
|
||||
(catalogLoading || Boolean(catalogError) || !catalog.length))
|
||||
}
|
||||
submitLabel="创建"
|
||||
title="创建外管用户"
|
||||
onClose={onClose}
|
||||
@ -108,6 +120,20 @@ export function ExternalAdminUserFormDialog({
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
|
||||
{abilities.canManagePermissions ? (
|
||||
<AdminFormSection title="外管权限">
|
||||
<ExternalAdminPermissionSelector
|
||||
catalog={catalog}
|
||||
disabled={loading}
|
||||
error={catalogError}
|
||||
loading={catalogLoading}
|
||||
value={form.permissions || []}
|
||||
onChange={(permissions) => onFormChange({ permissions })}
|
||||
onRetry={onReloadCatalog}
|
||||
/>
|
||||
</AdminFormSection>
|
||||
) : null}
|
||||
</AdminFormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@ -50,6 +50,137 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.permissionSelector {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.permissionToolbar {
|
||||
display: flex;
|
||||
min-height: var(--control-height);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.permissionToolbar > strong {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--control-font-size);
|
||||
}
|
||||
|
||||
.permissionToolbarActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.permissionToolbarActions :global(.MuiButton-root) {
|
||||
min-width: 64px;
|
||||
}
|
||||
|
||||
.permissionGroups {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.permissionGroup {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.permissionGroupHeader {
|
||||
display: flex;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: 0 var(--space-3);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
background: var(--bg-card-strong);
|
||||
}
|
||||
|
||||
.permissionGroupHeader > strong {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--control-font-size);
|
||||
}
|
||||
|
||||
.permissionGroupHeader label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.permissionOptions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.permissionOption {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 48px;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--bg-card-strong);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--motion-fast) var(--ease-standard),
|
||||
background var(--motion-fast) var(--ease-standard),
|
||||
transform var(--motion-base) var(--ease-emphasized);
|
||||
}
|
||||
|
||||
.permissionOption:hover {
|
||||
border-color: var(--primary-border-strong);
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.permissionOption:active {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
.permissionOption > span {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.permissionOption strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.permissionOption strong {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--control-font-size);
|
||||
}
|
||||
|
||||
.permissionState {
|
||||
display: grid;
|
||||
min-height: 150px;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
color: var(--text-tertiary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.permissionDrawerBody {
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.lookupRow {
|
||||
grid-template-columns: 1fr;
|
||||
@ -58,4 +189,24 @@
|
||||
.lookupButton {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.permissionToolbar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.permissionToolbarActions,
|
||||
.permissionToolbarActions :global(.MuiButton-root) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.permissionOptions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.permissionOption {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,16 +3,21 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAppScope } from "@/app/app-scope/AppScopeProvider.jsx";
|
||||
import {
|
||||
createExternalAdminUser,
|
||||
getExternalAdminUserPermissions,
|
||||
listExternalAdminUsers,
|
||||
listExternalAdminPermissionCatalog,
|
||||
lookupExternalAdminUserTarget,
|
||||
resetExternalAdminUserPassword,
|
||||
updateExternalAdminUserPermissions,
|
||||
updateExternalAdminUserStatus,
|
||||
} from "@/features/external-admin-users/api";
|
||||
import { useExternalAdminUserAbilities } from "@/features/external-admin-users/permissions.js";
|
||||
import { defaultPermissionSelection } from "@/features/external-admin-users/permissionSelection.js";
|
||||
import {
|
||||
externalAdminUserCreateFormSchema,
|
||||
externalAdminUserLookupSchema,
|
||||
externalAdminUserPasswordFormSchema,
|
||||
externalAdminUserPermissionsFormSchema,
|
||||
} from "@/features/external-admin-users/schema";
|
||||
import { toPageQuery } from "@/shared/api/query";
|
||||
import { parseForm } from "@/shared/forms/validation";
|
||||
@ -23,8 +28,9 @@ import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
|
||||
const pageSize = 50;
|
||||
const emptyPage = { items: [], page: 1, pageSize, total: 0 };
|
||||
const emptyCreateForm = () => ({ confirmPassword: "", displayUserId: "", password: "", username: "" });
|
||||
const emptyCreateForm = () => ({ confirmPassword: "", displayUserId: "", password: "", permissions: [], username: "" });
|
||||
const emptyPasswordForm = () => ({ confirmPassword: "", password: "" });
|
||||
const emptyPermissionCatalog = { items: [], total: 0 };
|
||||
|
||||
export function useExternalAdminUsersPage() {
|
||||
const { appCode } = useAppScope();
|
||||
@ -34,6 +40,7 @@ export function useExternalAdminUsersPage() {
|
||||
const { showToast } = useToast();
|
||||
const currentAppCodeRef = useRef(appCode);
|
||||
const lookupRequestRef = useRef(0);
|
||||
const permissionRequestRef = useRef(0);
|
||||
|
||||
const [keyword, setKeywordState] = useState("");
|
||||
const [status, setStatusState] = useState("");
|
||||
@ -43,10 +50,15 @@ export function useExternalAdminUsersPage() {
|
||||
const [createForm, setCreateFormState] = useState(emptyCreateForm);
|
||||
const [targetUser, setTargetUser] = useState(null);
|
||||
const [lookupLoading, setLookupLoading] = useState(false);
|
||||
const [createPermissionsReady, setCreatePermissionsReady] = useState(false);
|
||||
const [passwordUser, setPasswordUser] = useState(null);
|
||||
const [passwordForm, setPasswordFormState] = useState(emptyPasswordForm);
|
||||
const [actionAppCode, setActionAppCode] = useState("");
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
const [permissionUser, setPermissionUser] = useState(null);
|
||||
const [permissionForm, setPermissionFormState] = useState({ expectedRevision: null, permissions: [] });
|
||||
const [permissionLoading, setPermissionLoading] = useState(false);
|
||||
const [permissionError, setPermissionError] = useState("");
|
||||
|
||||
const requestQuery = useMemo(
|
||||
() => toPageQuery({ keyword, page, pageSize, status }),
|
||||
@ -64,6 +76,22 @@ export function useExternalAdminUsersPage() {
|
||||
initialData: emptyPage,
|
||||
queryKey: ["external-admin-users", appCode, requestQuery],
|
||||
});
|
||||
const catalogQueryFn = useCallback(() => listExternalAdminPermissionCatalog(appCode), [appCode]);
|
||||
const {
|
||||
data: permissionCatalog = emptyPermissionCatalog,
|
||||
error: catalogQueryError,
|
||||
loading: catalogLoading,
|
||||
reload: reloadPermissionCatalog,
|
||||
} = useAdminQuery(catalogQueryFn, {
|
||||
enabled: Boolean(appCode && abilities.canManagePermissions),
|
||||
errorMessage: "加载外管权限目录失败",
|
||||
initialData: emptyPermissionCatalog,
|
||||
queryKey: ["external-admin-permission-catalog", appCode],
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const catalogEmpty =
|
||||
abilities.canManagePermissions && !catalogLoading && !catalogQueryError && permissionCatalog.items.length === 0;
|
||||
const catalogError = catalogQueryError || (catalogEmpty ? "外管权限目录为空,请联系管理员" : "");
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// 写操作完成回调依赖最新租户;布局 effect 在用户能继续交互前同步边界,又不在渲染阶段读写 ref。
|
||||
@ -73,20 +101,51 @@ export function useExternalAdminUsersPage() {
|
||||
useEffect(() => {
|
||||
// App 是外管账号的租户边界;切换后必须立即丢弃账号、密码和用户匹配结果,禁止跨 App 复用敏感表单。
|
||||
lookupRequestRef.current += 1;
|
||||
permissionRequestRef.current += 1;
|
||||
setCreateOpen(false);
|
||||
setCreateFormState(emptyCreateForm());
|
||||
setCreatePermissionsReady(false);
|
||||
setTargetUser(null);
|
||||
setLookupLoading(false);
|
||||
setPasswordUser(null);
|
||||
setPasswordFormState(emptyPasswordForm());
|
||||
setActionAppCode("");
|
||||
setLoadingAction("");
|
||||
setPermissionUser(null);
|
||||
setPermissionFormState({ expectedRevision: null, permissions: [] });
|
||||
setPermissionLoading(false);
|
||||
setPermissionError("");
|
||||
setKeywordState("");
|
||||
setStatusState("");
|
||||
setPage(1);
|
||||
setMergedPage({ ...emptyPage, appCode });
|
||||
}, [appCode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!createOpen ||
|
||||
!abilities.canManagePermissions ||
|
||||
createPermissionsReady ||
|
||||
catalogLoading ||
|
||||
catalogError
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// 默认值完全由目录声明,避免前端把未来新增的高风险能力自动授权;即使默认集合为空,也显式保留 []。
|
||||
setCreateFormState((current) => ({
|
||||
...current,
|
||||
permissions: defaultPermissionSelection(permissionCatalog.items || []),
|
||||
}));
|
||||
setCreatePermissionsReady(true);
|
||||
}, [
|
||||
abilities.canManagePermissions,
|
||||
catalogError,
|
||||
catalogLoading,
|
||||
createOpen,
|
||||
createPermissionsReady,
|
||||
permissionCatalog.items,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
setMergedPage({ ...emptyPage, appCode });
|
||||
}, [appCode, keyword, status]);
|
||||
@ -134,6 +193,9 @@ export function useExternalAdminUsersPage() {
|
||||
};
|
||||
|
||||
const setCreateForm = (patch) => {
|
||||
if (Object.prototype.hasOwnProperty.call(patch, "permissions")) {
|
||||
setCreatePermissionsReady(true);
|
||||
}
|
||||
setCreateFormState((current) => ({ ...current, ...patch }));
|
||||
};
|
||||
const changeDisplayUserId = (value) => {
|
||||
@ -144,7 +206,15 @@ export function useExternalAdminUsersPage() {
|
||||
};
|
||||
const openCreate = () => {
|
||||
lookupRequestRef.current += 1;
|
||||
setCreateFormState(emptyCreateForm());
|
||||
const catalogReady = !catalogLoading && !catalogError;
|
||||
setCreateFormState({
|
||||
...emptyCreateForm(),
|
||||
permissions:
|
||||
abilities.canManagePermissions && catalogReady
|
||||
? defaultPermissionSelection(permissionCatalog.items || [])
|
||||
: [],
|
||||
});
|
||||
setCreatePermissionsReady(!abilities.canManagePermissions || catalogReady);
|
||||
setTargetUser(null);
|
||||
setLookupLoading(false);
|
||||
setActionAppCode(appCode);
|
||||
@ -157,6 +227,7 @@ export function useExternalAdminUsersPage() {
|
||||
lookupRequestRef.current += 1;
|
||||
setCreateOpen(false);
|
||||
setCreateFormState(emptyCreateForm());
|
||||
setCreatePermissionsReady(false);
|
||||
setTargetUser(null);
|
||||
setLookupLoading(false);
|
||||
setActionAppCode("");
|
||||
@ -206,6 +277,10 @@ export function useExternalAdminUsersPage() {
|
||||
const submitCreate = async (event) => {
|
||||
event.preventDefault();
|
||||
const scope = actionAppCode;
|
||||
if (!abilities.canCreate) {
|
||||
showToast("没有创建外管用户的权限", "error");
|
||||
return;
|
||||
}
|
||||
if (!scope || scope !== appCode) {
|
||||
showToast("应用已切换,请重新创建", "error");
|
||||
return;
|
||||
@ -218,6 +293,10 @@ export function useExternalAdminUsersPage() {
|
||||
showToast("该 App 用户已绑定外管账号", "error");
|
||||
return;
|
||||
}
|
||||
if (catalogLoading || catalogError || permissionCatalog.items.length === 0) {
|
||||
showToast(catalogError || "外管权限目录尚未加载", "error");
|
||||
return;
|
||||
}
|
||||
let formPayload;
|
||||
try {
|
||||
formPayload = parseForm(externalAdminUserCreateFormSchema, createForm);
|
||||
@ -230,6 +309,8 @@ export function useExternalAdminUsersPage() {
|
||||
try {
|
||||
await createExternalAdminUser(scope, {
|
||||
password: formPayload.password,
|
||||
// 创建权限已收口为 create + permissions,始终提交显式快照,[] 表示主动创建零权限账号。
|
||||
permissions: formPayload.permissions || [],
|
||||
// 始终提交查询接口返回的稳定 userId,短 ID / 靓号只用于定位,避免后续改号造成错误绑定。
|
||||
targetUserId: targetUser.userId,
|
||||
username: formPayload.username,
|
||||
@ -239,6 +320,7 @@ export function useExternalAdminUsersPage() {
|
||||
}
|
||||
setCreateOpen(false);
|
||||
setCreateFormState(emptyCreateForm());
|
||||
setCreatePermissionsReady(false);
|
||||
setTargetUser(null);
|
||||
setActionAppCode("");
|
||||
showToast("外管用户已创建", "success");
|
||||
@ -341,10 +423,121 @@ export function useExternalAdminUsersPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadPermissions = useCallback(
|
||||
async (item, scope = appCode) => {
|
||||
const requestId = permissionRequestRef.current + 1;
|
||||
permissionRequestRef.current = requestId;
|
||||
setPermissionLoading(true);
|
||||
setPermissionError("");
|
||||
try {
|
||||
const result = await getExternalAdminUserPermissions(scope, item.id);
|
||||
if (currentAppCodeRef.current !== scope || permissionRequestRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
setPermissionFormState({
|
||||
// 详情和目录可能并发返回,先保留服务端快照;提交边界再按届时已加载的目录过滤。
|
||||
expectedRevision: result.revision,
|
||||
permissions: result.permissions,
|
||||
});
|
||||
} catch (err) {
|
||||
if (currentAppCodeRef.current === scope && permissionRequestRef.current === requestId) {
|
||||
setPermissionError(err.message || "加载账号权限失败");
|
||||
}
|
||||
} finally {
|
||||
if (currentAppCodeRef.current === scope && permissionRequestRef.current === requestId) {
|
||||
setPermissionLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[appCode],
|
||||
);
|
||||
|
||||
const openPermissions = (item) => {
|
||||
setPermissionUser(item);
|
||||
setPermissionFormState({ expectedRevision: null, permissions: [] });
|
||||
setPermissionError("");
|
||||
setActionAppCode(appCode);
|
||||
void loadPermissions(item, appCode);
|
||||
};
|
||||
const closePermissions = () => {
|
||||
if (loadingAction.startsWith("permissions:")) {
|
||||
return;
|
||||
}
|
||||
permissionRequestRef.current += 1;
|
||||
setPermissionUser(null);
|
||||
setPermissionFormState({ expectedRevision: null, permissions: [] });
|
||||
setPermissionLoading(false);
|
||||
setPermissionError("");
|
||||
setActionAppCode("");
|
||||
};
|
||||
const retryPermissions = () => {
|
||||
if (permissionUser) {
|
||||
void loadPermissions(permissionUser, actionAppCode || appCode);
|
||||
}
|
||||
};
|
||||
const setPermissions = (permissions) => {
|
||||
setPermissionFormState((current) => ({ ...current, permissions }));
|
||||
};
|
||||
const submitPermissions = async () => {
|
||||
const scope = actionAppCode;
|
||||
const item = permissionUser;
|
||||
if (!abilities.canManagePermissions) {
|
||||
showToast("没有配置外管权限的权限", "error");
|
||||
return;
|
||||
}
|
||||
if (!item || !scope || scope !== appCode) {
|
||||
showToast("应用已切换,请重新操作", "error");
|
||||
return;
|
||||
}
|
||||
if (catalogLoading || catalogError || permissionCatalog.items.length === 0) {
|
||||
showToast(catalogError || "外管权限目录尚未加载", "error");
|
||||
return;
|
||||
}
|
||||
let payload;
|
||||
try {
|
||||
payload = parseForm(externalAdminUserPermissionsFormSchema, permissionForm);
|
||||
} catch (err) {
|
||||
showToast(err.message || "权限参数不正确", "error");
|
||||
return;
|
||||
}
|
||||
const knownCodes = new Set((permissionCatalog.items || []).map((permission) => permission.code));
|
||||
const permissions = payload.permissions.filter((permission) => knownCodes.has(permission));
|
||||
|
||||
setPermissionError("");
|
||||
setLoadingAction(`permissions:${item.id}`);
|
||||
try {
|
||||
// 目录是允许写入的唯一边界;被服务端下线的旧编码不应在本次编辑中重新写回。
|
||||
await updateExternalAdminUserPermissions(scope, item.id, permissions, payload.expectedRevision);
|
||||
if (currentAppCodeRef.current !== scope) {
|
||||
return;
|
||||
}
|
||||
setPermissionUser(null);
|
||||
setPermissionFormState({ expectedRevision: null, permissions: [] });
|
||||
setPermissionError("");
|
||||
setActionAppCode("");
|
||||
showToast("权限已更新,该账号需重新登录", "success");
|
||||
await refreshList(scope);
|
||||
} catch (err) {
|
||||
if (currentAppCodeRef.current === scope) {
|
||||
const message =
|
||||
Number(err?.status) === 409
|
||||
? "账号权限已被更新,请重新加载"
|
||||
: "更新外管权限失败";
|
||||
setPermissionError(message);
|
||||
showToast(message, "error");
|
||||
}
|
||||
} finally {
|
||||
if (currentAppCodeRef.current === scope) {
|
||||
setLoadingAction("");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
closeCreate,
|
||||
closePassword,
|
||||
closePermissions,
|
||||
createForm,
|
||||
createOpen,
|
||||
data,
|
||||
@ -356,21 +549,33 @@ export function useExternalAdminUsersPage() {
|
||||
lookupTarget,
|
||||
openCreate,
|
||||
openPassword,
|
||||
openPermissions,
|
||||
page,
|
||||
pageSize,
|
||||
passwordForm,
|
||||
passwordUser,
|
||||
permissionCatalog,
|
||||
permissionError,
|
||||
permissionForm,
|
||||
permissionLoading,
|
||||
permissionUser,
|
||||
catalogError,
|
||||
catalogLoading,
|
||||
reload,
|
||||
resetFilters,
|
||||
reloadPermissionCatalog,
|
||||
retryPermissions,
|
||||
setCreateForm,
|
||||
setDisplayUserId: changeDisplayUserId,
|
||||
setKeyword,
|
||||
setPage,
|
||||
setPasswordForm,
|
||||
setPermissions,
|
||||
setStatus,
|
||||
status,
|
||||
submitCreate,
|
||||
submitPassword,
|
||||
submitPermissions,
|
||||
targetUser,
|
||||
toggleStatus,
|
||||
};
|
||||
|
||||
@ -2,12 +2,18 @@ import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
abilities: { canCreate: true, canResetPassword: true, canStatus: true, canView: true },
|
||||
abilities: { canCreate: true, canManagePermissions: true, canResetPassword: true, canStatus: true, canView: true },
|
||||
appCode: "fami",
|
||||
catalogError: "",
|
||||
catalogLoading: false,
|
||||
catalogPage: null,
|
||||
catalogQueryOptions: null,
|
||||
confirm: vi.fn(),
|
||||
createExternalAdminUser: vi.fn(),
|
||||
getExternalAdminUserPermissions: vi.fn(),
|
||||
invalidateQueries: vi.fn(),
|
||||
listExternalAdminUsers: vi.fn(),
|
||||
listExternalAdminPermissionCatalog: vi.fn(),
|
||||
lookupExternalAdminUserTarget: vi.fn(),
|
||||
queryOptions: null,
|
||||
queryPage: { items: [], page: 1, pageSize: 50, total: 0 },
|
||||
@ -15,6 +21,7 @@ const mocks = vi.hoisted(() => ({
|
||||
resetExternalAdminUserPassword: vi.fn(),
|
||||
showToast: vi.fn(),
|
||||
updateExternalAdminUserStatus: vi.fn(),
|
||||
updateExternalAdminUserPermissions: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
@ -27,10 +34,13 @@ vi.mock("@/app/app-scope/AppScopeProvider.jsx", () => ({
|
||||
|
||||
vi.mock("@/features/external-admin-users/api", () => ({
|
||||
createExternalAdminUser: mocks.createExternalAdminUser,
|
||||
getExternalAdminUserPermissions: mocks.getExternalAdminUserPermissions,
|
||||
listExternalAdminUsers: mocks.listExternalAdminUsers,
|
||||
listExternalAdminPermissionCatalog: mocks.listExternalAdminPermissionCatalog,
|
||||
lookupExternalAdminUserTarget: mocks.lookupExternalAdminUserTarget,
|
||||
resetExternalAdminUserPassword: mocks.resetExternalAdminUserPassword,
|
||||
updateExternalAdminUserStatus: mocks.updateExternalAdminUserStatus,
|
||||
updateExternalAdminUserPermissions: mocks.updateExternalAdminUserPermissions,
|
||||
}));
|
||||
|
||||
vi.mock("@/features/external-admin-users/permissions.js", () => ({
|
||||
@ -40,6 +50,18 @@ vi.mock("@/features/external-admin-users/permissions.js", () => ({
|
||||
vi.mock("@/shared/hooks/useAdminQuery.js", () => ({
|
||||
useAdminQuery: (_queryFn, options) => {
|
||||
mocks.queryOptions = options;
|
||||
if (options.queryKey[0] === "external-admin-permission-catalog") {
|
||||
mocks.catalogQueryOptions = options;
|
||||
if (options.enabled) {
|
||||
void _queryFn();
|
||||
}
|
||||
return {
|
||||
data: mocks.catalogPage,
|
||||
error: mocks.catalogError,
|
||||
loading: mocks.catalogLoading,
|
||||
reload: mocks.reload,
|
||||
};
|
||||
}
|
||||
return { data: mocks.queryPage, error: "", loading: false, reload: mocks.reload };
|
||||
},
|
||||
}));
|
||||
@ -55,15 +77,26 @@ vi.mock("@/shared/ui/ToastProvider.jsx", () => ({
|
||||
import { useExternalAdminUsersPage } from "./useExternalAdminUsersPage.js";
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.abilities = { canCreate: true, canManagePermissions: true, canResetPassword: true, canStatus: true, canView: true };
|
||||
mocks.appCode = "fami";
|
||||
mocks.catalogError = "";
|
||||
mocks.catalogLoading = false;
|
||||
mocks.catalogPage = permissionCatalogFixture();
|
||||
mocks.queryPage = { items: [], page: 1, pageSize: 50, total: 0 };
|
||||
mocks.confirm.mockResolvedValue(true);
|
||||
mocks.createExternalAdminUser.mockResolvedValue(externalUserFixture());
|
||||
mocks.listExternalAdminPermissionCatalog.mockResolvedValue(permissionCatalogFixture());
|
||||
mocks.getExternalAdminUserPermissions.mockResolvedValue({
|
||||
accountId: "71",
|
||||
permissions: ["user:list"],
|
||||
revision: 4,
|
||||
});
|
||||
mocks.invalidateQueries.mockResolvedValue(undefined);
|
||||
mocks.lookupExternalAdminUserTarget.mockResolvedValue(targetFixture());
|
||||
mocks.reload.mockResolvedValue(undefined);
|
||||
mocks.resetExternalAdminUserPassword.mockResolvedValue(externalUserFixture());
|
||||
mocks.updateExternalAdminUserStatus.mockResolvedValue({ ...externalUserFixture(), status: "disabled" });
|
||||
mocks.updateExternalAdminUserPermissions.mockResolvedValue(externalUserFixture());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@ -88,6 +121,7 @@ test("creates an App-scoped account with the canonical userId returned by lookup
|
||||
expect(mocks.lookupExternalAdminUserTarget).toHaveBeenCalledWith("fami", "VIP-1001");
|
||||
expect(mocks.createExternalAdminUser).toHaveBeenCalledWith("fami", {
|
||||
password: "StrongPass123!",
|
||||
permissions: ["user:list", "user:update"],
|
||||
targetUserId: "internal-user-1001",
|
||||
username: "ops.fami",
|
||||
});
|
||||
@ -95,6 +129,67 @@ test("creates an App-scoped account with the canonical userId returned by lookup
|
||||
expect(result.current.createOpen).toBe(false);
|
||||
});
|
||||
|
||||
test("creates an explicit zero-permission account after the operator clears defaults", async () => {
|
||||
const { result } = renderHook(() => useExternalAdminUsersPage());
|
||||
|
||||
act(() => result.current.openCreate());
|
||||
act(() => result.current.setCreateForm({ permissions: [] }));
|
||||
act(() => result.current.setDisplayUserId("VIP-1001"));
|
||||
await act(async () => result.current.lookupTarget());
|
||||
act(() =>
|
||||
result.current.setCreateForm({
|
||||
confirmPassword: "StrongPass123!",
|
||||
password: "StrongPass123!",
|
||||
username: "ops.zero",
|
||||
}),
|
||||
);
|
||||
await act(async () => result.current.submitCreate({ preventDefault: vi.fn() }));
|
||||
|
||||
expect(mocks.createExternalAdminUser).toHaveBeenCalledWith("fami", {
|
||||
password: "StrongPass123!",
|
||||
permissions: [],
|
||||
targetUserId: "internal-user-1001",
|
||||
username: "ops.zero",
|
||||
});
|
||||
});
|
||||
|
||||
test("does not request the permission catalog without the configure-permissions grant", () => {
|
||||
mocks.abilities = { ...mocks.abilities, canCreate: false, canManagePermissions: false };
|
||||
|
||||
renderHook(() => useExternalAdminUsersPage());
|
||||
|
||||
expect(mocks.catalogQueryOptions.enabled).toBe(false);
|
||||
expect(mocks.listExternalAdminPermissionCatalog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("treats an empty successful catalog as unavailable and blocks create and update writes", async () => {
|
||||
mocks.catalogPage = { items: [], total: 0 };
|
||||
const item = externalUserFixture();
|
||||
const { result } = renderHook(() => useExternalAdminUsersPage());
|
||||
|
||||
expect(result.current.catalogError).toBe("外管权限目录为空,请联系管理员");
|
||||
act(() => result.current.openCreate());
|
||||
act(() => result.current.setDisplayUserId("VIP-1001"));
|
||||
await act(async () => result.current.lookupTarget());
|
||||
act(() =>
|
||||
result.current.setCreateForm({
|
||||
confirmPassword: "StrongPass123!",
|
||||
password: "StrongPass123!",
|
||||
username: "ops.empty",
|
||||
}),
|
||||
);
|
||||
await act(async () => result.current.submitCreate({ preventDefault: vi.fn() }));
|
||||
expect(mocks.createExternalAdminUser).not.toHaveBeenCalled();
|
||||
|
||||
act(() => result.current.closeCreate());
|
||||
act(() => result.current.openPermissions(item));
|
||||
await waitFor(() => expect(result.current.permissionLoading).toBe(false));
|
||||
await act(async () => result.current.submitPermissions());
|
||||
|
||||
expect(mocks.updateExternalAdminUserPermissions).not.toHaveBeenCalled();
|
||||
expect(mocks.showToast).toHaveBeenCalledWith("外管权限目录为空,请联系管理员", "error");
|
||||
});
|
||||
|
||||
test("clears list filters, lookup results and password form when App scope changes", async () => {
|
||||
const { result, rerender } = renderHook(() => useExternalAdminUsersPage());
|
||||
|
||||
@ -112,13 +207,76 @@ test("clears list filters, lookup results and password form when App scope chang
|
||||
await waitFor(() => expect(result.current.keyword).toBe(""));
|
||||
expect(result.current.status).toBe("");
|
||||
expect(result.current.createOpen).toBe(false);
|
||||
expect(result.current.createForm).toEqual({ confirmPassword: "", displayUserId: "", password: "", username: "" });
|
||||
expect(result.current.createForm).toEqual({ confirmPassword: "", displayUserId: "", password: "", permissions: [], username: "" });
|
||||
expect(result.current.targetUser).toBeNull();
|
||||
expect(result.current.passwordUser).toBeNull();
|
||||
expect(result.current.passwordForm).toEqual({ confirmPassword: "", password: "" });
|
||||
expect(mocks.queryOptions.queryKey[1]).toBe("lalu");
|
||||
});
|
||||
|
||||
test("loads and replaces an account permission snapshot, including explicit zero permissions", async () => {
|
||||
const item = externalUserFixture();
|
||||
const { result } = renderHook(() => useExternalAdminUsersPage());
|
||||
|
||||
act(() => result.current.openPermissions(item));
|
||||
await waitFor(() => expect(result.current.permissionForm.permissions).toEqual(["user:list"]));
|
||||
expect(mocks.getExternalAdminUserPermissions).toHaveBeenCalledWith("fami", 71);
|
||||
|
||||
act(() => result.current.setPermissions([]));
|
||||
await act(async () => result.current.submitPermissions());
|
||||
|
||||
expect(mocks.updateExternalAdminUserPermissions).toHaveBeenCalledWith("fami", 71, [], 4);
|
||||
expect(mocks.showToast).toHaveBeenCalledWith("权限已更新,该账号需重新登录", "success");
|
||||
expect(result.current.permissionUser).toBeNull();
|
||||
});
|
||||
|
||||
test("keeps the permission drawer open and offers reload after a concurrent update", async () => {
|
||||
const conflict = Object.assign(new Error("conflict"), { status: 409 });
|
||||
mocks.updateExternalAdminUserPermissions.mockRejectedValueOnce(conflict);
|
||||
const item = externalUserFixture();
|
||||
const { result } = renderHook(() => useExternalAdminUsersPage());
|
||||
|
||||
act(() => result.current.openPermissions(item));
|
||||
await waitFor(() => expect(result.current.permissionLoading).toBe(false));
|
||||
await act(async () => result.current.submitPermissions());
|
||||
|
||||
expect(result.current.permissionUser).toEqual(item);
|
||||
expect(result.current.permissionError).toBe("账号权限已被更新,请重新加载");
|
||||
expect(mocks.showToast).toHaveBeenCalledWith("账号权限已被更新,请重新加载", "error");
|
||||
|
||||
mocks.getExternalAdminUserPermissions.mockResolvedValueOnce({
|
||||
accountId: "71",
|
||||
permissions: ["user:list", "user:update"],
|
||||
revision: 5,
|
||||
});
|
||||
act(() => result.current.retryPermissions());
|
||||
await waitFor(() => expect(result.current.permissionForm.expectedRevision).toBe(5));
|
||||
await act(async () => result.current.submitPermissions());
|
||||
|
||||
expect(mocks.updateExternalAdminUserPermissions).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"fami",
|
||||
71,
|
||||
["user:list", "user:update"],
|
||||
5,
|
||||
);
|
||||
expect(result.current.permissionUser).toBeNull();
|
||||
});
|
||||
|
||||
test("does not expose backend permission codes when a non-conflict update fails", async () => {
|
||||
mocks.updateExternalAdminUserPermissions.mockRejectedValueOnce(
|
||||
new Error("privilege:grant requires user-title:grant"),
|
||||
);
|
||||
const { result } = renderHook(() => useExternalAdminUsersPage());
|
||||
|
||||
act(() => result.current.openPermissions(externalUserFixture()));
|
||||
await waitFor(() => expect(result.current.permissionLoading).toBe(false));
|
||||
await act(async () => result.current.submitPermissions());
|
||||
|
||||
expect(result.current.permissionError).toBe("更新外管权限失败");
|
||||
expect(mocks.showToast).toHaveBeenCalledWith("更新外管权限失败", "error");
|
||||
});
|
||||
|
||||
test("ignores a late user lookup response after switching App", async () => {
|
||||
let resolveLookup;
|
||||
mocks.lookupExternalAdminUserTarget.mockImplementationOnce(
|
||||
@ -184,8 +342,20 @@ function externalUserFixture() {
|
||||
lastLoginAtMs: 1784077200000,
|
||||
linkedUser: targetFixture(),
|
||||
permissions: [],
|
||||
permissionRevision: 4,
|
||||
status: "active",
|
||||
updatedAtMs: 1784077200000,
|
||||
username: "ops.fami",
|
||||
};
|
||||
}
|
||||
|
||||
function permissionCatalogFixture() {
|
||||
return {
|
||||
items: [
|
||||
{ capabilities: ["user:list"], code: "user:list", defaultGranted: true, dependencies: [], group: "用户管理", label: "用户列表" },
|
||||
{ capabilities: ["user:list", "user:update"], code: "user:update", defaultGranted: true, dependencies: ["user:list"], group: "用户管理", label: "编辑用户" },
|
||||
{ capabilities: ["room:list"], code: "room:list", defaultGranted: false, dependencies: [], group: "房间管理", label: "房间列表" },
|
||||
],
|
||||
total: 3,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import PasswordOutlined from "@mui/icons-material/PasswordOutlined";
|
||||
import PersonAddAltOutlined from "@mui/icons-material/PersonAddAltOutlined";
|
||||
import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined";
|
||||
import AdminPanelSettingsOutlined from "@mui/icons-material/AdminPanelSettingsOutlined";
|
||||
import { ExternalAdminPermissionDrawer } from "@/features/external-admin-users/components/ExternalAdminPermissionDrawer.jsx";
|
||||
import { ExternalAdminUserFormDialog } from "@/features/external-admin-users/components/ExternalAdminUserFormDialog.jsx";
|
||||
import { ExternalAdminUserPasswordDialog } from "@/features/external-admin-users/components/ExternalAdminUserPasswordDialog.jsx";
|
||||
import styles from "@/features/external-admin-users/external-admin-users.module.css";
|
||||
@ -56,7 +58,7 @@ const columns = [
|
||||
label: "状态",
|
||||
render: (item, _index, context) => {
|
||||
const page = context?.page;
|
||||
// 外管状态和密码都是账号级敏感写操作;同页串行化可避免两个请求互相覆盖全局 loading 状态。
|
||||
// 外管状态、密码和权限都是账号级敏感写操作;同页串行化可避免多个请求互相覆盖全局 loading 状态。
|
||||
const accountActionInFlight = Boolean(page?.loadingAction);
|
||||
return (
|
||||
<AdminSwitch
|
||||
@ -74,7 +76,7 @@ const columns = [
|
||||
{
|
||||
key: "permissions",
|
||||
label: "外管权限",
|
||||
render: (item) => (item.permissions?.length ? `${item.permissions.length} 项` : "-"),
|
||||
render: (item) => `${item.permissions?.length || 0} 项`,
|
||||
width: "minmax(120px, 0.65fr)",
|
||||
},
|
||||
{
|
||||
@ -102,6 +104,13 @@ const columns = [
|
||||
const page = context?.page;
|
||||
return (
|
||||
<AdminRowActions>
|
||||
<AdminActionIconButton
|
||||
disabled={!page?.abilities.canManagePermissions || Boolean(page.loadingAction)}
|
||||
label="配置权限"
|
||||
onClick={() => page.openPermissions(item)}
|
||||
>
|
||||
<AdminPanelSettingsOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
<AdminActionIconButton
|
||||
disabled={!page?.abilities.canResetPassword || Boolean(page.loadingAction)}
|
||||
label="重置密码"
|
||||
@ -113,7 +122,7 @@ const columns = [
|
||||
);
|
||||
},
|
||||
resizable: false,
|
||||
width: "88px",
|
||||
width: "132px",
|
||||
},
|
||||
];
|
||||
|
||||
@ -192,6 +201,9 @@ export function ExternalAdminUsersPage() {
|
||||
</DataState>
|
||||
<ExternalAdminUserFormDialog
|
||||
abilities={page.abilities}
|
||||
catalog={page.permissionCatalog.items || []}
|
||||
catalogError={page.catalogError}
|
||||
catalogLoading={page.catalogLoading}
|
||||
form={page.createForm}
|
||||
loading={page.loadingAction === "create"}
|
||||
lookupLoading={page.lookupLoading}
|
||||
@ -201,8 +213,25 @@ export function ExternalAdminUsersPage() {
|
||||
onDisplayUserIdChange={page.setDisplayUserId}
|
||||
onFormChange={page.setCreateForm}
|
||||
onLookup={page.lookupTarget}
|
||||
onReloadCatalog={page.reloadPermissionCatalog}
|
||||
onSubmit={page.submitCreate}
|
||||
/>
|
||||
<ExternalAdminPermissionDrawer
|
||||
abilities={page.abilities}
|
||||
catalog={page.permissionCatalog.items || []}
|
||||
catalogError={page.catalogError}
|
||||
catalogLoading={page.catalogLoading}
|
||||
error={page.permissionError}
|
||||
loading={page.loadingAction.startsWith("permissions:")}
|
||||
permissions={page.permissionForm.permissions}
|
||||
permissionsLoading={page.permissionLoading}
|
||||
user={page.permissionUser}
|
||||
onChange={page.setPermissions}
|
||||
onClose={page.closePermissions}
|
||||
onReloadCatalog={page.reloadPermissionCatalog}
|
||||
onRetry={page.retryPermissions}
|
||||
onSubmit={page.submitPermissions}
|
||||
/>
|
||||
<ExternalAdminUserPasswordDialog
|
||||
abilities={page.abilities}
|
||||
form={page.passwordForm}
|
||||
|
||||
@ -0,0 +1,97 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, expect, test, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ openPermissions: vi.fn(), page: null }));
|
||||
|
||||
vi.mock("@/features/external-admin-users/hooks/useExternalAdminUsersPage.js", () => ({
|
||||
useExternalAdminUsersPage: () => mocks.page,
|
||||
}));
|
||||
|
||||
import { ExternalAdminUsersPage } from "./ExternalAdminUsersPage.jsx";
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.openPermissions.mockReset();
|
||||
mocks.page = pageFixture();
|
||||
});
|
||||
|
||||
test("exposes a dedicated row action for configuring external account permissions", () => {
|
||||
render(<ExternalAdminUsersPage />);
|
||||
|
||||
const button = screen.getByRole("button", { name: "配置权限" });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(mocks.openPermissions).toHaveBeenCalledWith(mocks.page.data.items[0]);
|
||||
expect(screen.getByText("2 项")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("disables permission configuration without the main-admin grant ability", () => {
|
||||
mocks.page.abilities.canManagePermissions = false;
|
||||
render(<ExternalAdminUsersPage />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "配置权限" })).toBeDisabled();
|
||||
});
|
||||
|
||||
function pageFixture() {
|
||||
const item = {
|
||||
appCode: "fami",
|
||||
createdAtMs: 1784073600000,
|
||||
createdByAdminId: 1,
|
||||
id: 71,
|
||||
linkedUser: { displayUserId: "123456", userId: "user-1", username: "fami123456" },
|
||||
permissions: ["user:list", "user:update"],
|
||||
permissionRevision: 3,
|
||||
status: "active",
|
||||
username: "123456",
|
||||
};
|
||||
return {
|
||||
abilities: {
|
||||
canCreate: true,
|
||||
canManagePermissions: true,
|
||||
canResetPassword: true,
|
||||
canStatus: true,
|
||||
canView: true,
|
||||
},
|
||||
catalogError: "",
|
||||
catalogLoading: false,
|
||||
closeCreate: vi.fn(),
|
||||
closePassword: vi.fn(),
|
||||
closePermissions: vi.fn(),
|
||||
createForm: { confirmPassword: "", displayUserId: "", password: "", permissions: [], username: "" },
|
||||
createOpen: false,
|
||||
data: { items: [item], page: 1, pageSize: 50, total: 1 },
|
||||
error: "",
|
||||
keyword: "",
|
||||
loading: false,
|
||||
loadingAction: "",
|
||||
lookupLoading: false,
|
||||
lookupTarget: vi.fn(),
|
||||
openCreate: vi.fn(),
|
||||
openPassword: vi.fn(),
|
||||
openPermissions: mocks.openPermissions,
|
||||
page: 1,
|
||||
passwordForm: { confirmPassword: "", password: "" },
|
||||
passwordUser: null,
|
||||
permissionCatalog: { items: [], total: 0 },
|
||||
permissionError: "",
|
||||
permissionForm: { expectedRevision: null, permissions: [] },
|
||||
permissionLoading: false,
|
||||
permissionUser: null,
|
||||
reload: vi.fn(),
|
||||
reloadPermissionCatalog: vi.fn(),
|
||||
resetFilters: vi.fn(),
|
||||
retryPermissions: vi.fn(),
|
||||
setCreateForm: vi.fn(),
|
||||
setDisplayUserId: vi.fn(),
|
||||
setKeyword: vi.fn(),
|
||||
setPage: vi.fn(),
|
||||
setPasswordForm: vi.fn(),
|
||||
setPermissions: vi.fn(),
|
||||
setStatus: vi.fn(),
|
||||
status: "",
|
||||
submitCreate: vi.fn(),
|
||||
submitPassword: vi.fn(),
|
||||
submitPermissions: vi.fn(),
|
||||
targetUser: null,
|
||||
toggleStatus: vi.fn(),
|
||||
};
|
||||
}
|
||||
58
src/features/external-admin-users/permissionSelection.js
Normal file
58
src/features/external-admin-users/permissionSelection.js
Normal file
@ -0,0 +1,58 @@
|
||||
export function defaultPermissionSelection(catalog) {
|
||||
return catalog
|
||||
.filter((permission) => permission.defaultGranted)
|
||||
.reduce(
|
||||
(selected, permission) => updatePermissionSelection(catalog, selected, permission.code, true),
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
export function updatePermissionSelection(catalog, current, code, checked) {
|
||||
const permissionMap = new Map(catalog.map((permission) => [permission.code, permission]));
|
||||
const selected = new Set(current.filter((permissionCode) => permissionMap.has(permissionCode)));
|
||||
|
||||
if (checked) {
|
||||
// 目录依赖由后端维护;勾选业务动作时递归补齐前置能力,保证提交的是可执行权限快照。
|
||||
addWithDependencies(code, permissionMap, selected, new Set());
|
||||
} else {
|
||||
selected.delete(code);
|
||||
// 取消前置能力后同步移除所有失去依赖的动作,避免保存一个界面无法真实使用的组合。
|
||||
removeBrokenDependents(permissionMap, selected);
|
||||
}
|
||||
|
||||
return catalog.map((permission) => permission.code).filter((permissionCode) => selected.has(permissionCode));
|
||||
}
|
||||
|
||||
export function updatePermissionGroup(catalog, current, groupCodes, checked) {
|
||||
return groupCodes.reduce(
|
||||
(next, code) => updatePermissionSelection(catalog, next, code, checked),
|
||||
current,
|
||||
);
|
||||
}
|
||||
|
||||
function addWithDependencies(code, permissionMap, selected, visiting) {
|
||||
if (!permissionMap.has(code) || visiting.has(code)) {
|
||||
return;
|
||||
}
|
||||
visiting.add(code);
|
||||
const permission = permissionMap.get(code);
|
||||
(permission.dependencies || []).forEach((dependency) =>
|
||||
addWithDependencies(dependency, permissionMap, selected, visiting),
|
||||
);
|
||||
selected.add(code);
|
||||
visiting.delete(code);
|
||||
}
|
||||
|
||||
function removeBrokenDependents(permissionMap, selected) {
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
selected.forEach((code) => {
|
||||
const dependencies = permissionMap.get(code)?.dependencies || [];
|
||||
if (dependencies.some((dependency) => !selected.has(dependency))) {
|
||||
selected.delete(code);
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -3,9 +3,14 @@ import { PERMISSIONS } from "@/app/permissions";
|
||||
|
||||
export function useExternalAdminUserAbilities() {
|
||||
const { can } = useAuth();
|
||||
const canCreateAccount = can(PERMISSIONS.externalAdminUserCreate);
|
||||
const canManagePermissions = can(PERMISSIONS.externalAdminUserPermissions);
|
||||
|
||||
return {
|
||||
canCreate: can(PERMISSIONS.externalAdminUserCreate),
|
||||
// 创建必须同时能配置权限,禁止通过“省略权限字段使用默认值”绕过独立授权边界。
|
||||
canCreate: canCreateAccount && canManagePermissions,
|
||||
canCreateAccount,
|
||||
canManagePermissions,
|
||||
canResetPassword: can(PERMISSIONS.externalAdminUserResetPassword),
|
||||
canStatus: can(PERMISSIONS.externalAdminUserStatus),
|
||||
canView: can(PERMISSIONS.externalAdminUserView),
|
||||
|
||||
28
src/features/external-admin-users/permissions.test.jsx
Normal file
28
src/features/external-admin-users/permissions.test.jsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { beforeEach, expect, test, vi } from "vitest";
|
||||
import { PERMISSIONS } from "@/app/permissions";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ granted: new Set() }));
|
||||
|
||||
vi.mock("@/app/auth/AuthProvider.jsx", () => ({
|
||||
useAuth: () => ({ can: (permission) => mocks.granted.has(permission) }),
|
||||
}));
|
||||
|
||||
import { useExternalAdminUserAbilities } from "./permissions.js";
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.granted = new Set();
|
||||
});
|
||||
|
||||
test("requires both account creation and permission configuration grants to create", () => {
|
||||
mocks.granted.add(PERMISSIONS.externalAdminUserCreate);
|
||||
const { result, rerender } = renderHook(() => useExternalAdminUserAbilities());
|
||||
|
||||
expect(result.current.canCreateAccount).toBe(true);
|
||||
expect(result.current.canManagePermissions).toBe(false);
|
||||
expect(result.current.canCreate).toBe(false);
|
||||
|
||||
mocks.granted.add(PERMISSIONS.externalAdminUserPermissions);
|
||||
rerender();
|
||||
expect(result.current.canCreate).toBe(true);
|
||||
});
|
||||
@ -3,6 +3,7 @@ import {
|
||||
externalAdminUserCreateFormSchema,
|
||||
externalAdminUserLookupSchema,
|
||||
externalAdminUserPasswordFormSchema,
|
||||
externalAdminUserPermissionsFormSchema,
|
||||
} from "./schema";
|
||||
|
||||
describe("external admin user schemas", () => {
|
||||
@ -62,4 +63,16 @@ describe("external admin user schemas", () => {
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("preserves an explicit zero-permission snapshot and removes duplicate codes", () => {
|
||||
expect(
|
||||
externalAdminUserPermissionsFormSchema.parse({ expectedRevision: 4, permissions: [] }),
|
||||
).toEqual({ expectedRevision: 4, permissions: [] });
|
||||
expect(
|
||||
externalAdminUserPermissionsFormSchema.parse({
|
||||
expectedRevision: 4,
|
||||
permissions: ["user:list", "user:list", "room:list"],
|
||||
}),
|
||||
).toEqual({ expectedRevision: 4, permissions: ["user:list", "room:list"] });
|
||||
});
|
||||
});
|
||||
|
||||
@ -16,6 +16,17 @@ const usernameSchema = z
|
||||
// 外管系统使用独立登录标识;限制为跨浏览器、网关和审计日志均稳定的 ASCII 账号字符集。
|
||||
.regex(/^[A-Za-z0-9._-]+$/, "外管账号只能包含字母、数字、点、下划线和连字符");
|
||||
|
||||
const permissionCodeSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "权限编码不能为空")
|
||||
.max(128, "权限编码不能超过 128 个字符")
|
||||
.refine((value) => !/[\s\p{Cc}]/u.test(value), "权限编码不能包含空白或控制字符");
|
||||
|
||||
const permissionsSchema = z.array(permissionCodeSchema).max(200, "权限数量不能超过 200 项").transform((items) => [
|
||||
...new Set(items),
|
||||
]);
|
||||
|
||||
// 密码属于登录凭证,不能 trim 或自动改写;前端只做长度与确认值校验,服务端会重复校验并仅持久化密码摘要。
|
||||
const passwordSchema = z
|
||||
.string()
|
||||
@ -32,6 +43,7 @@ export const externalAdminUserCreateFormSchema = z
|
||||
confirmPassword: passwordSchema,
|
||||
displayUserId: displayUserIdSchema,
|
||||
password: passwordSchema,
|
||||
permissions: permissionsSchema.optional(),
|
||||
username: usernameSchema,
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
@ -57,7 +69,15 @@ export const externalAdminUserStatusSchema = z.object({
|
||||
status: z.enum(["active", "disabled"]),
|
||||
});
|
||||
|
||||
export const externalAdminUserPermissionsFormSchema = z.object({
|
||||
expectedRevision: z.number().int().positive("权限版本不正确"),
|
||||
// 空数组是明确的零权限快照,不能用默认权限替换,也不能在提交前过滤掉该字段。
|
||||
permissions: permissionsSchema,
|
||||
});
|
||||
|
||||
export type ExternalAdminUserCreateForm = z.input<typeof externalAdminUserCreateFormSchema>;
|
||||
export type ExternalAdminUserCreatePayload = z.output<typeof externalAdminUserCreateFormSchema>;
|
||||
export type ExternalAdminUserPasswordForm = z.input<typeof externalAdminUserPasswordFormSchema>;
|
||||
export type ExternalAdminUserPasswordPayload = z.output<typeof externalAdminUserPasswordFormSchema>;
|
||||
export type ExternalAdminUserPermissionsForm = z.input<typeof externalAdminUserPermissionsFormSchema>;
|
||||
export type ExternalAdminUserPermissionsPayload = z.output<typeof externalAdminUserPermissionsFormSchema>;
|
||||
|
||||
@ -136,6 +136,7 @@ export const API_OPERATIONS = {
|
||||
getCPConfig: "getCPConfig",
|
||||
getCPWeeklyRankConfig: "getCPWeeklyRankConfig",
|
||||
getCumulativeRechargeRewardConfig: "getCumulativeRechargeRewardConfig",
|
||||
getExternalAdminUserPermissions: "getExternalAdminUserPermissions",
|
||||
getFinanceCoinSellerRechargeExchangeRate: "getFinanceCoinSellerRechargeExchangeRate",
|
||||
getFinanceScope: "getFinanceScope",
|
||||
getFinanceUSDTAddresses: "getFinanceUSDTAddresses",
|
||||
@ -211,6 +212,7 @@ export const API_OPERATIONS = {
|
||||
listEmojiPackCategories: "listEmojiPackCategories",
|
||||
listEmojiPacks: "listEmojiPacks",
|
||||
listExploreTabs: "listExploreTabs",
|
||||
listExternalAdminPermissionCatalog: "listExternalAdminPermissionCatalog",
|
||||
listExternalAdminUsers: "listExternalAdminUsers",
|
||||
listFinanceCoinSellerRechargeOrders: "listFinanceCoinSellerRechargeOrders",
|
||||
listFinanceScopeAssignments: "listFinanceScopeAssignments",
|
||||
@ -346,6 +348,7 @@ export const API_OPERATIONS = {
|
||||
updateCumulativeRechargeRewardConfig: "updateCumulativeRechargeRewardConfig",
|
||||
updateDiceConfig: "updateDiceConfig",
|
||||
updateExploreTab: "updateExploreTab",
|
||||
updateExternalAdminUserPermissions: "updateExternalAdminUserPermissions",
|
||||
updateExternalAdminUserStatus: "updateExternalAdminUserStatus",
|
||||
updateFirstRechargeRewardConfig: "updateFirstRechargeRewardConfig",
|
||||
updateGift: "updateGift",
|
||||
@ -684,7 +687,7 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
operationId: API_OPERATIONS.createExternalAdminUser,
|
||||
path: "/v1/admin/external-admin-users",
|
||||
permission: "external-admin-user:create",
|
||||
permissions: ["external-admin-user:create"]
|
||||
permissions: ["external-admin-user:create","external-admin-user:permissions"]
|
||||
},
|
||||
createFinanceCoinSellerRechargeOrder: {
|
||||
method: "POST",
|
||||
@ -1267,6 +1270,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "cumulative-recharge-reward:view",
|
||||
permissions: ["cumulative-recharge-reward:view"]
|
||||
},
|
||||
getExternalAdminUserPermissions: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.getExternalAdminUserPermissions,
|
||||
path: "/v1/admin/external-admin-users/{id}/permissions",
|
||||
permission: "external-admin-user:permissions",
|
||||
permissions: ["external-admin-user:permissions"]
|
||||
},
|
||||
getFinanceCoinSellerRechargeExchangeRate: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.getFinanceCoinSellerRechargeExchangeRate,
|
||||
@ -1790,6 +1800,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "app-config:view",
|
||||
permissions: ["app-config:view"]
|
||||
},
|
||||
listExternalAdminPermissionCatalog: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listExternalAdminPermissionCatalog,
|
||||
path: "/v1/admin/external-admin-users/permission-catalog",
|
||||
permission: "external-admin-user:permissions",
|
||||
permissions: ["external-admin-user:permissions"]
|
||||
},
|
||||
listExternalAdminUsers: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listExternalAdminUsers,
|
||||
@ -2721,6 +2738,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "app-config:update",
|
||||
permissions: ["app-config:update"]
|
||||
},
|
||||
updateExternalAdminUserPermissions: {
|
||||
method: "PUT",
|
||||
operationId: API_OPERATIONS.updateExternalAdminUserPermissions,
|
||||
path: "/v1/admin/external-admin-users/{id}/permissions",
|
||||
permission: "external-admin-user:permissions",
|
||||
permissions: ["external-admin-user:permissions"]
|
||||
},
|
||||
updateExternalAdminUserStatus: {
|
||||
method: "PATCH",
|
||||
operationId: API_OPERATIONS.updateExternalAdminUserStatus,
|
||||
|
||||
132
src/shared/api/generated/schema.d.ts
vendored
132
src/shared/api/generated/schema.d.ts
vendored
@ -3612,6 +3612,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/external-admin-users/permission-catalog": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["listExternalAdminPermissionCatalog"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/external-admin-users/target": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -3660,6 +3676,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/external-admin-users/{id}/permissions": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getExternalAdminUserPermissions"];
|
||||
put: operations["updateExternalAdminUserPermissions"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/exports/app-users": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -4796,6 +4828,12 @@ export interface components {
|
||||
ApiResponseExternalAdminUserTarget: components["schemas"]["Envelope"] & {
|
||||
data?: components["schemas"]["ExternalAdminUserTargetLookup"];
|
||||
};
|
||||
ApiResponseExternalAdminPermissionCatalog: components["schemas"]["Envelope"] & {
|
||||
data?: components["schemas"]["ExternalAdminPermissionCatalog"];
|
||||
};
|
||||
ApiResponseExternalAdminUserPermissions: components["schemas"]["Envelope"] & {
|
||||
data?: components["schemas"]["ExternalAdminUserPermissions"];
|
||||
};
|
||||
ApiPageExternalAdminUser: {
|
||||
items: components["schemas"]["ExternalAdminUser"][];
|
||||
page: number;
|
||||
@ -4825,6 +4863,8 @@ export interface components {
|
||||
status: "active" | "disabled";
|
||||
linkedUser: components["schemas"]["ExternalAdminUserTarget"];
|
||||
permissions: string[];
|
||||
/** Format: int64 */
|
||||
permissionRevision: number;
|
||||
createdByAdminId?: number;
|
||||
/** Format: int64 */
|
||||
lastLoginAtMs?: number | null;
|
||||
@ -4837,6 +4877,30 @@ export interface components {
|
||||
targetUserId: string;
|
||||
username: string;
|
||||
password: string;
|
||||
permissions?: string[];
|
||||
};
|
||||
ExternalAdminPermissionCatalogItem: {
|
||||
code: string;
|
||||
label: string;
|
||||
group: string;
|
||||
dependencies: string[];
|
||||
capabilities: string[];
|
||||
defaultGranted: boolean;
|
||||
};
|
||||
ExternalAdminPermissionCatalog: {
|
||||
items: components["schemas"]["ExternalAdminPermissionCatalogItem"][];
|
||||
total: number;
|
||||
};
|
||||
ExternalAdminUserPermissions: {
|
||||
accountId: string;
|
||||
permissions: string[];
|
||||
/** Format: int64 */
|
||||
revision: number;
|
||||
};
|
||||
ExternalAdminUserPermissionsInput: {
|
||||
permissions: string[];
|
||||
/** Format: int64 */
|
||||
expectedRevision: number;
|
||||
};
|
||||
ExternalAdminUserStatusInput: {
|
||||
/** @enum {string} */
|
||||
@ -7030,6 +7094,24 @@ export interface components {
|
||||
"application/json": components["schemas"]["ApiResponseExternalAdminUserTarget"];
|
||||
};
|
||||
};
|
||||
/** @description External admin permission catalog */
|
||||
ExternalAdminPermissionCatalogResponse: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ApiResponseExternalAdminPermissionCatalog"];
|
||||
};
|
||||
};
|
||||
/** @description External admin account permissions */
|
||||
ExternalAdminUserPermissionsResponse: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ApiResponseExternalAdminUserPermissions"];
|
||||
};
|
||||
};
|
||||
/** @description OK */
|
||||
AgencyPageResponse: {
|
||||
headers: {
|
||||
@ -12214,6 +12296,20 @@ export interface operations {
|
||||
201: components["responses"]["ExternalAdminUserResponse"];
|
||||
};
|
||||
};
|
||||
listExternalAdminPermissionCatalog: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header: {
|
||||
"X-App-Code": components["parameters"]["AppCodeHeader"];
|
||||
};
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["ExternalAdminPermissionCatalogResponse"];
|
||||
};
|
||||
};
|
||||
lookupExternalAdminUserTarget: {
|
||||
parameters: {
|
||||
query: {
|
||||
@ -12270,6 +12366,42 @@ export interface operations {
|
||||
200: components["responses"]["ExternalAdminUserResponse"];
|
||||
};
|
||||
};
|
||||
getExternalAdminUserPermissions: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header: {
|
||||
"X-App-Code": components["parameters"]["AppCodeHeader"];
|
||||
};
|
||||
path: {
|
||||
id: components["parameters"]["Id"];
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["ExternalAdminUserPermissionsResponse"];
|
||||
};
|
||||
};
|
||||
updateExternalAdminUserPermissions: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header: {
|
||||
"X-App-Code": components["parameters"]["AppCodeHeader"];
|
||||
};
|
||||
path: {
|
||||
id: components["parameters"]["Id"];
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ExternalAdminUserPermissionsInput"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
200: components["responses"]["ExternalAdminUserResponse"];
|
||||
};
|
||||
};
|
||||
appExportUsers: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
||||
@ -84,3 +84,17 @@ test("does not replay mutations after a network failure", async () => {
|
||||
await expect(apiRequest("/v1/admin/countries", { body: {}, method: "POST" })).rejects.toThrow("Failed to fetch");
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("preserves the HTTP status on API errors for optimistic concurrency handling", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ code: 409, message: "账号权限版本冲突" }), { status: 409 })
|
||||
)
|
||||
);
|
||||
|
||||
await expect(apiRequest("/v1/admin/external-admin-users/71/permissions", { body: {}, method: "PUT" })).rejects.toMatchObject({
|
||||
message: "账号权限版本冲突",
|
||||
status: 409
|
||||
});
|
||||
});
|
||||
|
||||
@ -124,19 +124,26 @@ export async function apiRequest<TData = unknown, TBody = unknown>(
|
||||
|
||||
if (raw) {
|
||||
if (!response.ok) {
|
||||
throw new Error(response.statusText || "请求失败");
|
||||
throw requestError(response.statusText || "请求失败", response.status);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
const payload = await readJSON(response);
|
||||
if (!response.ok || payload.code !== 0) {
|
||||
throw new Error(resolveErrorMessage(response, payload.message));
|
||||
throw requestError(resolveErrorMessage(response, payload.message), response.status);
|
||||
}
|
||||
|
||||
return payload.data as TData;
|
||||
}
|
||||
|
||||
function requestError(message: string, status: number) {
|
||||
const error = new Error(message) as Error & { status: number };
|
||||
// 保留 HTTP 状态供并发写入等业务分支精确判断,同时维持既有 Error message 行为。
|
||||
error.status = status;
|
||||
return error;
|
||||
}
|
||||
|
||||
function refreshOnce() {
|
||||
if (!refreshHandler) {
|
||||
return Promise.resolve(false);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user