Merge pull request 'Keep admin user detail and one-time password working' (#119) from fix/multiuser-project-collab-r3 into main
This commit was merged in pull request #119.
This commit is contained in:
+5
-2
@@ -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<RoleRecord, 'permissions'> { 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<AdminIssuedOneTimePassword>(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<AdminIssuedOneTimePassword>(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) })
|
||||
|
||||
@@ -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 <p className="ad-hint">未获得创建项目能力:被加入项目后由成员关系提供基础协作</p>
|
||||
return <p className="ad-hint">创建项目来源:{sources.join(' + ')} · 联动形成本人创建项目的设置、成员、项目权限与文档职责管理资格</p>
|
||||
}
|
||||
|
||||
/** 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() {
|
||||
<button className="ad-mini" disabled={roleLoading || !roleTrail.length} onClick={() => { setRoleCursor(roleTrail[roleTrail.length - 1]); setRoleTrail(roleTrail.slice(0, -1)) }}>上一页角色</button>
|
||||
<button className="ad-mini" disabled={roleLoading || !nextRoleCursor} onClick={() => { setRoleTrail([...roleTrail, roleCursor]); setRoleCursor(nextRoleCursor) }}>下一页角色</button>
|
||||
</div>}
|
||||
{(canViewRoles || canViewGrants) && <ProjectCreateSourceNote role={roleView?.role} grants={grantBaseline} />}
|
||||
{(canViewRoles || canViewGrants) && <ProjectCreateSourceNote role={roleWithPermissions(roleView?.role, roles)} grants={grantBaseline} />}
|
||||
{canViewGrants && <Collapsible id={`system-grants-${selected.userId}`} trigger="明确系统权限">
|
||||
<GrantGroupEditor catalog={catalog} grants={grants} disabled={!canManageGrants || !policy.maintain || busy} onChange={setGrants} />
|
||||
<GrantChangePreview baseline={grantBaseline} grants={grants} labels={labels} owner={selected.owner} />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(<ProjectCreateSourceNote role={{ name: '项目经理' }} grants={[]} />))
|
||||
.toContain('被加入项目后由成员关系提供基础协作')
|
||||
})
|
||||
|
||||
it('用角色列表里同一 roleId 的权限补齐来源', () => {
|
||||
const resolved = roleWithPermissions(summary, [listed])
|
||||
expect(resolved?.permissions?.some(item => item.actionId === 'project.create')).toBe(true)
|
||||
expect(renderToStaticMarkup(<ProjectCreateSourceNote role={resolved} grants={[]} />))
|
||||
.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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user