From 1fe321240ad2b911714bb9c2437869784fa8dee0 Mon Sep 17 00:00:00 2001 From: agent-exec-transport Date: Tue, 20 Oct 2026 10:48:43 +0000 Subject: [PATCH] agent-exec transport 91fa500d0ed8f7daca02bd46ba843a9a/b201c6aced99 --- src/api.ts | 7 ++- src/components/AccountUsersPanel.tsx | 11 ++++- src/rolePermissions.ts | 7 +-- test/admin-user-role-sources.test.tsx | 61 +++++++++++++++++++++++++++ test/one-time-password.test.ts | 5 ++- 5 files changed, 83 insertions(+), 8 deletions(-) create mode 100644 test/admin-user-role-sources.test.tsx diff --git a/src/api.ts b/src/api.ts index d699cba..eff28e4 100644 --- a/src/api.ts +++ b/src/api.ts @@ -25,7 +25,9 @@ export interface UserRoleBinding { bindingId: string; userId: string; roleId: string; active: boolean; bindingVersion: number assignedBy: string; assignedAt: string; userStateVersion: number; authorityEpoch: number } -export interface UserRoleView { binding?: UserRoleBinding; role?: RoleRecord } +/** getUserRole 返回的角色摘要:实测不含 permissions,权限补齐由角色列表条目提供。 */ +export interface UserRoleSummary extends Omit { permissions?: RolePermissionRecord[] } +export interface UserRoleView { binding?: UserRoleBinding; role?: UserRoleSummary } /** Backend 计算的本人 project.create 联动(/api/me linkedCapabilities)。 */ export interface LinkedCapabilities { projectCreate: boolean; source?: string; linkedActions: string[]; conflicts?: string[]; authorityEpoch: number @@ -153,7 +155,8 @@ export const api = { // 管理员建号:可提供初始密码;省略密码时由服务端生成并仅返回一次。 // isAdmin 仅超级管理员可用(R3/R4),普通管理员携带时由服务端拒绝。 createAdminUser: (owner: string, name: string, password?: string, isAdmin?: boolean) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile: { name }, ...(password ? { password } : {}), ...(isAdmin ? { isAdmin: true } : {}) }) }).then(r => json<{ userId: string; owner: string; passwordSet: boolean; mustChangePassword: boolean; initializationState: string; initialPassword?: string }>(r)), - adminIssueOneTimePassword: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/one-time-password`, { method: 'POST' }).then(r => json(r)), + // 服务端严格解码 JSON 对象体:无 Content-Type 与请求体的 POST 会被拒为 400 EOF。 + adminIssueOneTimePassword: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/one-time-password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }).then(r => json(r)), searchAdminUsers: (request: { query: string; mode: 'exact' | 'fuzzy'; status: 'all' | 'enabled' | 'disabled'; fields: string[]; cursor?: string; limit?: number }) => { const params = new URLSearchParams({ query: request.query, mode: request.mode, status: request.status, fields: request.fields.join(','), limit: String(request.limit ?? 20) }) diff --git a/src/components/AccountUsersPanel.tsx b/src/components/AccountUsersPanel.tsx index 94a8c39..135d72f 100644 --- a/src/components/AccountUsersPanel.tsx +++ b/src/components/AccountUsersPanel.tsx @@ -95,13 +95,20 @@ export function GrantChangePreview({ baseline, grants, labels, owner }: { /** 账号的创建能力来源与联动说明(角色默认与明确授权可并存)。 */ export function ProjectCreateSourceNote({ role, grants }: { - role: { name: string; permissions: { actionId: string; allowed: boolean }[] } | null | undefined; grants: string[] + role: { name: string; permissions?: { actionId: string; allowed: boolean }[] } | null | undefined; grants: string[] }) { const sources = projectCreateSources(role, grants) if (sources.length === 0) return

未获得创建项目能力:被加入项目后由成员关系提供基础协作

return

创建项目来源:{sources.join(' + ')} · 联动形成本人创建项目的设置、成员、项目权限与文档职责管理资格

} +/** getUserRole 的角色摘要不带 permissions:用已加载角色列表里同一 roleId 的条目补齐, + * 列表里也没有时按无角色默认权限处理(不新增请求,也不改写已有权限)。 */ +export function roleWithPermissions(role: UserRoleView['role'], roles: RoleRecord[]): UserRoleView['role'] { + if (!role || role.permissions) return role + return roles.find(item => item.roleId === role.roleId) ?? role +} + export function AccountUsersPanel() { const me = useStore(state => state.me) const allowed = (action: string) => !!me?.isAdmin || hasSystemAction(me?.systemActions, action) @@ -326,7 +333,7 @@ export function AccountUsersPanel() { } - {(canViewRoles || canViewGrants) && } + {(canViewRoles || canViewGrants) && } {canViewGrants && diff --git a/src/rolePermissions.ts b/src/rolePermissions.ts index 03c1ba0..fb4ca64 100644 --- a/src/rolePermissions.ts +++ b/src/rolePermissions.ts @@ -205,12 +205,13 @@ export function rebaseGrantDraft(baseline: string[], local: string[], server: st return [...next].sort() } -/** 某账号 project.create 的实际来源(角色默认与明确系统授权可并存)。 */ +/** 某账号 project.create 的实际来源(角色默认与明确系统授权可并存)。 + * getUserRole 的角色摘要不带 permissions,缺失按「无角色默认权限」处理。 */ export function projectCreateSources( - role: { name: string; permissions: { actionId: string; allowed: boolean }[] } | null | undefined, grants: string[], + role: { name: string; permissions?: { actionId: string; allowed: boolean }[] } | null | undefined, grants: string[], ): string[] { const sources: string[] = [] - if (role?.permissions.some(item => item.actionId === PROJECT_CREATE_ACTION && item.allowed)) sources.push(`职位角色「${role.name}」`) + if (role?.permissions?.some(item => item.actionId === PROJECT_CREATE_ACTION && item.allowed)) sources.push(`职位角色「${role.name}」`) if (grants.includes(PROJECT_CREATE_ACTION)) sources.push('明确系统授权') return sources } diff --git a/test/admin-user-role-sources.test.tsx b/test/admin-user-role-sources.test.tsx new file mode 100644 index 0000000..91d705a --- /dev/null +++ b/test/admin-user-role-sources.test.tsx @@ -0,0 +1,61 @@ +// 管理端用户面板两处缺陷的回归锁定: +// (1) getUserRole 的 role 摘要不带 permissions(线上实测),projectCreateSources 不得因此抛异常; +// 展示来源时用已加载角色列表里同一 roleId 的条目补齐权限,不新增请求。 +// (2) 一次性登录密码按服务端严格解码要求发送 JSON 对象体。 +import { afterEach, describe, expect, it, vi } from 'vitest' +import { renderToStaticMarkup } from 'react-dom/server' +import { api, type RoleRecord } from '../src/api' +import { auth } from '../src/auth' +import { ProjectCreateSourceNote, roleWithPermissions } from '../src/components/AccountUsersPanel' +import { projectCreateSources } from '../src/rolePermissions' + +const listed: RoleRecord = { + roleId: 'role-pm', name: '项目经理', description: '研发', enabled: true, roleVersion: 3, bindingCount: 1, + permissions: [ + { actionId: 'project.create', allowed: true, capAllowed: true, permissionVersion: 2 }, + { actionId: 'document.read', allowed: true, capAllowed: true, permissionVersion: 1 }, + ], +} +/** getUserRole 的真实响应形状:有角色字段、没有 permissions。 */ +const summary = { roleId: 'role-pm', name: '项目经理', description: '研发', enabled: true, roleVersion: 3, bindingCount: 1 } + +afterEach(() => vi.restoreAllMocks()) + +describe('角色摘要缺少 permissions 时的创建能力来源', () => { + it('把缺失的 permissions 当作空集,不抛异常也不虚构角色来源', () => { + expect(projectCreateSources({ name: '项目经理' }, [])).toEqual([]) + expect(projectCreateSources({ name: '项目经理' }, ['project.create'])).toEqual(['明确系统授权']) + expect(projectCreateSources({ ...summary, permissions: undefined }, [])).toEqual([]) + expect(renderToStaticMarkup()) + .toContain('被加入项目后由成员关系提供基础协作') + }) + + it('用角色列表里同一 roleId 的权限补齐来源', () => { + const resolved = roleWithPermissions(summary, [listed]) + expect(resolved?.permissions?.some(item => item.actionId === 'project.create')).toBe(true) + expect(renderToStaticMarkup()) + .toContain('创建项目来源:职位角色「项目经理」') + }) + + it('列表里没有对应条目时按无角色默认权限处理,角色自带权限时不被列表覆盖', () => { + expect(projectCreateSources(roleWithPermissions(summary, []), [])).toEqual([]) + const denied = { ...listed, roleId: 'role-other', permissions: [{ actionId: 'project.create', allowed: false, capAllowed: false, permissionVersion: 1 }] } + expect(projectCreateSources(roleWithPermissions({ ...summary, permissions: denied.permissions }, [denied]), [])).toEqual([]) + }) +}) + +describe('管理员一次性登录密码请求体', () => { + it('发送 POST + JSON 对象体,避免服务端 400 EOF', async () => { + const issued = { owner: 'alice', id: 'otp_123', oneTimePassword: 'otp-0001', mustChangePassword: true } + const request = vi.spyOn(auth, 'fetch').mockResolvedValue(new Response(JSON.stringify(issued))) + + const out = await api.adminIssueOneTimePassword('alice') + + const [url, options] = request.mock.calls[0] + expect(url).toBe('/api/admin/users/alice/one-time-password') + expect(options?.method).toBe('POST') + expect(options?.headers).toEqual({ 'Content-Type': 'application/json' }) + expect(JSON.parse(String(options?.body))).toEqual({}) + expect(out).toEqual(issued) + }) +}) \ No newline at end of file diff --git a/test/one-time-password.test.ts b/test/one-time-password.test.ts index 0083ed3..1b3d79f 100644 --- a/test/one-time-password.test.ts +++ b/test/one-time-password.test.ts @@ -21,7 +21,10 @@ describe('管理员一次性密码发放', () => { const out = await api.adminIssueOneTimePassword('alice') expect(fetchSpy).toHaveBeenCalledTimes(1) - expect(fetchSpy).toHaveBeenCalledWith('/api/admin/users/alice/one-time-password', { method: 'POST' }) + // 服务端严格解码 JSON 对象体:无体无 Content-Type 会被拒为 400 EOF。 + expect(fetchSpy).toHaveBeenCalledWith('/api/admin/users/alice/one-time-password', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}', + }) expect(out).toEqual(response) })