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