feat(external-admin): configure account permissions

This commit is contained in:
zhx 2026-07-15 18:45:36 +08:00
parent 490e36b2cf
commit e9c4595f02
23 changed files with 1629 additions and 15 deletions

View File

@ -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": {

View File

@ -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",

View File

@ -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",

View File

@ -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 {

View File

@ -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();
});

View File

@ -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>
);
}

View File

@ -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 }));
}

View File

@ -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([]);
});
});

View File

@ -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>
); );
} }

View File

@ -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;
}
} }

View File

@ -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,
}; };

View File

@ -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,
};
}

View File

@ -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}

View File

@ -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(),
};
}

View 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;
}
});
}
}

View File

@ -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),

View 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);
});

View File

@ -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"] });
});
}); });

View File

@ -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>;

View File

@ -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,

View File

@ -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?: {

View File

@ -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
});
});

View File

@ -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);