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-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": {
|
"/admin/external-admin-users/target": {
|
||||||
@ -6240,6 +6255,46 @@
|
|||||||
"x-permissions": ["external-admin-user:reset-password"]
|
"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": {
|
"/exports/app-users": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "appExportUsers",
|
"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": {
|
"AgencyPageResponse": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
"content": {
|
"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": {
|
"ApiPageExternalAdminUser": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["items", "page", "pageSize", "total"],
|
"required": ["items", "page", "pageSize", "total"],
|
||||||
@ -9606,7 +9699,7 @@
|
|||||||
},
|
},
|
||||||
"ExternalAdminUser": {
|
"ExternalAdminUser": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["id", "appCode", "username", "status", "linkedUser", "permissions", "createdAtMs"],
|
"required": ["id", "appCode", "username", "status", "linkedUser", "permissions", "permissionRevision", "createdAtMs"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": { "type": "integer" },
|
"id": { "type": "integer" },
|
||||||
"appCode": { "type": "string" },
|
"appCode": { "type": "string" },
|
||||||
@ -9617,6 +9710,7 @@
|
|||||||
"type": "array",
|
"type": "array",
|
||||||
"items": { "type": "string" }
|
"items": { "type": "string" }
|
||||||
},
|
},
|
||||||
|
"permissionRevision": { "type": "integer", "format": "int64", "minimum": 1 },
|
||||||
"createdByAdminId": { "type": "integer" },
|
"createdByAdminId": { "type": "integer" },
|
||||||
"lastLoginAtMs": { "type": "integer", "format": "int64", "nullable": true },
|
"lastLoginAtMs": { "type": "integer", "format": "int64", "nullable": true },
|
||||||
"createdAtMs": { "type": "integer", "format": "int64" },
|
"createdAtMs": { "type": "integer", "format": "int64" },
|
||||||
@ -9635,7 +9729,72 @@
|
|||||||
"maxLength": 64,
|
"maxLength": 64,
|
||||||
"pattern": "^[A-Za-z0-9._-]+$"
|
"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": {
|
"ExternalAdminUserStatusInput": {
|
||||||
|
|||||||
@ -63,6 +63,7 @@ export const PERMISSIONS = {
|
|||||||
coinAdjustmentCreate: "coin-adjustment:create",
|
coinAdjustmentCreate: "coin-adjustment:create",
|
||||||
externalAdminUserView: "external-admin-user:view",
|
externalAdminUserView: "external-admin-user:view",
|
||||||
externalAdminUserCreate: "external-admin-user:create",
|
externalAdminUserCreate: "external-admin-user:create",
|
||||||
|
externalAdminUserPermissions: "external-admin-user:permissions",
|
||||||
externalAdminUserStatus: "external-admin-user:status",
|
externalAdminUserStatus: "external-admin-user:status",
|
||||||
externalAdminUserResetPassword: "external-admin-user:reset-password",
|
externalAdminUserResetPassword: "external-admin-user:reset-password",
|
||||||
reportView: "report:view",
|
reportView: "report:view",
|
||||||
|
|||||||
@ -2,10 +2,13 @@ import { afterEach, expect, test, vi } from "vitest";
|
|||||||
import { setAccessToken, setSelectedAppCode } from "@/shared/api/request";
|
import { setAccessToken, setSelectedAppCode } from "@/shared/api/request";
|
||||||
import {
|
import {
|
||||||
createExternalAdminUser,
|
createExternalAdminUser,
|
||||||
|
getExternalAdminUserPermissions,
|
||||||
listExternalAdminUsers,
|
listExternalAdminUsers,
|
||||||
|
listExternalAdminPermissionCatalog,
|
||||||
lookupExternalAdminUserTarget,
|
lookupExternalAdminUserTarget,
|
||||||
resetExternalAdminUserPassword,
|
resetExternalAdminUserPassword,
|
||||||
updateExternalAdminUserStatus,
|
updateExternalAdminUserStatus,
|
||||||
|
updateExternalAdminUserPermissions,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@ -14,6 +17,56 @@ afterEach(() => {
|
|||||||
vi.unstubAllGlobals();
|
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 () => {
|
test("external admin APIs pin every read and write to the explicit App scope", async () => {
|
||||||
setSelectedAppCode("lalu");
|
setSelectedAppCode("lalu");
|
||||||
const externalUser = externalUserFixture();
|
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",
|
userId: "user-1001",
|
||||||
},
|
},
|
||||||
permissions: ["user:list", "room:edit"],
|
permissions: ["user:list", "room:edit"],
|
||||||
|
permissionRevision: 3,
|
||||||
status: "active",
|
status: "active",
|
||||||
username: "ops.fami",
|
username: "ops.fami",
|
||||||
},
|
},
|
||||||
@ -114,6 +168,7 @@ function externalUserFixture() {
|
|||||||
username: "Fami user",
|
username: "Fami user",
|
||||||
},
|
},
|
||||||
permissions: ["user:list", "room:edit"],
|
permissions: ["user:list", "room:edit"],
|
||||||
|
permission_revision: 3,
|
||||||
status: "active",
|
status: "active",
|
||||||
updated_at_ms: "1784077200000",
|
updated_at_ms: "1784077200000",
|
||||||
username: "ops.fami",
|
username: "ops.fami",
|
||||||
|
|||||||
@ -17,6 +17,7 @@ export interface ExternalAdminUserDto {
|
|||||||
lastLoginAtMs?: number;
|
lastLoginAtMs?: number;
|
||||||
linkedUser: ExternalAdminLinkedUserDto;
|
linkedUser: ExternalAdminLinkedUserDto;
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
|
permissionRevision: number;
|
||||||
status: ExternalAdminUserStatus;
|
status: ExternalAdminUserStatus;
|
||||||
updatedAtMs?: number;
|
updatedAtMs?: number;
|
||||||
username: string;
|
username: string;
|
||||||
@ -26,8 +27,29 @@ export interface ExternalAdminUserTargetDto extends ExternalAdminLinkedUserDto {
|
|||||||
existingExternalAdminUser?: ExternalAdminUserDto;
|
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 {
|
export interface CreateExternalAdminUserPayload {
|
||||||
password: string;
|
password: string;
|
||||||
|
permissions?: string[];
|
||||||
targetUserId: string;
|
targetUserId: string;
|
||||||
username: string;
|
username: string;
|
||||||
}
|
}
|
||||||
@ -58,6 +80,32 @@ export async function lookupExternalAdminUserTarget(
|
|||||||
return normalizeTarget(data);
|
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(
|
export async function createExternalAdminUser(
|
||||||
appCode: string,
|
appCode: string,
|
||||||
payload: CreateExternalAdminUserPayload,
|
payload: CreateExternalAdminUserPayload,
|
||||||
@ -108,6 +156,24 @@ export async function resetExternalAdminUserPassword(
|
|||||||
return normalizeExternalAdminUser(data);
|
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) {
|
function appScopeHeaders(appCode: string) {
|
||||||
// 外管账号是强 App 隔离数据;显式固定请求头,避免切换 App 后全局请求上下文把写操作送到另一租户。
|
// 外管账号是强 App 隔离数据;显式固定请求头,避免切换 App 后全局请求上下文把写操作送到另一租户。
|
||||||
return { "X-App-Code": String(appCode || "").trim().toLowerCase() };
|
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),
|
lastLoginAtMs: optionalNumber(item.lastLoginAtMs ?? item.last_login_at_ms),
|
||||||
linkedUser,
|
linkedUser,
|
||||||
permissions: stringArray(item.permissions),
|
permissions: stringArray(item.permissions),
|
||||||
|
permissionRevision: numberValue(item.permissionRevision ?? item.permission_revision),
|
||||||
status: status === "disabled" ? "disabled" : "active",
|
status: status === "disabled" ? "disabled" : "active",
|
||||||
updatedAtMs: optionalNumber(item.updatedAtMs ?? item.updated_at_ms),
|
updatedAtMs: optionalNumber(item.updatedAtMs ?? item.updated_at_ms),
|
||||||
username: stringValue(item.username ?? item.account ?? item.accountName ?? item.account_name),
|
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 {
|
function normalizeUser(raw: RawRecord): ExternalAdminLinkedUserDto {
|
||||||
const user = asRecord(raw) || {};
|
const user = asRecord(raw) || {};
|
||||||
return {
|
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";
|
} from "@/shared/ui/AdminFormDialog.jsx";
|
||||||
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||||||
import styles from "@/features/external-admin-users/external-admin-users.module.css";
|
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({
|
export function ExternalAdminUserFormDialog({
|
||||||
abilities,
|
abilities,
|
||||||
|
catalog,
|
||||||
|
catalogError,
|
||||||
|
catalogLoading,
|
||||||
form,
|
form,
|
||||||
loading,
|
loading,
|
||||||
lookupLoading,
|
lookupLoading,
|
||||||
@ -18,6 +22,7 @@ export function ExternalAdminUserFormDialog({
|
|||||||
onDisplayUserIdChange,
|
onDisplayUserIdChange,
|
||||||
onFormChange,
|
onFormChange,
|
||||||
onLookup,
|
onLookup,
|
||||||
|
onReloadCatalog,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
open,
|
open,
|
||||||
targetUser,
|
targetUser,
|
||||||
@ -30,7 +35,14 @@ export function ExternalAdminUserFormDialog({
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
open={open}
|
open={open}
|
||||||
size="large"
|
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="创建"
|
submitLabel="创建"
|
||||||
title="创建外管用户"
|
title="创建外管用户"
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
@ -108,6 +120,20 @@ export function ExternalAdminUserFormDialog({
|
|||||||
/>
|
/>
|
||||||
</AdminFormFieldGrid>
|
</AdminFormFieldGrid>
|
||||||
</AdminFormSection>
|
</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>
|
</AdminFormDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -50,6 +50,137 @@
|
|||||||
white-space: nowrap;
|
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) {
|
@media (max-width: 760px) {
|
||||||
.lookupRow {
|
.lookupRow {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@ -58,4 +189,24 @@
|
|||||||
.lookupButton {
|
.lookupButton {
|
||||||
width: 100%;
|
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 { useAppScope } from "@/app/app-scope/AppScopeProvider.jsx";
|
||||||
import {
|
import {
|
||||||
createExternalAdminUser,
|
createExternalAdminUser,
|
||||||
|
getExternalAdminUserPermissions,
|
||||||
listExternalAdminUsers,
|
listExternalAdminUsers,
|
||||||
|
listExternalAdminPermissionCatalog,
|
||||||
lookupExternalAdminUserTarget,
|
lookupExternalAdminUserTarget,
|
||||||
resetExternalAdminUserPassword,
|
resetExternalAdminUserPassword,
|
||||||
|
updateExternalAdminUserPermissions,
|
||||||
updateExternalAdminUserStatus,
|
updateExternalAdminUserStatus,
|
||||||
} from "@/features/external-admin-users/api";
|
} from "@/features/external-admin-users/api";
|
||||||
import { useExternalAdminUserAbilities } from "@/features/external-admin-users/permissions.js";
|
import { useExternalAdminUserAbilities } from "@/features/external-admin-users/permissions.js";
|
||||||
|
import { defaultPermissionSelection } from "@/features/external-admin-users/permissionSelection.js";
|
||||||
import {
|
import {
|
||||||
externalAdminUserCreateFormSchema,
|
externalAdminUserCreateFormSchema,
|
||||||
externalAdminUserLookupSchema,
|
externalAdminUserLookupSchema,
|
||||||
externalAdminUserPasswordFormSchema,
|
externalAdminUserPasswordFormSchema,
|
||||||
|
externalAdminUserPermissionsFormSchema,
|
||||||
} from "@/features/external-admin-users/schema";
|
} from "@/features/external-admin-users/schema";
|
||||||
import { toPageQuery } from "@/shared/api/query";
|
import { toPageQuery } from "@/shared/api/query";
|
||||||
import { parseForm } from "@/shared/forms/validation";
|
import { parseForm } from "@/shared/forms/validation";
|
||||||
@ -23,8 +28,9 @@ import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
|||||||
|
|
||||||
const pageSize = 50;
|
const pageSize = 50;
|
||||||
const emptyPage = { items: [], page: 1, pageSize, total: 0 };
|
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 emptyPasswordForm = () => ({ confirmPassword: "", password: "" });
|
||||||
|
const emptyPermissionCatalog = { items: [], total: 0 };
|
||||||
|
|
||||||
export function useExternalAdminUsersPage() {
|
export function useExternalAdminUsersPage() {
|
||||||
const { appCode } = useAppScope();
|
const { appCode } = useAppScope();
|
||||||
@ -34,6 +40,7 @@ export function useExternalAdminUsersPage() {
|
|||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const currentAppCodeRef = useRef(appCode);
|
const currentAppCodeRef = useRef(appCode);
|
||||||
const lookupRequestRef = useRef(0);
|
const lookupRequestRef = useRef(0);
|
||||||
|
const permissionRequestRef = useRef(0);
|
||||||
|
|
||||||
const [keyword, setKeywordState] = useState("");
|
const [keyword, setKeywordState] = useState("");
|
||||||
const [status, setStatusState] = useState("");
|
const [status, setStatusState] = useState("");
|
||||||
@ -43,10 +50,15 @@ export function useExternalAdminUsersPage() {
|
|||||||
const [createForm, setCreateFormState] = useState(emptyCreateForm);
|
const [createForm, setCreateFormState] = useState(emptyCreateForm);
|
||||||
const [targetUser, setTargetUser] = useState(null);
|
const [targetUser, setTargetUser] = useState(null);
|
||||||
const [lookupLoading, setLookupLoading] = useState(false);
|
const [lookupLoading, setLookupLoading] = useState(false);
|
||||||
|
const [createPermissionsReady, setCreatePermissionsReady] = useState(false);
|
||||||
const [passwordUser, setPasswordUser] = useState(null);
|
const [passwordUser, setPasswordUser] = useState(null);
|
||||||
const [passwordForm, setPasswordFormState] = useState(emptyPasswordForm);
|
const [passwordForm, setPasswordFormState] = useState(emptyPasswordForm);
|
||||||
const [actionAppCode, setActionAppCode] = useState("");
|
const [actionAppCode, setActionAppCode] = useState("");
|
||||||
const [loadingAction, setLoadingAction] = 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(
|
const requestQuery = useMemo(
|
||||||
() => toPageQuery({ keyword, page, pageSize, status }),
|
() => toPageQuery({ keyword, page, pageSize, status }),
|
||||||
@ -64,6 +76,22 @@ export function useExternalAdminUsersPage() {
|
|||||||
initialData: emptyPage,
|
initialData: emptyPage,
|
||||||
queryKey: ["external-admin-users", appCode, requestQuery],
|
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(() => {
|
useLayoutEffect(() => {
|
||||||
// 写操作完成回调依赖最新租户;布局 effect 在用户能继续交互前同步边界,又不在渲染阶段读写 ref。
|
// 写操作完成回调依赖最新租户;布局 effect 在用户能继续交互前同步边界,又不在渲染阶段读写 ref。
|
||||||
@ -73,20 +101,51 @@ export function useExternalAdminUsersPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// App 是外管账号的租户边界;切换后必须立即丢弃账号、密码和用户匹配结果,禁止跨 App 复用敏感表单。
|
// App 是外管账号的租户边界;切换后必须立即丢弃账号、密码和用户匹配结果,禁止跨 App 复用敏感表单。
|
||||||
lookupRequestRef.current += 1;
|
lookupRequestRef.current += 1;
|
||||||
|
permissionRequestRef.current += 1;
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
setCreateFormState(emptyCreateForm());
|
setCreateFormState(emptyCreateForm());
|
||||||
|
setCreatePermissionsReady(false);
|
||||||
setTargetUser(null);
|
setTargetUser(null);
|
||||||
setLookupLoading(false);
|
setLookupLoading(false);
|
||||||
setPasswordUser(null);
|
setPasswordUser(null);
|
||||||
setPasswordFormState(emptyPasswordForm());
|
setPasswordFormState(emptyPasswordForm());
|
||||||
setActionAppCode("");
|
setActionAppCode("");
|
||||||
setLoadingAction("");
|
setLoadingAction("");
|
||||||
|
setPermissionUser(null);
|
||||||
|
setPermissionFormState({ expectedRevision: null, permissions: [] });
|
||||||
|
setPermissionLoading(false);
|
||||||
|
setPermissionError("");
|
||||||
setKeywordState("");
|
setKeywordState("");
|
||||||
setStatusState("");
|
setStatusState("");
|
||||||
setPage(1);
|
setPage(1);
|
||||||
setMergedPage({ ...emptyPage, appCode });
|
setMergedPage({ ...emptyPage, appCode });
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
setMergedPage({ ...emptyPage, appCode });
|
setMergedPage({ ...emptyPage, appCode });
|
||||||
}, [appCode, keyword, status]);
|
}, [appCode, keyword, status]);
|
||||||
@ -134,6 +193,9 @@ export function useExternalAdminUsersPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const setCreateForm = (patch) => {
|
const setCreateForm = (patch) => {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(patch, "permissions")) {
|
||||||
|
setCreatePermissionsReady(true);
|
||||||
|
}
|
||||||
setCreateFormState((current) => ({ ...current, ...patch }));
|
setCreateFormState((current) => ({ ...current, ...patch }));
|
||||||
};
|
};
|
||||||
const changeDisplayUserId = (value) => {
|
const changeDisplayUserId = (value) => {
|
||||||
@ -144,7 +206,15 @@ export function useExternalAdminUsersPage() {
|
|||||||
};
|
};
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
lookupRequestRef.current += 1;
|
lookupRequestRef.current += 1;
|
||||||
setCreateFormState(emptyCreateForm());
|
const catalogReady = !catalogLoading && !catalogError;
|
||||||
|
setCreateFormState({
|
||||||
|
...emptyCreateForm(),
|
||||||
|
permissions:
|
||||||
|
abilities.canManagePermissions && catalogReady
|
||||||
|
? defaultPermissionSelection(permissionCatalog.items || [])
|
||||||
|
: [],
|
||||||
|
});
|
||||||
|
setCreatePermissionsReady(!abilities.canManagePermissions || catalogReady);
|
||||||
setTargetUser(null);
|
setTargetUser(null);
|
||||||
setLookupLoading(false);
|
setLookupLoading(false);
|
||||||
setActionAppCode(appCode);
|
setActionAppCode(appCode);
|
||||||
@ -157,6 +227,7 @@ export function useExternalAdminUsersPage() {
|
|||||||
lookupRequestRef.current += 1;
|
lookupRequestRef.current += 1;
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
setCreateFormState(emptyCreateForm());
|
setCreateFormState(emptyCreateForm());
|
||||||
|
setCreatePermissionsReady(false);
|
||||||
setTargetUser(null);
|
setTargetUser(null);
|
||||||
setLookupLoading(false);
|
setLookupLoading(false);
|
||||||
setActionAppCode("");
|
setActionAppCode("");
|
||||||
@ -206,6 +277,10 @@ export function useExternalAdminUsersPage() {
|
|||||||
const submitCreate = async (event) => {
|
const submitCreate = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const scope = actionAppCode;
|
const scope = actionAppCode;
|
||||||
|
if (!abilities.canCreate) {
|
||||||
|
showToast("没有创建外管用户的权限", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!scope || scope !== appCode) {
|
if (!scope || scope !== appCode) {
|
||||||
showToast("应用已切换,请重新创建", "error");
|
showToast("应用已切换,请重新创建", "error");
|
||||||
return;
|
return;
|
||||||
@ -218,6 +293,10 @@ export function useExternalAdminUsersPage() {
|
|||||||
showToast("该 App 用户已绑定外管账号", "error");
|
showToast("该 App 用户已绑定外管账号", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (catalogLoading || catalogError || permissionCatalog.items.length === 0) {
|
||||||
|
showToast(catalogError || "外管权限目录尚未加载", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
let formPayload;
|
let formPayload;
|
||||||
try {
|
try {
|
||||||
formPayload = parseForm(externalAdminUserCreateFormSchema, createForm);
|
formPayload = parseForm(externalAdminUserCreateFormSchema, createForm);
|
||||||
@ -230,6 +309,8 @@ export function useExternalAdminUsersPage() {
|
|||||||
try {
|
try {
|
||||||
await createExternalAdminUser(scope, {
|
await createExternalAdminUser(scope, {
|
||||||
password: formPayload.password,
|
password: formPayload.password,
|
||||||
|
// 创建权限已收口为 create + permissions,始终提交显式快照,[] 表示主动创建零权限账号。
|
||||||
|
permissions: formPayload.permissions || [],
|
||||||
// 始终提交查询接口返回的稳定 userId,短 ID / 靓号只用于定位,避免后续改号造成错误绑定。
|
// 始终提交查询接口返回的稳定 userId,短 ID / 靓号只用于定位,避免后续改号造成错误绑定。
|
||||||
targetUserId: targetUser.userId,
|
targetUserId: targetUser.userId,
|
||||||
username: formPayload.username,
|
username: formPayload.username,
|
||||||
@ -239,6 +320,7 @@ export function useExternalAdminUsersPage() {
|
|||||||
}
|
}
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
setCreateFormState(emptyCreateForm());
|
setCreateFormState(emptyCreateForm());
|
||||||
|
setCreatePermissionsReady(false);
|
||||||
setTargetUser(null);
|
setTargetUser(null);
|
||||||
setActionAppCode("");
|
setActionAppCode("");
|
||||||
showToast("外管用户已创建", "success");
|
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 {
|
return {
|
||||||
abilities,
|
abilities,
|
||||||
closeCreate,
|
closeCreate,
|
||||||
closePassword,
|
closePassword,
|
||||||
|
closePermissions,
|
||||||
createForm,
|
createForm,
|
||||||
createOpen,
|
createOpen,
|
||||||
data,
|
data,
|
||||||
@ -356,21 +549,33 @@ export function useExternalAdminUsersPage() {
|
|||||||
lookupTarget,
|
lookupTarget,
|
||||||
openCreate,
|
openCreate,
|
||||||
openPassword,
|
openPassword,
|
||||||
|
openPermissions,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
passwordForm,
|
passwordForm,
|
||||||
passwordUser,
|
passwordUser,
|
||||||
|
permissionCatalog,
|
||||||
|
permissionError,
|
||||||
|
permissionForm,
|
||||||
|
permissionLoading,
|
||||||
|
permissionUser,
|
||||||
|
catalogError,
|
||||||
|
catalogLoading,
|
||||||
reload,
|
reload,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
|
reloadPermissionCatalog,
|
||||||
|
retryPermissions,
|
||||||
setCreateForm,
|
setCreateForm,
|
||||||
setDisplayUserId: changeDisplayUserId,
|
setDisplayUserId: changeDisplayUserId,
|
||||||
setKeyword,
|
setKeyword,
|
||||||
setPage,
|
setPage,
|
||||||
setPasswordForm,
|
setPasswordForm,
|
||||||
|
setPermissions,
|
||||||
setStatus,
|
setStatus,
|
||||||
status,
|
status,
|
||||||
submitCreate,
|
submitCreate,
|
||||||
submitPassword,
|
submitPassword,
|
||||||
|
submitPermissions,
|
||||||
targetUser,
|
targetUser,
|
||||||
toggleStatus,
|
toggleStatus,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,12 +2,18 @@ import { act, renderHook, waitFor } from "@testing-library/react";
|
|||||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
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",
|
appCode: "fami",
|
||||||
|
catalogError: "",
|
||||||
|
catalogLoading: false,
|
||||||
|
catalogPage: null,
|
||||||
|
catalogQueryOptions: null,
|
||||||
confirm: vi.fn(),
|
confirm: vi.fn(),
|
||||||
createExternalAdminUser: vi.fn(),
|
createExternalAdminUser: vi.fn(),
|
||||||
|
getExternalAdminUserPermissions: vi.fn(),
|
||||||
invalidateQueries: vi.fn(),
|
invalidateQueries: vi.fn(),
|
||||||
listExternalAdminUsers: vi.fn(),
|
listExternalAdminUsers: vi.fn(),
|
||||||
|
listExternalAdminPermissionCatalog: vi.fn(),
|
||||||
lookupExternalAdminUserTarget: vi.fn(),
|
lookupExternalAdminUserTarget: vi.fn(),
|
||||||
queryOptions: null,
|
queryOptions: null,
|
||||||
queryPage: { items: [], page: 1, pageSize: 50, total: 0 },
|
queryPage: { items: [], page: 1, pageSize: 50, total: 0 },
|
||||||
@ -15,6 +21,7 @@ const mocks = vi.hoisted(() => ({
|
|||||||
resetExternalAdminUserPassword: vi.fn(),
|
resetExternalAdminUserPassword: vi.fn(),
|
||||||
showToast: vi.fn(),
|
showToast: vi.fn(),
|
||||||
updateExternalAdminUserStatus: vi.fn(),
|
updateExternalAdminUserStatus: vi.fn(),
|
||||||
|
updateExternalAdminUserPermissions: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@tanstack/react-query", () => ({
|
vi.mock("@tanstack/react-query", () => ({
|
||||||
@ -27,10 +34,13 @@ vi.mock("@/app/app-scope/AppScopeProvider.jsx", () => ({
|
|||||||
|
|
||||||
vi.mock("@/features/external-admin-users/api", () => ({
|
vi.mock("@/features/external-admin-users/api", () => ({
|
||||||
createExternalAdminUser: mocks.createExternalAdminUser,
|
createExternalAdminUser: mocks.createExternalAdminUser,
|
||||||
|
getExternalAdminUserPermissions: mocks.getExternalAdminUserPermissions,
|
||||||
listExternalAdminUsers: mocks.listExternalAdminUsers,
|
listExternalAdminUsers: mocks.listExternalAdminUsers,
|
||||||
|
listExternalAdminPermissionCatalog: mocks.listExternalAdminPermissionCatalog,
|
||||||
lookupExternalAdminUserTarget: mocks.lookupExternalAdminUserTarget,
|
lookupExternalAdminUserTarget: mocks.lookupExternalAdminUserTarget,
|
||||||
resetExternalAdminUserPassword: mocks.resetExternalAdminUserPassword,
|
resetExternalAdminUserPassword: mocks.resetExternalAdminUserPassword,
|
||||||
updateExternalAdminUserStatus: mocks.updateExternalAdminUserStatus,
|
updateExternalAdminUserStatus: mocks.updateExternalAdminUserStatus,
|
||||||
|
updateExternalAdminUserPermissions: mocks.updateExternalAdminUserPermissions,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/features/external-admin-users/permissions.js", () => ({
|
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", () => ({
|
vi.mock("@/shared/hooks/useAdminQuery.js", () => ({
|
||||||
useAdminQuery: (_queryFn, options) => {
|
useAdminQuery: (_queryFn, options) => {
|
||||||
mocks.queryOptions = 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 };
|
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";
|
import { useExternalAdminUsersPage } from "./useExternalAdminUsersPage.js";
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
mocks.abilities = { canCreate: true, canManagePermissions: true, canResetPassword: true, canStatus: true, canView: true };
|
||||||
mocks.appCode = "fami";
|
mocks.appCode = "fami";
|
||||||
|
mocks.catalogError = "";
|
||||||
|
mocks.catalogLoading = false;
|
||||||
|
mocks.catalogPage = permissionCatalogFixture();
|
||||||
mocks.queryPage = { items: [], page: 1, pageSize: 50, total: 0 };
|
mocks.queryPage = { items: [], page: 1, pageSize: 50, total: 0 };
|
||||||
mocks.confirm.mockResolvedValue(true);
|
mocks.confirm.mockResolvedValue(true);
|
||||||
mocks.createExternalAdminUser.mockResolvedValue(externalUserFixture());
|
mocks.createExternalAdminUser.mockResolvedValue(externalUserFixture());
|
||||||
|
mocks.listExternalAdminPermissionCatalog.mockResolvedValue(permissionCatalogFixture());
|
||||||
|
mocks.getExternalAdminUserPermissions.mockResolvedValue({
|
||||||
|
accountId: "71",
|
||||||
|
permissions: ["user:list"],
|
||||||
|
revision: 4,
|
||||||
|
});
|
||||||
mocks.invalidateQueries.mockResolvedValue(undefined);
|
mocks.invalidateQueries.mockResolvedValue(undefined);
|
||||||
mocks.lookupExternalAdminUserTarget.mockResolvedValue(targetFixture());
|
mocks.lookupExternalAdminUserTarget.mockResolvedValue(targetFixture());
|
||||||
mocks.reload.mockResolvedValue(undefined);
|
mocks.reload.mockResolvedValue(undefined);
|
||||||
mocks.resetExternalAdminUserPassword.mockResolvedValue(externalUserFixture());
|
mocks.resetExternalAdminUserPassword.mockResolvedValue(externalUserFixture());
|
||||||
mocks.updateExternalAdminUserStatus.mockResolvedValue({ ...externalUserFixture(), status: "disabled" });
|
mocks.updateExternalAdminUserStatus.mockResolvedValue({ ...externalUserFixture(), status: "disabled" });
|
||||||
|
mocks.updateExternalAdminUserPermissions.mockResolvedValue(externalUserFixture());
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
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.lookupExternalAdminUserTarget).toHaveBeenCalledWith("fami", "VIP-1001");
|
||||||
expect(mocks.createExternalAdminUser).toHaveBeenCalledWith("fami", {
|
expect(mocks.createExternalAdminUser).toHaveBeenCalledWith("fami", {
|
||||||
password: "StrongPass123!",
|
password: "StrongPass123!",
|
||||||
|
permissions: ["user:list", "user:update"],
|
||||||
targetUserId: "internal-user-1001",
|
targetUserId: "internal-user-1001",
|
||||||
username: "ops.fami",
|
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);
|
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 () => {
|
test("clears list filters, lookup results and password form when App scope changes", async () => {
|
||||||
const { result, rerender } = renderHook(() => useExternalAdminUsersPage());
|
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(""));
|
await waitFor(() => expect(result.current.keyword).toBe(""));
|
||||||
expect(result.current.status).toBe("");
|
expect(result.current.status).toBe("");
|
||||||
expect(result.current.createOpen).toBe(false);
|
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.targetUser).toBeNull();
|
||||||
expect(result.current.passwordUser).toBeNull();
|
expect(result.current.passwordUser).toBeNull();
|
||||||
expect(result.current.passwordForm).toEqual({ confirmPassword: "", password: "" });
|
expect(result.current.passwordForm).toEqual({ confirmPassword: "", password: "" });
|
||||||
expect(mocks.queryOptions.queryKey[1]).toBe("lalu");
|
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 () => {
|
test("ignores a late user lookup response after switching App", async () => {
|
||||||
let resolveLookup;
|
let resolveLookup;
|
||||||
mocks.lookupExternalAdminUserTarget.mockImplementationOnce(
|
mocks.lookupExternalAdminUserTarget.mockImplementationOnce(
|
||||||
@ -184,8 +342,20 @@ function externalUserFixture() {
|
|||||||
lastLoginAtMs: 1784077200000,
|
lastLoginAtMs: 1784077200000,
|
||||||
linkedUser: targetFixture(),
|
linkedUser: targetFixture(),
|
||||||
permissions: [],
|
permissions: [],
|
||||||
|
permissionRevision: 4,
|
||||||
status: "active",
|
status: "active",
|
||||||
updatedAtMs: 1784077200000,
|
updatedAtMs: 1784077200000,
|
||||||
username: "ops.fami",
|
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 PasswordOutlined from "@mui/icons-material/PasswordOutlined";
|
||||||
import PersonAddAltOutlined from "@mui/icons-material/PersonAddAltOutlined";
|
import PersonAddAltOutlined from "@mui/icons-material/PersonAddAltOutlined";
|
||||||
import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined";
|
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 { ExternalAdminUserFormDialog } from "@/features/external-admin-users/components/ExternalAdminUserFormDialog.jsx";
|
||||||
import { ExternalAdminUserPasswordDialog } from "@/features/external-admin-users/components/ExternalAdminUserPasswordDialog.jsx";
|
import { ExternalAdminUserPasswordDialog } from "@/features/external-admin-users/components/ExternalAdminUserPasswordDialog.jsx";
|
||||||
import styles from "@/features/external-admin-users/external-admin-users.module.css";
|
import styles from "@/features/external-admin-users/external-admin-users.module.css";
|
||||||
@ -56,7 +58,7 @@ const columns = [
|
|||||||
label: "状态",
|
label: "状态",
|
||||||
render: (item, _index, context) => {
|
render: (item, _index, context) => {
|
||||||
const page = context?.page;
|
const page = context?.page;
|
||||||
// 外管状态和密码都是账号级敏感写操作;同页串行化可避免两个请求互相覆盖全局 loading 状态。
|
// 外管状态、密码和权限都是账号级敏感写操作;同页串行化可避免多个请求互相覆盖全局 loading 状态。
|
||||||
const accountActionInFlight = Boolean(page?.loadingAction);
|
const accountActionInFlight = Boolean(page?.loadingAction);
|
||||||
return (
|
return (
|
||||||
<AdminSwitch
|
<AdminSwitch
|
||||||
@ -74,7 +76,7 @@ const columns = [
|
|||||||
{
|
{
|
||||||
key: "permissions",
|
key: "permissions",
|
||||||
label: "外管权限",
|
label: "外管权限",
|
||||||
render: (item) => (item.permissions?.length ? `${item.permissions.length} 项` : "-"),
|
render: (item) => `${item.permissions?.length || 0} 项`,
|
||||||
width: "minmax(120px, 0.65fr)",
|
width: "minmax(120px, 0.65fr)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -102,6 +104,13 @@ const columns = [
|
|||||||
const page = context?.page;
|
const page = context?.page;
|
||||||
return (
|
return (
|
||||||
<AdminRowActions>
|
<AdminRowActions>
|
||||||
|
<AdminActionIconButton
|
||||||
|
disabled={!page?.abilities.canManagePermissions || Boolean(page.loadingAction)}
|
||||||
|
label="配置权限"
|
||||||
|
onClick={() => page.openPermissions(item)}
|
||||||
|
>
|
||||||
|
<AdminPanelSettingsOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
<AdminActionIconButton
|
<AdminActionIconButton
|
||||||
disabled={!page?.abilities.canResetPassword || Boolean(page.loadingAction)}
|
disabled={!page?.abilities.canResetPassword || Boolean(page.loadingAction)}
|
||||||
label="重置密码"
|
label="重置密码"
|
||||||
@ -113,7 +122,7 @@ const columns = [
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
resizable: false,
|
resizable: false,
|
||||||
width: "88px",
|
width: "132px",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -192,6 +201,9 @@ export function ExternalAdminUsersPage() {
|
|||||||
</DataState>
|
</DataState>
|
||||||
<ExternalAdminUserFormDialog
|
<ExternalAdminUserFormDialog
|
||||||
abilities={page.abilities}
|
abilities={page.abilities}
|
||||||
|
catalog={page.permissionCatalog.items || []}
|
||||||
|
catalogError={page.catalogError}
|
||||||
|
catalogLoading={page.catalogLoading}
|
||||||
form={page.createForm}
|
form={page.createForm}
|
||||||
loading={page.loadingAction === "create"}
|
loading={page.loadingAction === "create"}
|
||||||
lookupLoading={page.lookupLoading}
|
lookupLoading={page.lookupLoading}
|
||||||
@ -201,8 +213,25 @@ export function ExternalAdminUsersPage() {
|
|||||||
onDisplayUserIdChange={page.setDisplayUserId}
|
onDisplayUserIdChange={page.setDisplayUserId}
|
||||||
onFormChange={page.setCreateForm}
|
onFormChange={page.setCreateForm}
|
||||||
onLookup={page.lookupTarget}
|
onLookup={page.lookupTarget}
|
||||||
|
onReloadCatalog={page.reloadPermissionCatalog}
|
||||||
onSubmit={page.submitCreate}
|
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
|
<ExternalAdminUserPasswordDialog
|
||||||
abilities={page.abilities}
|
abilities={page.abilities}
|
||||||
form={page.passwordForm}
|
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() {
|
export function useExternalAdminUserAbilities() {
|
||||||
const { can } = useAuth();
|
const { can } = useAuth();
|
||||||
|
const canCreateAccount = can(PERMISSIONS.externalAdminUserCreate);
|
||||||
|
const canManagePermissions = can(PERMISSIONS.externalAdminUserPermissions);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
canCreate: can(PERMISSIONS.externalAdminUserCreate),
|
// 创建必须同时能配置权限,禁止通过“省略权限字段使用默认值”绕过独立授权边界。
|
||||||
|
canCreate: canCreateAccount && canManagePermissions,
|
||||||
|
canCreateAccount,
|
||||||
|
canManagePermissions,
|
||||||
canResetPassword: can(PERMISSIONS.externalAdminUserResetPassword),
|
canResetPassword: can(PERMISSIONS.externalAdminUserResetPassword),
|
||||||
canStatus: can(PERMISSIONS.externalAdminUserStatus),
|
canStatus: can(PERMISSIONS.externalAdminUserStatus),
|
||||||
canView: can(PERMISSIONS.externalAdminUserView),
|
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,
|
externalAdminUserCreateFormSchema,
|
||||||
externalAdminUserLookupSchema,
|
externalAdminUserLookupSchema,
|
||||||
externalAdminUserPasswordFormSchema,
|
externalAdminUserPasswordFormSchema,
|
||||||
|
externalAdminUserPermissionsFormSchema,
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
|
|
||||||
describe("external admin user schemas", () => {
|
describe("external admin user schemas", () => {
|
||||||
@ -62,4 +63,16 @@ describe("external admin user schemas", () => {
|
|||||||
|
|
||||||
expect(result.success).toBe(false);
|
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 账号字符集。
|
// 外管系统使用独立登录标识;限制为跨浏览器、网关和审计日志均稳定的 ASCII 账号字符集。
|
||||||
.regex(/^[A-Za-z0-9._-]+$/, "外管账号只能包含字母、数字、点、下划线和连字符");
|
.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 或自动改写;前端只做长度与确认值校验,服务端会重复校验并仅持久化密码摘要。
|
// 密码属于登录凭证,不能 trim 或自动改写;前端只做长度与确认值校验,服务端会重复校验并仅持久化密码摘要。
|
||||||
const passwordSchema = z
|
const passwordSchema = z
|
||||||
.string()
|
.string()
|
||||||
@ -32,6 +43,7 @@ export const externalAdminUserCreateFormSchema = z
|
|||||||
confirmPassword: passwordSchema,
|
confirmPassword: passwordSchema,
|
||||||
displayUserId: displayUserIdSchema,
|
displayUserId: displayUserIdSchema,
|
||||||
password: passwordSchema,
|
password: passwordSchema,
|
||||||
|
permissions: permissionsSchema.optional(),
|
||||||
username: usernameSchema,
|
username: usernameSchema,
|
||||||
})
|
})
|
||||||
.superRefine((value, context) => {
|
.superRefine((value, context) => {
|
||||||
@ -57,7 +69,15 @@ export const externalAdminUserStatusSchema = z.object({
|
|||||||
status: z.enum(["active", "disabled"]),
|
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 ExternalAdminUserCreateForm = z.input<typeof externalAdminUserCreateFormSchema>;
|
||||||
export type ExternalAdminUserCreatePayload = z.output<typeof externalAdminUserCreateFormSchema>;
|
export type ExternalAdminUserCreatePayload = z.output<typeof externalAdminUserCreateFormSchema>;
|
||||||
export type ExternalAdminUserPasswordForm = z.input<typeof externalAdminUserPasswordFormSchema>;
|
export type ExternalAdminUserPasswordForm = z.input<typeof externalAdminUserPasswordFormSchema>;
|
||||||
export type ExternalAdminUserPasswordPayload = z.output<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",
|
getCPConfig: "getCPConfig",
|
||||||
getCPWeeklyRankConfig: "getCPWeeklyRankConfig",
|
getCPWeeklyRankConfig: "getCPWeeklyRankConfig",
|
||||||
getCumulativeRechargeRewardConfig: "getCumulativeRechargeRewardConfig",
|
getCumulativeRechargeRewardConfig: "getCumulativeRechargeRewardConfig",
|
||||||
|
getExternalAdminUserPermissions: "getExternalAdminUserPermissions",
|
||||||
getFinanceCoinSellerRechargeExchangeRate: "getFinanceCoinSellerRechargeExchangeRate",
|
getFinanceCoinSellerRechargeExchangeRate: "getFinanceCoinSellerRechargeExchangeRate",
|
||||||
getFinanceScope: "getFinanceScope",
|
getFinanceScope: "getFinanceScope",
|
||||||
getFinanceUSDTAddresses: "getFinanceUSDTAddresses",
|
getFinanceUSDTAddresses: "getFinanceUSDTAddresses",
|
||||||
@ -211,6 +212,7 @@ export const API_OPERATIONS = {
|
|||||||
listEmojiPackCategories: "listEmojiPackCategories",
|
listEmojiPackCategories: "listEmojiPackCategories",
|
||||||
listEmojiPacks: "listEmojiPacks",
|
listEmojiPacks: "listEmojiPacks",
|
||||||
listExploreTabs: "listExploreTabs",
|
listExploreTabs: "listExploreTabs",
|
||||||
|
listExternalAdminPermissionCatalog: "listExternalAdminPermissionCatalog",
|
||||||
listExternalAdminUsers: "listExternalAdminUsers",
|
listExternalAdminUsers: "listExternalAdminUsers",
|
||||||
listFinanceCoinSellerRechargeOrders: "listFinanceCoinSellerRechargeOrders",
|
listFinanceCoinSellerRechargeOrders: "listFinanceCoinSellerRechargeOrders",
|
||||||
listFinanceScopeAssignments: "listFinanceScopeAssignments",
|
listFinanceScopeAssignments: "listFinanceScopeAssignments",
|
||||||
@ -346,6 +348,7 @@ export const API_OPERATIONS = {
|
|||||||
updateCumulativeRechargeRewardConfig: "updateCumulativeRechargeRewardConfig",
|
updateCumulativeRechargeRewardConfig: "updateCumulativeRechargeRewardConfig",
|
||||||
updateDiceConfig: "updateDiceConfig",
|
updateDiceConfig: "updateDiceConfig",
|
||||||
updateExploreTab: "updateExploreTab",
|
updateExploreTab: "updateExploreTab",
|
||||||
|
updateExternalAdminUserPermissions: "updateExternalAdminUserPermissions",
|
||||||
updateExternalAdminUserStatus: "updateExternalAdminUserStatus",
|
updateExternalAdminUserStatus: "updateExternalAdminUserStatus",
|
||||||
updateFirstRechargeRewardConfig: "updateFirstRechargeRewardConfig",
|
updateFirstRechargeRewardConfig: "updateFirstRechargeRewardConfig",
|
||||||
updateGift: "updateGift",
|
updateGift: "updateGift",
|
||||||
@ -684,7 +687,7 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
operationId: API_OPERATIONS.createExternalAdminUser,
|
operationId: API_OPERATIONS.createExternalAdminUser,
|
||||||
path: "/v1/admin/external-admin-users",
|
path: "/v1/admin/external-admin-users",
|
||||||
permission: "external-admin-user:create",
|
permission: "external-admin-user:create",
|
||||||
permissions: ["external-admin-user:create"]
|
permissions: ["external-admin-user:create","external-admin-user:permissions"]
|
||||||
},
|
},
|
||||||
createFinanceCoinSellerRechargeOrder: {
|
createFinanceCoinSellerRechargeOrder: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@ -1267,6 +1270,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "cumulative-recharge-reward:view",
|
permission: "cumulative-recharge-reward:view",
|
||||||
permissions: ["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: {
|
getFinanceCoinSellerRechargeExchangeRate: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.getFinanceCoinSellerRechargeExchangeRate,
|
operationId: API_OPERATIONS.getFinanceCoinSellerRechargeExchangeRate,
|
||||||
@ -1790,6 +1800,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "app-config:view",
|
permission: "app-config:view",
|
||||||
permissions: ["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: {
|
listExternalAdminUsers: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.listExternalAdminUsers,
|
operationId: API_OPERATIONS.listExternalAdminUsers,
|
||||||
@ -2721,6 +2738,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "app-config:update",
|
permission: "app-config:update",
|
||||||
permissions: ["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: {
|
updateExternalAdminUserStatus: {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
operationId: API_OPERATIONS.updateExternalAdminUserStatus,
|
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;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/admin/external-admin-users/target": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -3660,6 +3676,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/exports/app-users": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -4796,6 +4828,12 @@ export interface components {
|
|||||||
ApiResponseExternalAdminUserTarget: components["schemas"]["Envelope"] & {
|
ApiResponseExternalAdminUserTarget: components["schemas"]["Envelope"] & {
|
||||||
data?: components["schemas"]["ExternalAdminUserTargetLookup"];
|
data?: components["schemas"]["ExternalAdminUserTargetLookup"];
|
||||||
};
|
};
|
||||||
|
ApiResponseExternalAdminPermissionCatalog: components["schemas"]["Envelope"] & {
|
||||||
|
data?: components["schemas"]["ExternalAdminPermissionCatalog"];
|
||||||
|
};
|
||||||
|
ApiResponseExternalAdminUserPermissions: components["schemas"]["Envelope"] & {
|
||||||
|
data?: components["schemas"]["ExternalAdminUserPermissions"];
|
||||||
|
};
|
||||||
ApiPageExternalAdminUser: {
|
ApiPageExternalAdminUser: {
|
||||||
items: components["schemas"]["ExternalAdminUser"][];
|
items: components["schemas"]["ExternalAdminUser"][];
|
||||||
page: number;
|
page: number;
|
||||||
@ -4825,6 +4863,8 @@ export interface components {
|
|||||||
status: "active" | "disabled";
|
status: "active" | "disabled";
|
||||||
linkedUser: components["schemas"]["ExternalAdminUserTarget"];
|
linkedUser: components["schemas"]["ExternalAdminUserTarget"];
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
|
/** Format: int64 */
|
||||||
|
permissionRevision: number;
|
||||||
createdByAdminId?: number;
|
createdByAdminId?: number;
|
||||||
/** Format: int64 */
|
/** Format: int64 */
|
||||||
lastLoginAtMs?: number | null;
|
lastLoginAtMs?: number | null;
|
||||||
@ -4837,6 +4877,30 @@ export interface components {
|
|||||||
targetUserId: string;
|
targetUserId: string;
|
||||||
username: string;
|
username: string;
|
||||||
password: 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: {
|
ExternalAdminUserStatusInput: {
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
@ -7030,6 +7094,24 @@ export interface components {
|
|||||||
"application/json": components["schemas"]["ApiResponseExternalAdminUserTarget"];
|
"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 */
|
/** @description OK */
|
||||||
AgencyPageResponse: {
|
AgencyPageResponse: {
|
||||||
headers: {
|
headers: {
|
||||||
@ -12214,6 +12296,20 @@ export interface operations {
|
|||||||
201: components["responses"]["ExternalAdminUserResponse"];
|
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: {
|
lookupExternalAdminUserTarget: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query: {
|
query: {
|
||||||
@ -12270,6 +12366,42 @@ export interface operations {
|
|||||||
200: components["responses"]["ExternalAdminUserResponse"];
|
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: {
|
appExportUsers: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
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");
|
await expect(apiRequest("/v1/admin/countries", { body: {}, method: "POST" })).rejects.toThrow("Failed to fetch");
|
||||||
expect(fetch).toHaveBeenCalledTimes(1);
|
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 (raw) {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(response.statusText || "请求失败");
|
throw requestError(response.statusText || "请求失败", response.status);
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = await readJSON(response);
|
const payload = await readJSON(response);
|
||||||
if (!response.ok || payload.code !== 0) {
|
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;
|
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() {
|
function refreshOnce() {
|
||||||
if (!refreshHandler) {
|
if (!refreshHandler) {
|
||||||
return Promise.resolve(false);
|
return Promise.resolve(false);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user