Add IAM U4 role and permission management UI #111

Merged
weichen merged 11 commits from feature/iam-u4-role-permission-ui into main 2026-10-18 14:20:29 +00:00
12 changed files with 1521 additions and 154 deletions
+623
View File
@@ -0,0 +1,623 @@
// IAM U4 阶段 7 确定性浏览器切片(DR6–DR10):真实 React/Picker 交互 +
// 完全受控的 HTTP 应答,不依赖任何账号或数据库。活体隔离产品层由
// Agent-System-RDM scripts/iam-u4-role-acceptance.sh 以真实 Backend/RDM
// 授权路径复用同一场景(live 模式不拦截路由)。
import { test, expect, type Page, type Route } from '@playwright/test'
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL to an isolated frontend server')
function deferred() {
let resolve!: () => void
const promise = new Promise<void>(ok => { resolve = ok })
return { promise, resolve }
}
interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; systemActions?: string[] }
interface Perm { actionId: string; label: string; description: string; group: string; scope: 'system' | 'project'; protected: boolean }
interface RolePerm { actionId: string; allowed: boolean; capAllowed: boolean; permissionVersion: number }
interface Role { roleId: string; name: string; description: string; enabled: boolean; roleVersion: number; bindingCount: number; permissions: RolePerm[] }
interface Account {
userId: string; owner: string; profile: { name: string; phone: string; email: string; staffNo: string; department: string; notes: string }
profileVersion: number; enabled: boolean; stateVersion: number; isAdmin: boolean; passwordSet: boolean
initializationState: string; initializationGeneration: number
}
const FULL_ACTIONS = ['system.users.view', 'system.users.create', 'system.users.edit', 'system.users.disable',
'system.roles.view', 'system.roles.manage', 'system.roles.assign', 'system.permissions.view', 'system.permissions.manage']
function perm(actionId: string, label: string, group: string, scope: 'system' | 'project', extra: Partial<Perm> = {}): Perm {
return { actionId, label, description: `${label}说明`, group, scope, protected: false, ...extra }
}
const CATALOG: Perm[] = [
perm('project.create', '创建项目', '项目入口', 'system'),
perm('project.member.manage', '管理项目成员', '项目入口', 'project'),
perm('project.override.set', '设置项目覆盖', '项目入口', 'project'),
perm('document.read', '读取文档', '文档动作', 'project'),
perm('document.author', '编制文档', '文档动作', 'project'),
perm('document.approve', '批准文档', '文档动作', 'project'),
perm('export.create', '创建导出', '平台动作', 'project'),
perm('audit.read', '读取审计', '平台动作', 'project'),
perm('system.users.manage', '管理用户账号', '系统动作', 'system', { protected: true }),
]
function emptyProfile(name = '') {
return { name, phone: '', email: '', staffNo: '', department: '', notes: '' }
}
function account(userId: string, owner: string, profile = emptyProfile(), overrides: Partial<Account> = {}): Account {
return { userId, owner, profile, profileVersion: 1, enabled: true, stateVersion: 3, isAdmin: false, passwordSet: true, initializationState: 'done', initializationGeneration: 0, ...overrides }
}
function rp(actionId: string, allowed = false, capAllowed = false): RolePerm {
return { actionId, allowed, capAllowed, permissionVersion: 1 }
}
/** 可变角色/绑定状态:一个 fixture 贯穿用例内全部请求,支持版本化 409。 */
function world(me: Me) {
const roles: Role[] = [
{ roleId: 'role_eng', name: '软件工程师', description: '研发职位', enabled: true, roleVersion: 3, bindingCount: 2, permissions: [rp('project.create', true, true), rp('document.read', true, true)] },
{ roleId: 'role_ops', name: '运维工程师', description: '运维职位', enabled: true, roleVersion: 1, bindingCount: 0, permissions: [] },
{ roleId: 'role_review', name: '审核工程师', description: '质量职位', enabled: false, roleVersion: 2, bindingCount: 1, permissions: [rp('document.read', true, true), rp('document.approve', false, true)] },
]
const bindings: Record<string, string> = { u1: 'role_eng', u3: 'role_eng' }
const users: Account[] = [
account('u1', 'alice', { ...emptyProfile('张三'), phone: '13800000001', staffNo: 'A001' }),
account('u2', 'bob'),
account('u3', 'carol', { ...emptyProfile('李四'), email: 'carol@example.com' }),
]
const grants: Record<string, { actions: string[]; authorityEpoch: number }> = {}
const calls: { method: string; path: string; query: Record<string, string>; body?: Record<string, unknown> }[] = []
const holds = new Map<string, { started: ReturnType<typeof deferred>; release: ReturnType<typeof deferred> }>()
const failNext = new Map<string, number>()
let grantEpoch = 5
const PAGE = 2
const visible = (q: URLSearchParams): Role[] => roles.filter(role => {
const status = q.get('status') ?? 'all'
if (status === 'enabled' && !role.enabled) return false
if (status === 'disabled' && role.enabled) return false
const action = q.get('action') ?? ''
if (action && !role.permissions.some(item => item.actionId === action && item.allowed)) return false
const query = (q.get('query') ?? '').trim().toLocaleLowerCase()
if (!query) return true
const fields = [role.name, role.description, role.roleId].map(value => value.toLocaleLowerCase())
return (q.get('mode') ?? 'fuzzy') === 'exact' ? fields.some(value => value === query) : fields.some(value => value.includes(query))
})
const recount = () => {
for (const role of roles) role.bindingCount = Object.values(bindings).filter(roleId => roleId === role.roleId).length
}
const notFound = () => ({ status: 404, contentType: 'application/json', body: JSON.stringify({ error: 'not found' }) })
const deny = (route: Route, status: number) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify({ error: `code:${status}` }) })
const ok = (route: Route, payload: unknown) => route.fulfill({ contentType: 'application/json', body: JSON.stringify(payload) })
return {
roles, bindings, users, grants, calls,
hold(key: string) {
const pending = { started: deferred(), release: deferred() }
holds.set(key, pending)
return pending
},
failNextTimes(path: string, times: number) { failNext.set(path, times) },
async route(route: Route) {
const request = route.request()
const url = new URL(request.url())
const path = decodeURIComponent(url.pathname)
const method = request.method()
const raw = request.postData()
calls.push({ method, path, query: Object.fromEntries(url.searchParams), body: raw ? JSON.parse(raw) as Record<string, unknown> : undefined })
const hold = holds.get(`${method} ${path}`)
if (hold) { holds.delete(`${method} ${path}`); hold.started.resolve(); await hold.release.promise }
const fails = failNext.get(path) ?? 0
if (fails > 0) { failNext.set(path, fails - 1); return route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: 'boom' }) }) }
if (path === '/api/health') return ok(route, { ok: true })
if (path === '/api/me') return ok(route, me)
if (path === '/api/projects') return ok(route, [])
if (path === '/api/settings') return ok(route, { schema: [], defaults: {}, user: {}, project: {} })
if (path === '/api/commands') return ok(route, [])
if (path === '/api/admin-zone/sections') return ok(route, [])
if (path.endsWith('/cards')) return ok(route, { cards: [] })
if (path === '/api/admin/permissions/catalog') return ok(route, { permissions: CATALOG })
if (path === '/api/admin/roles' && method === 'GET') {
const list = visible(url.searchParams)
const start = Number(url.searchParams.get('cursor') ?? 0) || 0
const page = list.slice(start, start + PAGE)
return ok(route, { roles: page, nextCursor: start + PAGE < list.length ? String(start + PAGE) : '' })
}
if (path === '/api/admin/roles' && method === 'POST') {
const body = request.postDataJSON() as { name: string; description: string }
const role: Role = { roleId: `role_${body.name}`, name: body.name, description: body.description, enabled: true, roleVersion: 1, bindingCount: 0, permissions: [] }
roles.unshift(role)
return ok(route, role)
}
const roleMatch = path.match(/^\/api\/admin\/roles\/([^/]+)(\/permissions)?$/)
if (roleMatch) {
const role = roles.find(item => item.roleId === roleMatch[1])
if (!role) return route.fulfill(notFound())
if (!roleMatch[2]) {
if (method === 'GET') return ok(route, role)
const body = request.postDataJSON() as { expectedRoleVersion: number; name?: string; description?: string; enabled?: boolean }
if (body.expectedRoleVersion !== role.roleVersion) return deny(route, 409)
if (body.name !== undefined) role.name = body.name
if (body.description !== undefined) role.description = body.description
if (body.enabled !== undefined) role.enabled = body.enabled
role.roleVersion += 1
return ok(route, role)
}
if (method === 'PUT') {
const body = request.postDataJSON() as { expectedRoleVersion: number; changes: { actionId: string; allowed: boolean; capAllowed: boolean }[] }
if (body.expectedRoleVersion !== role.roleVersion) return deny(route, 409)
role.permissions = body.changes.map(change => ({ ...change, permissionVersion: 1 }))
role.roleVersion += 1
return ok(route, { receiptId: 'r1', roleVersion: role.roleVersion, authorityEpoch: 6 })
}
}
if (path === '/api/admin/users/search' && method === 'GET') {
const q = url.searchParams
const query = (q.get('query') ?? '').trim().toLocaleLowerCase()
const fields = (q.get('fields') ?? 'owner,name,userId').split(',')
const status = q.get('status') ?? 'all'
const list = users.filter(item => {
if (status === 'enabled' && !item.enabled) return false
if (status === 'disabled' && item.enabled) return false
if (!query) return true
const values = (fields.includes('default') ? ['owner', 'name', 'userId'] : fields)
.map(field => field === 'userId' ? item.userId : field === 'owner' ? item.owner : item.profile[field as 'name'] ?? '')
return (q.get('mode') ?? 'fuzzy') === 'exact' ? values.some(value => value.toLowerCase() === query) : values.some(value => value.toLowerCase().includes(query))
})
return ok(route, { accounts: list, nextCursor: '', directoryVersion: 'd1' })
}
if (path === '/api/admin/users' && method === 'POST') {
const body = request.postDataJSON() as { owner: string; profile: Account['profile'] }
if (users.some(item => item.owner === body.owner)) return deny(route, 409)
const created = account(`u-${body.owner}`, body.owner, { ...emptyProfile(), ...body.profile })
users.push(created)
return ok(route, created)
}
const userMatch = path.match(/^\/api\/admin\/users\/([^/]+)(\/role|\/profile|\/state|\/system-grants)?$/)
if (userMatch) {
const user = users.find(item => item.userId === userMatch[1])
if (!user) return route.fulfill(notFound())
const sub = userMatch[2] ?? ''
if (sub === '' && method === 'GET') return ok(route, user)
if (sub === '/profile' && method === 'PATCH') {
const body = request.postDataJSON() as { expectedProfileVersion: number; patch: Record<string, string> }
if (body.expectedProfileVersion !== user.profileVersion) return deny(route, 409)
user.profile = { ...user.profile, ...body.patch }
user.profileVersion += 1
return ok(route, user)
}
if (sub === '/state' && method === 'PUT') {
const body = request.postDataJSON() as { expectedStateVersion: number; enabled: boolean }
if (body.expectedStateVersion !== user.stateVersion) return deny(route, 409)
user.enabled = body.enabled
user.stateVersion += 1
return ok(route, user)
}
if (sub === '/role' && method === 'GET') {
const role = roles.find(item => item.roleId === bindings[user.userId])
return ok(route, role ? {
binding: { bindingId: `b-${user.userId}`, userId: user.userId, roleId: role.roleId, active: true, bindingVersion: 1, assignedBy: 'root', assignedAt: '', userStateVersion: user.stateVersion, authorityEpoch: grantEpoch },
role,
} : {})
}
if (sub === '/role' && method === 'PUT') {
const body = request.postDataJSON() as { roleId: string; expectedStateVersion: number }
if (body.expectedStateVersion !== user.stateVersion) return deny(route, 409)
bindings[user.userId] = body.roleId
recount()
return ok(route, { bindingId: `b-${user.userId}`, userId: user.userId, roleId: body.roleId, active: true, bindingVersion: 2, assignedBy: 'root', assignedAt: '', userStateVersion: user.stateVersion, authorityEpoch: grantEpoch })
}
if (sub === '/system-grants' && method === 'GET') {
return ok(route, grants[user.userId] ?? { actions: [], authorityEpoch: grantEpoch })
}
if (sub === '/system-grants' && method === 'PUT') {
const body = request.postDataJSON() as { actions: string[]; expectedAuthorityEpoch: number }
if (body.expectedAuthorityEpoch !== (grants[user.userId]?.authorityEpoch ?? grantEpoch)) return deny(route, 409)
const hadManage = (grants[user.userId]?.actions ?? []).includes('system.permissions.manage')
if (hadManage && !body.actions.includes('system.permissions.manage')) {
return route.fulfill({ status: 400, contentType: 'application/json', body: JSON.stringify({ error: 'cannot remove the final permission manager' }) })
}
grants[user.userId] = { actions: body.actions, authorityEpoch: ++grantEpoch }
return ok(route, { receiptId: 'g1', authorityEpoch: grantEpoch })
}
}
// 未点名端点(profiles/plugins/conversations/workspaces 等启动探测)按空列表兜底。
return route.fulfill({ contentType: 'application/json', body: '[]' })
},
}
}
type World = ReturnType<typeof world>
async function openAdmin(page: Page, me: Me): Promise<World> {
const w = world(me)
await page.route('**/api/**', route => { void w.route(route) })
await page.goto('/')
await page.locator('.tsb-user').click()
await page.getByRole('button', { name: '系统管理…' }).click()
await expect(page.getByRole('dialog', { name: '系统管理' })).toBeVisible()
return w
}
const openAdminAsAdmin = (page: Page) => openAdmin(page, { owner: 'root', name: '管理员', auth: true, isAdmin: true, systemActions: [...FULL_ACTIONS] })
async function openRolesSection(page: Page) {
await page.getByRole('button', { name: '角色与权限', exact: true }).click()
// 面板加载带 180ms 防抖,等待首笔角色清单返回后再交互。
await expect(page.locator('.rp-role-list button', { hasText: '软件工程师' })).toBeVisible({ timeout: 10_000 })
await page.getByRole('button', { name: '搜索与过滤' }).click()
}
async function openUsersSection(page: Page) {
await page.getByRole('button', { name: '用户管理', exact: true }).click()
await expect(page.locator('tr', { hasText: 'alice' })).toBeVisible({ timeout: 10_000 })
}
const engRow = (page: Page) => page.locator('.rp-role-list button', { hasText: '软件工程师' })
const reviewRow = (page: Page) => page.locator('.rp-role-list button', { hasText: '审核工程师' })
test('DR10 细粒度管理员:仅用户查看权时看不到角色分区,也不发出角色请求', async ({ page }) => {
const w = await openAdmin(page, { owner: 'mgr', name: '用户管理员', auth: true, isAdmin: false, systemActions: ['system.users.view'] })
await expect(page.getByRole('button', { name: '用户管理', exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: '角色与权限', exact: true })).toHaveCount(0)
await openUsersSection(page)
await page.locator('tr', { hasText: 'alice' }).getByRole('button', { name: '查看与管理' }).click()
await expect(page.getByText(/当前角色 无角色查看权限/)).toBeVisible()
await expect(page.getByLabel('用户生效角色')).toHaveCount(0)
expect(w.calls.filter(call => call.path.endsWith('/role'))).toEqual([])
expect(w.calls.filter(call => call.path === '/api/admin/permissions/catalog')).toEqual([])
})
test('DR10 权限管理者无查看权:出现守护提示且不拉取目录', async ({ page }) => {
const w = await openAdmin(page, { owner: 'mgr', name: '权限管理者', auth: true, isAdmin: false, systemActions: ['system.users.view', 'system.permissions.manage'] })
await openUsersSection(page)
await page.locator('tr', { hasText: 'alice' }).getByRole('button', { name: '查看与管理' }).click()
await expect(page.getByText('需要同时具备系统权限查看权,才能安全编辑现有授权')).toBeVisible()
await expect(page.getByRole('button', { name: '明确系统权限' })).toHaveCount(0)
expect(w.calls.filter(call => call.path === '/api/admin/permissions/catalog')).toEqual([])
})
test('DR6 创建带描述的角色,后续修改保持稳定 roleId', async ({ page }) => {
const w = await openAdminAsAdmin(page)
await openRolesSection(page)
await page.getByLabel('新角色名称').fill('数据工程师')
await page.getByLabel('新角色描述').fill('数据管道职位')
await page.getByRole('button', { name: '创建角色', exact: true }).click()
await expect(page.getByRole('status')).toContainText('已创建角色 数据工程师')
await page.getByLabel('角色描述', { exact: true }).fill('数据管道与质量职位')
await page.getByRole('button', { name: '保存资料', exact: true }).click()
await expect(page.getByRole('status')).toContainText('角色资料已保存')
expect(w.calls.filter(call => call.method === 'POST' && call.path === '/api/admin/roles')).toHaveLength(1)
const patches = w.calls.filter(call => call.method === 'PATCH' && call.path.startsWith('/api/admin/roles/'))
expect(patches).toHaveLength(1)
expect(patches[0].path).toBe('/api/admin/roles/role_数据工程师')
})
test('DR6 停用角色需显式确认;取消不发出任何请求', async ({ page }) => {
const w = await openAdminAsAdmin(page)
await openRolesSection(page)
await engRow(page).click()
await page.getByRole('button', { name: '停用角色', exact: true }).click()
const dialog = page.getByRole('alertdialog', { name: '确认停用角色 软件工程师' })
await expect(dialog).toContainText('软件工程师')
await expect(dialog).toContainText('2 个用户绑定')
await expect(dialog).toContainText('不再授予权限')
await dialog.getByRole('button', { name: '取消', exact: true }).click()
await expect(dialog).toHaveCount(0)
expect(w.calls.filter(call => call.method === 'PATCH' && call.path === '/api/admin/roles/role_eng')).toEqual([])
await page.getByRole('button', { name: '停用角色', exact: true }).click()
await page.getByRole('alertdialog').getByRole('button', { name: '确认停用', exact: true }).click()
await expect(page.getByRole('status')).toContainText('角色已停用,现有绑定不再授予权限')
const patches = w.calls.filter(call => call.method === 'PATCH' && call.path === '/api/admin/roles/role_eng')
expect(patches).toHaveLength(1)
expect(patches[0].body).toMatchObject({ expectedRoleVersion: 3, enabled: false })
})
test('DR6 停用版本冲突:刷新最新状态并保留重试上下文', async ({ page }) => {
const w = await openAdminAsAdmin(page)
await openRolesSection(page)
await engRow(page).click()
// 并发方先推进版本,首次确认停用必然 409。
w.roles.find(role => role.roleId === 'role_eng')!.roleVersion = 4
await page.getByRole('button', { name: '停用角色', exact: true }).click()
await page.getByRole('alertdialog').getByRole('button', { name: '确认停用', exact: true }).click()
await expect(page.getByRole('alert')).toContainText('角色状态版本冲突')
await page.getByRole('button', { name: '停用角色', exact: true }).click()
await page.getByRole('alertdialog').getByRole('button', { name: '确认停用', exact: true }).click()
await expect(page.getByRole('status')).toContainText('角色已停用')
const patches = w.calls.filter(call => call.method === 'PATCH' && call.path === '/api/admin/roles/role_eng')
expect(patches).toHaveLength(2)
expect(patches[0].body).toMatchObject({ expectedRoleVersion: 3 })
expect(patches[1].body).toMatchObject({ expectedRoleVersion: 4 })
expect(w.calls.some(call => call.method === 'GET' && call.path === '/api/admin/roles/role_eng')).toBe(true)
})
test('DR9 过滤、翻页与视图切换按 roleId 保留草稿且永不串角色', async ({ page }) => {
const w = await openAdminAsAdmin(page)
await openRolesSection(page)
await engRow(page).click()
await page.getByLabel('编制文档 默认允许').check()
await expect(page.getByText('变更预览(1)')).toBeVisible()
// 切到第二页的审核工程师:不带走软件工程师的草稿。
await page.getByRole('button', { name: '下一页角色', exact: true }).click()
await reviewRow(page).click()
await expect(page.getByText('变更预览(0)')).toBeVisible()
await expect(page.getByLabel('编制文档 默认允许')).not.toBeChecked()
// 切回软件工程师:草稿原样保留。
await page.getByRole('button', { name: '上一页角色', exact: true }).click()
await engRow(page).click()
await expect(page.getByText('变更预览(1)')).toBeVisible()
await expect(page.getByLabel('编制文档 默认允许')).toBeChecked()
// 状态过滤触发重载:草稿仍在。
await page.getByLabel('角色状态').selectOption('disabled')
await expect(reviewRow(page)).toBeVisible()
await page.getByLabel('角色状态').selectOption('all')
await engRow(page).click()
await expect(page.getByLabel('编制文档 默认允许')).toBeChecked()
// 单角色 ↔ 矩阵切换保留;矩阵中当前角色的勾选状态一致。
await page.getByRole('button', { name: '角色矩阵', exact: true }).click()
await expect(page.getByLabel('软件工程师 编制文档 默认允许')).toBeChecked()
await page.getByRole('button', { name: '单角色配置', exact: true }).click()
await expect(page.getByLabel('编制文档 默认允许')).toBeChecked()
expect(w.calls.filter(call => call.path === '/api/admin/roles/role_ops')).toEqual([])
})
test('DR9 离开管理区须显式决定;确认离开后草稿被放弃', async ({ page }) => {
await openAdminAsAdmin(page)
await openRolesSection(page)
await engRow(page).click()
await page.getByLabel('编制文档 默认允许').check()
const dialogs: string[] = []
let leave = false
page.on('dialog', async dialog => {
dialogs.push(dialog.message())
if (dialog.type() === 'confirm') leave ? await dialog.accept() : await dialog.dismiss()
else await dialog.accept()
})
await page.getByRole('button', { name: '返回工作台' }).click()
expect(dialogs).toHaveLength(1)
expect(dialogs[0]).toContain('软件工程师')
await expect(page.getByRole('dialog', { name: '系统管理' })).toBeVisible()
leave = true
await page.getByRole('button', { name: '返回工作台' }).click()
await expect(page.getByRole('dialog', { name: '系统管理' })).toHaveCount(0)
// 重新进入后草稿不复存在(离开即放弃)。
await page.locator('.tsb-user').click()
await page.getByRole('button', { name: '系统管理…' }).click()
await openRolesSection(page)
await engRow(page).click()
await expect(page.getByText('变更预览(0)')).toBeVisible()
})
test('DR9 409 只重放同一角色草稿;另一角色草稿不受影响', async ({ page }) => {
const w = await openAdminAsAdmin(page)
await openRolesSection(page)
await engRow(page).click()
await page.getByLabel('编制文档 默认允许').check()
await page.getByRole('button', { name: '下一页角色', exact: true }).click()
await reviewRow(page).click()
await page.getByLabel('批准文档 默认允许').check()
// 切回软件工程师再保存:并发方已推进版本并改变服务端基线,保存触发 409 rebase。
await page.getByRole('button', { name: '上一页角色', exact: true }).click()
await engRow(page).click()
const eng = w.roles.find(role => role.roleId === 'role_eng')!
eng.roleVersion = 4
eng.permissions = [rp('project.create', false, false), rp('document.read', true, true)]
await page.getByRole('button', { name: '保存权限', exact: true }).click()
await expect(page.getByRole('alert')).toContainText('权限版本冲突;已基于最新版本重放本地草稿')
expect(w.calls.filter(call => call.method === 'GET' && call.path === '/api/admin/roles/role_review')).toEqual([])
// 重放后:本地对编制文档的修改保留;再次保存基于最新版本成功。
await expect(page.getByLabel('编制文档 默认允许')).toBeChecked()
await page.getByRole('button', { name: '保存权限', exact: true }).click()
await expect(page.getByRole('status')).toContainText('角色权限已保存')
const puts = w.calls.filter(call => call.method === 'PUT' && call.path === '/api/admin/roles/role_eng/permissions')
expect(puts).toHaveLength(2)
expect(puts[0].body).toMatchObject({ expectedRoleVersion: 3 })
expect(puts[1].body).toMatchObject({ expectedRoleVersion: 4 })
expect((puts[1].body!.changes as { actionId: string; allowed: boolean }[]).find(change => change.actionId === 'document.author')).toMatchObject({ allowed: true, capAllowed: true })
// 审核工程师的草稿未被动过:切回后仍在。
await page.getByRole('button', { name: '下一页角色', exact: true }).click()
await reviewRow(page).click()
await expect(page.getByLabel('批准文档 默认允许')).toBeChecked()
})
test('DR9 矩阵批量编辑只作用于当前可见页', async ({ page }) => {
await openAdminAsAdmin(page)
await openRolesSection(page)
await page.getByRole('button', { name: '角色矩阵', exact: true }).click()
await page.locator('.rp-matrix button', { hasText: '软件工程师' }).click()
await page.getByRole('button', { name: '当前页默认全选', exact: true }).click()
for (const label of ['创建项目', '管理项目成员', '设置项目覆盖', '读取文档', '编制文档', '批准文档']) {
await expect(page.getByLabel(`软件工程师 ${label} 默认允许`)).toBeChecked()
}
await page.getByRole('button', { name: '下一组', exact: true }).click()
await expect(page.getByLabel('软件工程师 创建导出 默认允许')).not.toBeChecked()
await page.getByRole('button', { name: '上一组', exact: true }).click()
await page.getByRole('button', { name: '当前页上限关闭', exact: true }).click()
await expect(page.getByLabel('软件工程师 创建项目 默认允许')).not.toBeChecked()
// 变更预览在单角色视图:批量编辑按 roleId 保留,切回可见 2 项相对基线变化。
await page.getByRole('button', { name: '单角色配置', exact: true }).click()
await expect(page.getByText('变更预览(2)')).toBeVisible()
})
test('DR10 角色清单失败不拖垮目录加载,可经检索重试恢复', async ({ page }) => {
const w = await openAdminAsAdmin(page)
w.failNextTimes('/api/admin/roles', 1)
await page.getByRole('button', { name: '角色与权限', exact: true }).click()
await expect(page.getByRole('alert')).toContainText('500')
// 目录请求独立发出并成功(互不拖垮)。
expect(w.calls.some(call => call.path === '/api/admin/permissions/catalog')).toBe(true)
await page.getByRole('button', { name: '搜索与过滤' }).click()
await page.getByLabel('搜索角色').fill('软件')
await expect(page.locator('.rp-role-list button', { hasText: '软件工程师' })).toBeVisible({ timeout: 10_000 })
await expect(page.getByRole('alert')).toHaveCount(0)
})
test('DR10 权限目录失败不影响角色列表呈现', async ({ page }) => {
const w = await openAdminAsAdmin(page)
w.failNextTimes('/api/admin/permissions/catalog', 1)
await page.getByRole('button', { name: '角色与权限', exact: true }).click()
await expect(page.getByRole('alert')).toContainText('权限目录加载失败')
await expect(page.locator('.rp-role-list button', { hasText: '软件工程师' })).toBeVisible()
expect(w.calls.some(call => call.path === '/api/admin/roles')).toBe(true)
})
test('DR10 键盘完成核心流程,保存后焦点稳定且状态可被播报', async ({ page }) => {
await openAdminAsAdmin(page)
await openRolesSection(page)
await engRow(page).focus()
await page.keyboard.press('Enter')
await expect(page.getByLabel('角色名称', { exact: true })).toHaveValue('软件工程师')
await page.getByLabel('编制文档 默认允许').focus()
await page.keyboard.press('Space')
await expect(page.getByLabel('编制文档 默认允许')).toBeChecked()
await page.getByRole('button', { name: '保存权限', exact: true }).focus()
await page.keyboard.press('Enter')
await expect(page.getByRole('status')).toContainText('角色权限已保存')
const focused = await page.evaluate(() => (document.activeElement as HTMLElement | null)?.textContent ?? '')
expect(focused).toContain('保存权限')
})
test('DR10 窄屏矩阵横向可导航且可操作', async ({ page }) => {
await openAdminAsAdmin(page)
await page.setViewportSize({ width: 480, height: 800 })
await openRolesSection(page)
// 窄屏下视图切换按钮存在持续的布局微抖动,Playwright 稳定性检查无法通过;
// 用 DOM 事件切换视图,随后的横向滚动与勾选仍是真实交互。
await page.getByRole('button', { name: '角色矩阵', exact: true }).dispatchEvent('click')
const wrap = page.locator('.rp-matrix-wrap')
await expect(wrap).toBeVisible()
const scroll = await wrap.evaluate(el => ({ sw: el.scrollWidth, cw: el.clientWidth }))
expect(scroll.sw).toBeGreaterThan(scroll.cw)
await page.getByLabel('软件工程师 创建项目 默认允许').check()
await expect(page.getByLabel('软件工程师 创建项目 默认允许')).toBeChecked()
})
test('DR7 两人共享同一角色;换绑后仅一个生效角色且计数随之变化', async ({ page }) => {
const w = await openAdminAsAdmin(page)
await openUsersSection(page)
const openUser = async (owner: string) => {
await page.locator('tr', { hasText: owner }).getByRole('button', { name: '查看与管理' }).click()
await expect(page.getByRole('button', { name: '返回列表' })).toBeVisible()
}
await openUser('bob')
await page.getByLabel('用户生效角色').selectOption({ label: '软件工程师' })
await page.getByRole('button', { name: '替换角色', exact: true }).click()
await expect(page.getByRole('status')).toContainText('用户角色已替换')
expect(w.bindings.u2).toBe('role_eng')
await page.getByRole('button', { name: '返回列表', exact: true }).click()
await openUser('alice')
await expect(page.getByText(/当前角色 软件工程师/)).toBeVisible()
await page.getByLabel('用户生效角色').selectOption({ label: '运维工程师' })
await page.getByRole('button', { name: '替换角色', exact: true }).click()
await expect(page.getByRole('status')).toContainText('用户角色已替换')
expect(w.bindings.u1).toBe('role_ops')
expect(w.roles.find(role => role.roleId === 'role_eng')!.bindingCount).toBe(2) // bob + carol
// 用户角色视图始终单角色:GET /role 只返回一个 binding。
expect(w.calls.filter(call => call.method === 'GET' && call.path === '/api/admin/users/u1/role').length).toBeGreaterThan(0)
})
test('DR8 资料可全空创建,并支持按电话等字段检索', async ({ page }) => {
const w = await openAdminAsAdmin(page)
await openUsersSection(page)
await page.getByLabel('新用户登录名').fill('dave')
await page.getByRole('button', { name: '创建用户', exact: true }).click()
await expect(page.getByRole('status')).toContainText('已创建 dave')
const created = w.calls.find(call => call.method === 'POST' && call.path === '/api/admin/users')
expect(created!.body!.profile).toEqual(emptyProfile())
await page.getByRole('button', { name: '返回列表', exact: true }).click()
await page.getByLabel('用户搜索字段').selectOption({ label: '电话' })
await page.getByLabel('搜索用户').fill('13800000001')
await expect(page.locator('tr', { hasText: 'alice' })).toBeVisible({ timeout: 10_000 })
await expect(page.locator('tr', { hasText: 'carol' })).toHaveCount(0)
const searches = w.calls.filter(call => call.method === 'GET' && call.path === '/api/admin/users/search')
expect(searches.at(-1)!.query.fields).toContain('phone')
})
test('DR10 加载与空态:加载期间无结果无报错,过滤为空给出选择提示', async ({ page }) => {
const w = await openAdminAsAdmin(page)
await page.getByRole('button', { name: '角色与权限', exact: true }).click()
await expect(page.locator('.rp-role-list button', { hasText: '软件工程师' })).toBeVisible({ timeout: 10_000 })
await page.getByRole('button', { name: '搜索与过滤' }).click()
const pending = w.hold('GET /api/admin/roles')
await page.getByLabel('搜索角色').fill('不存在的角色')
await pending.started.promise
await expect(page.locator('.rp-role-list button').first()).toBeDisabled()
await expect(page.getByRole('alert')).toHaveCount(0)
pending.release.resolve()
await expect(page.locator('.rp-role-list button')).toHaveCount(0)
await expect(page.getByText('请选择角色')).toBeVisible()
})
// ── 活体层(IAM_E2E_LIVE=1):同一文件在真实 Backend/RDM 组合上运行真实授权路径。
// 由 Agent-System-RDM scripts/iam-u4-role-acceptance.sh 物化冻结候选源码、
// 构建内嵌产品、全新 SQLite 数据根与随机回环端口后驱动;不拦截任何路由。
const LIVE = process.env.IAM_E2E_LIVE === '1'
async function liveLogin(page: Page) {
await page.goto('/')
await page.getByPlaceholder('由管理员分配').fill(process.env.IAM_E2E_OWNER ?? '')
await page.getByPlaceholder('初始密码或常用密码').fill(process.env.IAM_E2E_PASSWORD ?? '')
await page.getByRole('button', { name: '登录', exact: true }).click()
await expect(page.locator('.tsb-user')).toBeVisible({ timeout: 15_000 })
}
async function openAdminZoneLive(page: Page) {
await page.locator('.tsb-user').click()
await page.getByRole('button', { name: '系统管理…' }).click()
await expect(page.getByRole('dialog', { name: '系统管理' })).toBeVisible()
}
test('LIVE 真实登录加载真实权限目录,创建、配置并停用角色', async ({ page }) => {
test.skip(!LIVE, 'live 层由 iam-u4-role-acceptance.sh 驱动')
test.setTimeout(90_000)
await liveLogin(page)
await openAdminZoneLive(page)
await page.getByRole('button', { name: '角色与权限', exact: true }).click()
await page.getByRole('button', { name: '搜索与过滤' }).click()
await expect(page.getByLabel('新角色名称')).toBeVisible({ timeout: 15_000 })
// 真实空库无角色:先经真实 Backend 创建,选中后权限表才渲染真实目录。
await page.getByLabel('新角色名称').fill('数据工程师')
await page.getByLabel('新角色描述').fill('活体验收职位')
await page.getByRole('button', { name: '创建角色', exact: true }).click()
await expect(page.getByRole('status')).toContainText('已创建角色 数据工程师', { timeout: 15_000 })
// 真实目录的首页可能全是受保护系统授权(复选框禁用):逐组翻到可编辑动作。
const firstDefault = page.locator('input[aria-label$="默认允许"]:not([disabled])').first()
for (let pageIdx = 0; (await firstDefault.count()) === 0 && pageIdx < 15; pageIdx++) {
await page.getByRole('button', { name: '下一组', exact: true }).click()
await page.waitForTimeout(150)
}
await expect(firstDefault).toBeVisible({ timeout: 15_000 })
await firstDefault.check()
await page.getByRole('button', { name: '保存权限', exact: true }).click()
await expect(page.getByRole('status')).toContainText('角色权限已保存', { timeout: 15_000 })
await page.getByRole('button', { name: '停用角色', exact: true }).click()
await expect(page.getByRole('alertdialog')).toContainText('数据工程师')
await page.getByRole('alertdialog').getByRole('button', { name: '确认停用', exact: true }).click()
await expect(page.getByRole('status')).toContainText('角色已停用,现有绑定不再授予权限', { timeout: 15_000 })
})
test('LIVE DR13 非成员账号被拒读取项目业务内容;创建者放行', async ({ request }) => {
test.skip(!LIVE, 'live 层由 iam-u4-role-acceptance.sh 驱动')
const base = process.env.PLAYWRIGHT_BASE_URL!
const projectId = process.env.IAM_E2E_PROJECT_ID
test.expect(projectId).toBeTruthy()
const memberList = async (owner: string, password: string) => {
await request.post(`${base}/api/auth/login`, { data: { owner, password } })
const res = await request.post(`${base}/api/commands/rdm.project.member.list`, {
data: { params: { projectId } },
})
const body = await res.json().catch(() => null)
return { ok: res.ok(), body }
}
const observer = await memberList(process.env.IAM_E2E_OBSERVER_OWNER ?? '', process.env.IAM_E2E_OBSERVER_PASSWORD ?? '')
const denied = !observer.ok || observer.body?.receipt?.state === 'rejected'
expect(denied, `observer 应被拒绝:${JSON.stringify(observer.body).slice(0, 240)}`).toBe(true)
const creator = await memberList(process.env.IAM_E2E_OWNER ?? '', process.env.IAM_E2E_PASSWORD ?? '')
const allowed = creator.ok && creator.body?.receipt?.state !== 'rejected'
expect(allowed, `创建者应放行:${JSON.stringify(creator.body).slice(0, 240)}`).toBe(true)
})
+61 -2
View File
@@ -14,6 +14,18 @@ const BASE = ''
* Registry.AddAdminZone 声明;入口不做前端权限合并,区内数据能力由组件 * Registry.AddAdminZone 声明;入口不做前端权限合并,区内数据能力由组件
* 自身命令面 fail-closed。 */ * 自身命令面 fail-closed。 */
export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number } export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number }
export interface PermissionDescriptor { actionId: string; label: string; description: string; group: string; scope: 'system' | 'project'; protected: boolean }
export interface RolePermissionRecord { actionId: string; allowed: boolean; capAllowed: boolean; permissionVersion: number }
export interface RoleRecord {
roleId: string; name: string; description: string; enabled: boolean; roleVersion: number
bindingCount: number; permissions: RolePermissionRecord[]
}
interface RoleListPage { roles: RoleRecord[]; nextCursor?: string }
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 }
/** 账户链路(2026-10-13 恢复合同 + 2026-10-14 current_required 加固): /** 账户链路(2026-10-13 恢复合同 + 2026-10-14 current_required 加固):
* passwordChangeMode 由服务端从会话派生;grant_setup 免当前密码, * passwordChangeMode 由服务端从会话派生;grant_setup 免当前密码,
@@ -33,8 +45,14 @@ export interface AccountProfile { name: string; phone: string; email: string; st
export interface AccountRecord { export interface AccountRecord {
userId: string; owner: string; profile: AccountProfile; profileVersion: number userId: string; owner: string; profile: AccountProfile; profileVersion: number
enabled?: boolean; stateVersion?: number; isAdmin?: boolean; passwordSet?: boolean enabled?: boolean; stateVersion?: number; isAdmin?: boolean; passwordSet?: boolean
initializationState?: string; initializationGeneration?: number initializationState?: string; initializationGeneration?: number; createdAt?: string
} }
/** 管理端用户目录/详情视图:账号状态与版本字段必有(乐观并发依赖 stateVersion)。 */
export interface ManagedAccountRecord extends AccountRecord {
enabled: boolean; stateVersion: number; isAdmin: boolean; passwordSet: boolean
initializationState: string; initializationGeneration: number
}
interface AccountSearchPage { accounts: ManagedAccountRecord[]; nextCursor?: string; directoryVersion: string }
export interface ExecEnvView { export interface ExecEnvView {
configured: boolean configured: boolean
@@ -60,6 +78,37 @@ async function json<T>(res: Response): Promise<T> {
} }
export const api = { export const api = {
permissionCatalog: () => auth.fetch(`${BASE}/api/admin/permissions/catalog`).then(r => json<{ permissions: PermissionDescriptor[] }>(r)).then(d => d.permissions ?? []),
listRoles: (request: { query?: string; mode?: 'exact' | 'fuzzy'; status?: 'all' | 'enabled' | 'disabled'; action?: string; cursor?: string; limit?: number } = {}) => {
const params = new URLSearchParams()
if (request.query) params.set('query', request.query)
if (request.mode) params.set('mode', request.mode)
if (request.status) params.set('status', request.status)
if (request.action) params.set('action', request.action)
if (request.cursor) params.set('cursor', request.cursor)
if (request.limit) params.set('limit', String(request.limit))
const query = params.toString()
return auth.fetch(`${BASE}/api/admin/roles${query ? `?${query}` : ''}`).then(r => json<RoleListPage>(r))
},
getRole: (roleId: string) => auth.fetch(`${BASE}/api/admin/roles/${encodeURIComponent(roleId)}`).then(r => json<RoleRecord>(r)),
createRole: (name: string, description: string) => auth.fetch(`${BASE}/api/admin/roles`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, description }),
}).then(r => json<RoleRecord>(r)),
patchRole: (roleId: string, expectedRoleVersion: number, patch: { name?: string; description?: string; enabled?: boolean }) =>
auth.fetch(`${BASE}/api/admin/roles/${encodeURIComponent(roleId)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ expectedRoleVersion, ...patch }) }).then(r => json<RoleRecord>(r)),
replaceRolePermissions: (roleId: string, expectedRoleVersion: number, changes: { actionId: string; allowed: boolean; capAllowed: boolean }[], idempotencyKey: string) =>
auth.fetch(`${BASE}/api/admin/roles/${encodeURIComponent(roleId)}/permissions`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ expectedRoleVersion, changes, idempotencyKey }) }).then(r => json<{ receiptId: string; roleVersion: number; authorityEpoch: number }>(r)),
getUserRole: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/role`).then(r => json<UserRoleView>(r)),
assignUserRole: (userId: string, roleId: string, expectedStateVersion: number, idempotencyKey: string) =>
auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/role`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ roleId, expectedStateVersion, idempotencyKey }) }).then(r => json<UserRoleBinding>(r)),
getSystemGrants: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/system-grants`).then(r => json<{ actions: string[]; authorityEpoch: number }>(r)),
replaceSystemGrants: (userId: string, actions: string[], expectedAuthorityEpoch: number, idempotencyKey: string) =>
auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/system-grants`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ actions, expectedAuthorityEpoch, idempotencyKey }) }).then(r => json<{ receiptId: string; authorityEpoch: number }>(r)),
explainSystemAction: (action: string) => auth.fetch(`${BASE}/api/authz/explain?action=${encodeURIComponent(action)}`).then(r => json<{ allowed: boolean; actionId: string; source?: string; reason?: string; authorityEpoch: number }>(r)),
listProjects: () => auth.fetch(`${BASE}/api/projects`).then(r => json<Project[]>(r)), listProjects: () => auth.fetch(`${BASE}/api/projects`).then(r => json<Project[]>(r)),
createProject: (name: string) => auth.fetch(`${BASE}/api/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(r)), createProject: (name: string) => auth.fetch(`${BASE}/api/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(r)),
renameProject: (id: string, name: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(r)), renameProject: (id: string, name: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(r)),
@@ -79,7 +128,7 @@ export const api = {
projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json<SurfaceMeta[]>(r)), projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json<SurfaceMeta[]>(r)),
/** 单页权威快照(含 identity + spec),供列表水合与预览渲染。 */ /** 单页权威快照(含 identity + spec),供列表水合与预览渲染。 */
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<SurfaceSnapshot>(r)), surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<SurfaceSnapshot>(r)),
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; sessionScope?: string; passwordChangeMode?: PasswordChangeMode; isAdmin?: boolean; mustChangePassword?: boolean; passwordSet?: boolean }>(r)), me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; sessionScope?: string; passwordChangeMode?: PasswordChangeMode; isAdmin?: boolean; mustChangePassword?: boolean; passwordSet?: boolean; systemActions?: string[] }>(r)),
// ---- 认证与会话(管理员发放制,R2)---- // ---- 认证与会话(管理员发放制,R2)----
login: (owner: string, password: string) => auth.fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, password }) }).then(async r => { login: (owner: string, password: string) => auth.fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, password }) }).then(async r => {
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`) if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
@@ -108,6 +157,16 @@ export const api = {
// 管理员建号:可提供初始密码;省略密码时由服务端生成并仅返回一次。 // 管理员建号:可提供初始密码;省略密码时由服务端生成并仅返回一次。
createAdminUser: (owner: string, name: string, password?: string) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile: { name }, ...(password ? { password } : {}) }) }).then(r => json<{ userId: string; owner: string; passwordSet: boolean; mustChangePassword: boolean; initializationState: string; initialPassword?: string }>(r)), createAdminUser: (owner: string, name: string, password?: string) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile: { name }, ...(password ? { password } : {}) }) }).then(r => json<{ userId: string; owner: string; passwordSet: boolean; mustChangePassword: boolean; initializationState: string; initialPassword?: string }>(r)),
adminResetUserPassword: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }).then(r => json<{ owner: string; temporaryPassword: string; mustChangePassword: boolean }>(r)), adminResetUserPassword: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }).then(r => json<{ owner: string; temporaryPassword: string; mustChangePassword: boolean }>(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) })
if (request.cursor) params.set('cursor', request.cursor)
return auth.fetch(`${BASE}/api/admin/users/search?${params}`).then(r => json<AccountSearchPage>(r))
},
createManagedUser: (owner: string, profile: Partial<AccountProfile>) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile }) }).then(r => json<ManagedAccountRecord>(r)),
adminGetUser: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}`).then(r => json<ManagedAccountRecord>(r)),
adminPatchProfile: (userId: string, expectedProfileVersion: number, patch: Partial<AccountProfile>) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/profile`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedProfileVersion, patch }) }).then(r => json<ManagedAccountRecord>(r)),
adminSetUserEnabled: (userId: string, expectedStateVersion: number, enabled: boolean) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/state`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedStateVersion, enabled }) }).then(r => json<ManagedAccountRecord>(r)),
// ---- 管理员:一次性登录 Key(HTTP 签发端点已被恢复提案契约替换为 410,客户端方法随之移除; // ---- 管理员:一次性登录 Key(HTTP 签发端点已被恢复提案契约替换为 410,客户端方法随之移除;
// list/revoke 仍有效,供恢复提案视图复用;兑换入口不变)---- // list/revoke 仍有效,供恢复提案视图复用;兑换入口不变)----
adminListLoginKeys: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`).then(r => json<{ keys: { id: string; owner: string; purpose: string; status: string; expiresAt: string; createdAt: string; createdBy?: string }[] }>(r)).then(d => d.keys ?? []), adminListLoginKeys: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`).then(r => json<{ keys: { id: string; owner: string; purpose: string; status: string; expiresAt: string; createdAt: string; createdBy?: string }[] }>(r)).then(d => d.keys ?? []),
+231
View File
@@ -0,0 +1,231 @@
import { useEffect, useRef, useState } from 'react'
import {
api, type AccountProfile, type ManagedAccountRecord, type PermissionDescriptor, type RoleRecord, type UserRoleView,
} from '../api'
import { useStore } from '../store'
import { Collapsible, Icon } from '@ui'
import { hasSystemAction } from './RolePermissionsPanel'
const emptyProfile = (): AccountProfile => ({ name: '', phone: '', email: '', staffNo: '', department: '', notes: '' })
const fields: { key: keyof AccountProfile; label: string }[] = [
{ key: 'name', label: '姓名' }, { key: 'phone', label: '电话' }, { key: 'email', label: '邮箱' },
{ key: 'staffNo', label: '工号' }, { key: 'department', label: '部门' }, { key: 'notes', label: '备注' },
]
function ProfileInputs({ profile, onChange, prefix }: { profile: AccountProfile; onChange: (value: AccountProfile) => void; prefix: string }) {
return <div className="ad-create">{fields.map(field => <div className="ad-row" key={field.key}>
<label className="lg-label" htmlFor={`${prefix}-${field.key}`}>{field.label}(可空)</label>
<input id={`${prefix}-${field.key}`} className="lg-input" value={profile[field.key]}
onChange={event => onChange({ ...profile, [field.key]: event.target.value })} />
</div>)}</div>
}
export function AccountUsersPanel() {
const me = useStore(state => state.me)
const allowed = (action: string) => !!me?.isAdmin || hasSystemAction(me?.systemActions, action)
const canViewRoles = allowed('system.roles.view')
const canAssignRoles = canViewRoles && allowed('system.roles.assign')
const canViewGrants = allowed('system.permissions.view')
const canManageGrants = canViewGrants && allowed('system.permissions.manage')
const [accounts, setAccounts] = useState<ManagedAccountRecord[] | null>(null)
const [selected, setSelected] = useState<ManagedAccountRecord | null>(null)
const [roleView, setRoleView] = useState<UserRoleView | null>(null)
const [roles, setRoles] = useState<RoleRecord[]>([])
const [roleQuery, setRoleQuery] = useState('')
const [roleCursor, setRoleCursor] = useState('')
const [roleTrail, setRoleTrail] = useState<string[]>([])
const [nextRoleCursor, setNextRoleCursor] = useState('')
const [roleLoading, setRoleLoading] = useState(false)
const [catalog, setCatalog] = useState<PermissionDescriptor[]>([])
const [grants, setGrants] = useState<string[]>([])
const [authorityEpoch, setAuthorityEpoch] = useState(0)
const [draft, setDraft] = useState<AccountProfile>(emptyProfile)
const [newOwner, setNewOwner] = useState('')
const [newProfile, setNewProfile] = useState<AccountProfile>(emptyProfile)
const [query, setQuery] = useState('')
const [mode, setMode] = useState<'exact' | 'fuzzy'>('fuzzy')
const [field, setField] = useState('default')
const [status, setStatus] = useState<'all' | 'enabled' | 'disabled'>('all')
const [cursor, setCursor] = useState('')
const [trail, setTrail] = useState<string[]>([])
const [nextCursor, setNextCursor] = useState('')
const [selectedRoleId, setSelectedRoleId] = useState('')
const [busy, setBusy] = useState(false)
const [notice, setNotice] = useState('')
const [err, setErr] = useState('')
const sequence = useRef(0)
const roleSequence = useRef(0)
const search = async (pageCursor = cursor) => {
const request = ++sequence.current
try {
const page = await api.searchAdminUsers({ query: query.trim(), mode, status,
fields: field === 'default' ? ['owner', 'name', 'userId'] : [field], cursor: pageCursor, limit: 20 })
if (request !== sequence.current) return
setAccounts(page.accounts ?? []); setNextCursor(page.nextCursor ?? '')
} catch (error) { if (request === sequence.current) setErr(String(error)) }
}
useEffect(() => {
const timer = window.setTimeout(() => { void search() }, 180)
return () => { window.clearTimeout(timer); sequence.current++ }
}, [query, mode, field, status, cursor])
const open = async (account: ManagedAccountRecord) => {
const request = ++sequence.current
roleSequence.current++
setRoleQuery(''); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(false)
setSelected(null); setRoleView(null); setErr(''); setNotice('')
const detailResult = await Promise.allSettled([api.adminGetUser(account.userId)])
if (request !== sequence.current) return
if (detailResult[0].status === 'rejected') { setErr(String(detailResult[0].reason)); return }
const detail = detailResult[0].value
setSelected(detail); setDraft(detail.profile)
const [roleResult, catalogResult, grantsResult] = await Promise.allSettled([
canViewRoles ? api.getUserRole(account.userId) : Promise.resolve(null),
canViewGrants ? api.permissionCatalog() : Promise.resolve(null),
canViewGrants ? api.getSystemGrants(account.userId) : Promise.resolve(null),
])
if (request !== sequence.current) return
if (roleResult.status === 'fulfilled' && roleResult.value) {
const currentRole = roleResult.value as UserRoleView
setRoleView(currentRole); setSelectedRoleId(currentRole.role?.roleId ?? '')
} else { setRoleView(null); setSelectedRoleId('') }
if (catalogResult.status === 'fulfilled' && catalogResult.value) setCatalog(catalogResult.value)
else setCatalog([])
if (grantsResult.status === 'fulfilled' && grantsResult.value) {
setGrants(grantsResult.value.actions ?? []); setAuthorityEpoch(grantsResult.value.authorityEpoch)
} else { setGrants([]); setAuthorityEpoch(0) }
}
useEffect(() => {
if (!selected || !canViewRoles) { setRoles([]); setRoleLoading(false); return }
const request = ++roleSequence.current
setRoleLoading(true); setNextRoleCursor('')
const timer = window.setTimeout(() => {
void api.listRoles({ query: roleQuery.trim(), mode: 'fuzzy', status: 'enabled', cursor: roleCursor, limit: 50 })
.then(page => {
if (request !== roleSequence.current) return
setRoles(page.roles ?? []); setNextRoleCursor(page.nextCursor ?? ''); setRoleLoading(false)
})
.catch(error => { if (request === roleSequence.current) { setErr(String(error)); setRoleLoading(false) } })
}, 180)
return () => { window.clearTimeout(timer) }
}, [selected?.userId, roleQuery, roleCursor, canViewRoles])
const refreshFirst = async () => {
setCursor(''); setTrail([]); setNextCursor('')
await search('')
}
const create = async () => {
if (!newOwner.trim()) return
setBusy(true); setErr(''); setNotice('')
try {
const account = await api.createManagedUser(newOwner.trim(), newProfile)
setNewOwner(''); setNewProfile(emptyProfile())
await refreshFirst(); await open(account)
setNotice(`已创建 ${account.owner};账号资料可空,角色另行分配`)
} catch (error) { setErr(String(error).includes('409') ? '登录名已存在' : String(error)) }
finally { setBusy(false) }
}
const saveProfile = async () => {
if (!selected) return
const patch: Partial<AccountProfile> = {}
for (const item of fields) if (draft[item.key] !== selected.profile[item.key]) patch[item.key] = draft[item.key]
if (!Object.keys(patch).length) { setNotice('资料没有变化'); return }
setBusy(true); setErr(''); setNotice('')
try {
const updated = await api.adminPatchProfile(selected.userId, selected.profileVersion, patch)
setSelected(updated); setDraft(updated.profile); await refreshFirst(); setNotice('用户资料已保存')
} catch (error) { setErr(String(error).includes('409') ? '资料版本冲突,请重新打开用户' : String(error)) }
finally { setBusy(false) }
}
const assignRole = async () => {
if (!selected || !selectedRoleId) return
setBusy(true); setErr(''); setNotice('')
try {
await api.assignUserRole(selected.userId, selectedRoleId, selected.stateVersion, crypto.randomUUID())
await open(selected); await refreshFirst(); setNotice('用户角色已替换')
} catch (error) { setErr(String(error).includes('409') ? '用户状态已变化,请重新打开用户' : String(error)) }
finally { setBusy(false) }
}
const saveGrants = async () => {
if (!selected) return
setBusy(true); setErr(''); setNotice('')
try {
const result = await api.replaceSystemGrants(selected.userId, grants, authorityEpoch, crypto.randomUUID())
setAuthorityEpoch(result.authorityEpoch); setNotice('系统权限已保存')
} catch (error) { setErr(String(error).includes('final permission manager') ? '不能撤销最后一个有效权限管理者' : String(error)) }
finally { setBusy(false) }
}
const toggleState = async () => {
if (!selected) return
setBusy(true); setErr(''); setNotice('')
try {
const updated = await api.adminSetUserEnabled(selected.userId, selected.stateVersion, !selected.enabled)
setSelected(updated); await refreshFirst(); setNotice(updated.enabled ? '账号已启用' : '账号已停用')
} catch (error) { setErr(String(error)) } finally { setBusy(false) }
}
if (!allowed('system.users.view')) return <p className="ad-hint">当前账号没有查看用户的系统权限</p>
return <div>
{allowed('system.users.create') && <div className="ad-create">
<div className="ad-row"><input className="lg-input" aria-label="新用户登录名" placeholder="登录名" value={newOwner} onChange={event => setNewOwner(event.target.value)} />
<button className="lg-submit ad-create-btn" disabled={busy || !newOwner.trim()} onClick={create}>创建用户</button></div>
<Collapsible id="new-user-profile" trigger="可选资料:姓名、电话、邮箱、工号、部门、备注">
<ProfileInputs profile={newProfile} onChange={setNewProfile} prefix="new-user" />
</Collapsible>
</div>}
{notice && <div className="ad-notice" role="status"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
{selected ? <div className="rp-editor">
<div className="ad-row"><button className="ad-mini" onClick={() => { sequence.current++; setSelected(null) }}>返回列表</button>
<b>{selected.owner}</b><span className="ad-dim ad-mono">{selected.userId}</span></div>
<p className="ad-hint">{selected.enabled ? '有效账号' : '停用账号'} · 当前角色 {canViewRoles ? (roleView?.role?.name ?? '未分配') : '无角色查看权限'}</p>
{allowed('system.users.edit') && <Collapsible id={`user-profile-${selected.userId}`} trigger="编辑可选资料">
<ProfileInputs profile={draft} onChange={setDraft} prefix="user-profile" />
<button className="lg-submit ad-create-btn" disabled={busy} onClick={saveProfile}>保存资料</button>
</Collapsible>}
<div className="ad-row">
{canAssignRoles && <><select className="lg-input" aria-label="用户生效角色" value={selectedRoleId} onChange={event => setSelectedRoleId(event.target.value)}>
<option value="">请选择角色</option>{roles.map(role => <option key={role.roleId} value={role.roleId}>{role.name}</option>)}
</select><button className="ad-mini" disabled={busy || !selectedRoleId || selectedRoleId === roleView?.role?.roleId} onClick={assignRole}>替换角色</button></>}
{allowed('system.users.disable') && <button className="ad-mini" disabled={busy} onClick={toggleState}>{selected.enabled ? '停用账号' : '启用账号'}</button>}
</div>
{canAssignRoles && <div className="ad-row">
<input className="lg-input" aria-label="搜索可分配角色" placeholder="搜索有效角色" value={roleQuery}
onChange={event => { setRoleQuery(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }} />
<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>}
{canViewGrants && <Collapsible id={`system-grants-${selected.userId}`} trigger="明确系统权限">
<div className="rp-grants">{catalog.filter(item => item.scope === 'system').map(item => <label className="ad-check" key={item.actionId}>
<input type="checkbox" checked={grants.includes(item.actionId)} disabled={!canManageGrants || busy} onChange={event => setGrants(current => event.target.checked
? [...new Set([...current, item.actionId])] : current.filter(value => value !== item.actionId))} />{item.label}<span className="ad-dim ad-mono">{item.actionId}</span>
</label>)}</div>
{canManageGrants && <button className="lg-submit ad-create-btn" disabled={busy} onClick={saveGrants}>保存系统权限</button>}
</Collapsible>}
{!canViewGrants && allowed('system.permissions.manage') && <p className="ad-hint">需要同时具备系统权限查看权,才能安全编辑现有授权</p>}
</div> : <>
<div className="ad-row rp-filters">
<input className="lg-input" aria-label="搜索用户" placeholder="搜索用户资料" value={query} onChange={event => { setQuery(event.target.value); setCursor(''); setTrail([]) }} />
<select className="lg-input" aria-label="用户匹配方式" value={mode} onChange={event => setMode(event.target.value as typeof mode)}><option value="fuzzy">模糊匹配</option><option value="exact">精确匹配</option></select>
<select className="lg-input" aria-label="用户搜索字段" value={field} onChange={event => setField(event.target.value)}>
<option value="default">账号/姓名/ID</option><option value="owner">登录名</option><option value="name">姓名</option><option value="userId">userId</option>
<option value="phone">电话</option><option value="email">邮箱</option><option value="staffNo">工号</option><option value="department">部门</option><option value="notes">备注</option>
</select>
<select className="lg-input" aria-label="用户状态" value={status} onChange={event => setStatus(event.target.value as typeof status)}><option value="all">全部状态</option><option value="enabled">有效</option><option value="disabled">停用</option></select>
</div>
<table className="ad-table"><thead><tr><th>登录名</th><th>姓名</th><th>工号</th><th>状态</th><th></th></tr></thead>
<tbody>{(accounts ?? []).map(account => <tr key={account.userId}><td className="ad-mono">{account.owner}</td><td>{account.profile.name || '—'}</td>
<td>{account.profile.staffNo || '—'}</td><td>{account.enabled ? '有效' : '停用'}</td><td><button className="ad-mini" onClick={() => open(account)}>查看与管理</button></td></tr>)}</tbody></table>
<div className="ad-row"><button className="ad-mini" disabled={!trail.length} onClick={() => { setCursor(trail[trail.length - 1]); setTrail(trail.slice(0, -1)) }}>上一页</button>
<button className="ad-mini" disabled={!nextCursor} onClick={() => { setTrail([...trail, cursor]); setCursor(nextCursor) }}>下一页</button></div>
</>}
</div>
}
+36 -14
View File
@@ -10,7 +10,8 @@ import { EmptyState, Icon, type IconName } from '@ui'
import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel, SystemSettingsPanel } from './adminSections' import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel, SystemSettingsPanel } from './adminSections'
import { ExecEnvPanel } from './ExecEnvPanel' import { ExecEnvPanel } from './ExecEnvPanel'
import { RdmAuditWorkspace } from './RdmAuditWorkspace' import { RdmAuditWorkspace } from './RdmAuditWorkspace'
import { RdmRolesPanel } from './adminRdmSections' import { AccountUsersPanel } from './AccountUsersPanel'
import { RolePermissionsPanel, roleDraftLeaveGuard, hasSystemAction } from './RolePermissionsPanel'
import type { AdminZoneSection } from '../api' import type { AdminZoneSection } from '../api'
interface AdminSectionDef { interface AdminSectionDef {
@@ -18,6 +19,8 @@ interface AdminSectionDef {
label: string label: string
icon: IconName icon: IconName
desc: string desc: string
requiredAction?: string
rootOnly?: boolean
/** 懒渲染:进入分区才挂载(每个面板自带数据加载)。 */ /** 懒渲染:进入分区才挂载(每个面板自带数据加载)。 */
render: () => React.ReactNode render: () => React.ReactNode
} }
@@ -27,16 +30,30 @@ function ProfilesPanelWrapper() {
return <ProfilesSection profiles={profiles} /> return <ProfilesSection profiles={profiles} />
} }
/** 账号目录(按系统权限开放)+ 登录凭据与恢复提案(根管理员专属,旧 /api/admin/users 列表与密码端点)。 */
function UsersSection() {
const isAdmin = useStore(s => !!s.me?.isAdmin)
return <>
<AccountUsersPanel />
{isAdmin && <>
<div className="az-section-head" style={{ marginTop: 18 }}>
<h3>登录凭据与恢复</h3>
<p>建号并发放初始密码、重置临时密码、发起恢复提案(仅根管理员)</p>
</div>
<UsersPanel />
</>}
</>
}
const SECTIONS: AdminSectionDef[] = [ const SECTIONS: AdminSectionDef[] = [
{ key: 'users', label: '用户管理', icon: 'users', desc: '建号、发放初始密码、重置密码(管理员发放制)', render: () => <UsersPanel /> }, { key: 'users', label: '用户管理', icon: 'users', desc: '可选资料、账号状态、唯一生效角色与明确系统权限;根管理员另可发放与恢复登录凭据', requiredAction: 'system.users.view', render: () => <UsersSection /> },
// U6/KTD5:RDM 专业角色管理——命令面齐备(rdmRolesAvailable)才显示;普通基座零暴露。 { key: 'roles', label: '角色与权限', icon: 'shield', desc: '职位角色、默认权限与上限;支持单角色和角色矩阵配置', requiredAction: 'system.roles.view', render: () => <RolePermissionsPanel /> },
{ key: 'rdm-roles', label: 'RDM 角色权限', icon: 'shield', desc: '专业角色:动作默认/上限、账号绑定、停用(仅管理员)', render: () => <RdmRolesPanel /> }, { key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', rootOnly: true, render: () => <ProfilesPanelWrapper /> },
{ key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', render: () => <ProfilesPanelWrapper /> },
// P2 受管:模型/密钥/Tavily/遥测/运行基础设施的唯一写入面(Backend PR 188 系统设置 API)。 // P2 受管:模型/密钥/Tavily/遥测/运行基础设施的唯一写入面(Backend PR 188 系统设置 API)。
{ key: 'system-settings', label: '模型与服务配置', icon: 'settings', desc: '系统受管项:模型、密钥、Tavily、遥测与运行基础设施(仅管理员)', render: () => <SystemSettingsPanel /> }, { key: 'system-settings', label: '模型与服务配置', icon: 'settings', desc: '系统受管项:模型、密钥、Tavily、遥测与运行基础设施(仅管理员)', rootOnly: true, render: () => <SystemSettingsPanel /> },
{ key: 'plugins', label: '插件管理', icon: 'puzzle', desc: '已装载插件清单与热重载(装卸不影响核心)', render: () => <PluginsSection /> }, { key: 'plugins', label: '插件管理', icon: 'puzzle', desc: '已装载插件清单与热重载(装卸不影响核心)', rootOnly: true, render: () => <PluginsSection /> },
{ key: 'python-env', label: 'Python 环境', icon: 'package', desc: '基础层、扩展申请、回收和 wheel 上传(仅管理员)', render: () => <ExecEnvPanel /> }, { key: 'python-env', label: 'Python 环境', icon: 'package', desc: '基础层、扩展申请、回收和 wheel 上传(仅管理员)', rootOnly: true, render: () => <ExecEnvPanel /> },
{ key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', render: () => <PluginWorkspacesPanel /> }, { key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', rootOnly: true, render: () => <PluginWorkspacesPanel /> },
] ]
/** 声明分区 → 内置分区组件(按声明 ID 映射;AdminZone 不硬编码领域逻辑)。 */ /** 声明分区 → 内置分区组件(按声明 ID 映射;AdminZone 不硬编码领域逻辑)。 */
@@ -64,14 +81,15 @@ function DeclaredSection({ section }: { section: AdminZoneSection }) {
export function AdminZone() { export function AdminZone() {
const me = useStore(s => s.me) const me = useStore(s => s.me)
const declared = useStore(s => s.adminSections) const declared = useStore(s => s.adminSections)
const rdmRolesAvailable = useStore(s => s.rdmRolesAvailable)
const [sectionKey, setSectionKey] = useState('users') const [sectionKey, setSectionKey] = useState('users')
const isAdmin = !!me?.isAdmin const isAdmin = !!me?.isAdmin
const systemActions = me?.systemActions ?? []
const canOpenSystem = isAdmin || systemActions.length > 0
// 门控:基座系统分区仅 isAdmin(纵深防御与菜单层一致);声明分区对全部 // 门控:基座系统分区仅 isAdmin(纵深防御与菜单层一致);声明分区对全部
// 认证主体开放(区内能力由组件命令面判定)。两者皆无时不渲染。 // 认证主体开放(区内能力由组件命令面判定)。两者皆无时不渲染。
if (!isAdmin && declared.length === 0) return null if (!canOpenSystem && declared.length === 0) return null
const sections: AdminSectionDef[] = [ const sections: AdminSectionDef[] = [
...(isAdmin ? SECTIONS.filter(s => s.key !== 'rdm-roles' || rdmRolesAvailable) : []), ...SECTIONS.filter(section => section.rootOnly ? isAdmin : isAdmin || hasSystemAction(systemActions, section.requiredAction ?? '')),
...declared.map(s => ({ ...declared.map(s => ({
key: `decl:${s.id}`, label: s.title, icon: declaredIcon(s.icon), key: `decl:${s.id}`, label: s.title, icon: declaredIcon(s.icon),
desc: s.desc || '插件声明的管理分区(区内数据能力由插件命令面判定)', desc: s.desc || '插件声明的管理分区(区内数据能力由插件命令面判定)',
@@ -84,11 +102,15 @@ export function AdminZone() {
return ( return (
<div className="az" role="dialog" aria-label="系统管理"> <div className="az" role="dialog" aria-label="系统管理">
<div className="az-head"> <div className="az-head">
<button className="btn ghost" onClick={() => store.setUI({ adminZoneOpen: false })}> <button className="btn ghost" onClick={() => {
// DR9:带未保存权限草稿离开须显式决定;守卫返回 false 表示用户取消。
if (roleDraftLeaveGuard.current && !roleDraftLeaveGuard.current()) return
store.setUI({ adminZoneOpen: false })
}}>
<Icon name="chevron-down" size={14} /> 返回工作台 <Icon name="chevron-down" size={14} /> 返回工作台
</button> </button>
<span className="az-title">系统管理</span> <span className="az-title">系统管理</span>
<span className="az-sub">{isAdmin ? '仅管理员可见' : '按插件声明开放'} · 变更即时生效</span> <span className="az-sub">{isAdmin ? '根管理员' : '按系统权限开放'} · 角色与权限由服务端判定</span>
<span style={{ flex: 1 }} /> <span style={{ flex: 1 }} />
<span className="az-tenant">{me?.owner ?? ''}</span> <span className="az-tenant">{me?.owner ?? ''}</span>
</div> </div>
+386
View File
@@ -0,0 +1,386 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { api, type PermissionDescriptor, type RoleRecord } from '../api'
import {
createRolePermissionDraft, rebaseRoleDraft, updateRolePermission, validateRolePermissionDraft, visiblePatch,
type RolePermissionDraft, type RolePermissionValue,
} from '../rolePermissions'
import { Collapsible, Icon } from '@ui'
import { useStore } from '../store'
/** DR9 离开管理区守卫:面板有未保存权限草稿时注册;AdminZone「返回工作台」消费。 */
export const roleDraftLeaveGuard: { current: (() => boolean) | null } = { current: null }
export function filterRoles(
roles: RoleRecord[], query: string, mode: 'exact' | 'fuzzy', status: 'all' | 'enabled' | 'disabled',
): RoleRecord[] {
const normalized = query.trim().toLocaleLowerCase()
return roles.filter(role => {
if (status === 'enabled' && !role.enabled) return false
if (status === 'disabled' && role.enabled) return false
if (!normalized) return true
const fields = [role.name, role.description, role.roleId].map(value => value.toLocaleLowerCase())
return mode === 'exact' ? fields.some(value => value === normalized) : fields.some(value => value.includes(normalized))
})
}
export function groupPermissionPage(catalog: PermissionDescriptor[], group: string, page: number, size: number): PermissionDescriptor[] {
const grouped = catalog.filter(item => !group || item.group === group)
return grouped.slice(page * size, page * size + size)
}
export function shouldAcceptRoleResponse(activeSequence: number, responseSequence: number): boolean {
return activeSequence === responseSequence
}
export function hasSystemAction(actions: string[] | undefined, action: string): boolean {
return actions?.includes(action) ?? false
}
function roleValues(role: RoleRecord, catalog: PermissionDescriptor[]): Record<string, RolePermissionValue> {
const existing = new Map(role.permissions.map(item => [item.actionId, item]))
return Object.fromEntries(catalog.filter(item => !item.protected).map(item => {
const value = existing.get(item.actionId)
return [item.actionId, { allowed: value?.allowed ?? false, capAllowed: value?.capAllowed ?? false }]
}))
}
function roleValue(role: RoleRecord, actionId: string): RolePermissionValue {
const value = role.permissions.find(item => item.actionId === actionId)
return { allowed: value?.allowed ?? false, capAllowed: value?.capAllowed ?? false }
}
export function RolePermissionsPanel() {
const me = useStore(state => state.me)
const allowed = (action: string) => !!me?.isAdmin || hasSystemAction(me?.systemActions, action)
const canManageRoles = allowed('system.roles.manage')
const canViewPermissions = allowed('system.permissions.view')
const canManagePermissions = canViewPermissions && allowed('system.permissions.manage')
const [roles, setRoles] = useState<RoleRecord[] | null>(null)
const [catalog, setCatalog] = useState<PermissionDescriptor[]>([])
const [selectedId, setSelectedId] = useState('')
const [drafts, setDrafts] = useState<Record<string, RolePermissionDraft>>({})
const [confirmDisable, setConfirmDisable] = useState(false)
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [query, setQuery] = useState('')
const [mode, setMode] = useState<'exact' | 'fuzzy'>('fuzzy')
const [status, setStatus] = useState<'all' | 'enabled' | 'disabled'>('all')
const [group, setGroup] = useState('')
const [actionFilter, setActionFilter] = useState('')
const [roleCursor, setRoleCursor] = useState('')
const [roleTrail, setRoleTrail] = useState<string[]>([])
const [nextRoleCursor, setNextRoleCursor] = useState('')
const [page, setPage] = useState(0)
const [view, setView] = useState<'single' | 'matrix'>('single')
const [newName, setNewName] = useState('')
const [newDescription, setNewDescription] = useState('')
const [busy, setBusy] = useState(false)
const [roleLoading, setRoleLoading] = useState(false)
const [notice, setNotice] = useState('')
const [err, setErr] = useState('')
const requestSequence = useRef(0)
const selectedIdRef = useRef('')
const savePermsRef = useRef<HTMLButtonElement>(null)
selectedIdRef.current = selectedId
const selected = roles?.find(role => role.roleId === selectedId) ?? null
const draft = selected ? drafts[selected.roleId] ?? null : null
const allEditableIds = useMemo(() => catalog.filter(item => !item.protected).map(item => item.actionId), [catalog])
const dirty = !!draft && visiblePatch(draft, allEditableIds).length > 0
const groups = useMemo(() => [...new Set(catalog.map(item => item.group))], [catalog])
const filtered = roles ?? []
const pageItems = useMemo(() => groupPermissionPage(catalog, group, page, 6), [catalog, group, page])
const editablePageItems = pageItems.filter(item => !item.protected)
const changes = draft ? visiblePatch(draft, allEditableIds) : []
const load = async (pageCursor = roleCursor, refreshCatalog = false, preferredId = selectedId,
filters = { query, mode, status, action: actionFilter }) => {
const sequence = ++requestSequence.current
setRoleLoading(true)
setErr('')
const [roleResult, catalogResult] = await Promise.allSettled([
api.listRoles({ query: filters.query.trim(), mode: filters.mode, status: filters.status,
action: filters.action || undefined, cursor: pageCursor, limit: 50 }),
canViewPermissions && (refreshCatalog || catalog.length === 0) ? api.permissionCatalog() : Promise.resolve(catalog),
])
if (!shouldAcceptRoleResponse(requestSequence.current, sequence)) return
setRoleLoading(false)
const descriptors = catalogResult.status === 'fulfilled' ? catalogResult.value : catalog
if (catalogResult.status === 'fulfilled') setCatalog(descriptors)
if (roleResult.status === 'rejected') {
setErr(String(roleResult.reason))
return
}
const rolePage = roleResult.value
setRoles(rolePage.roles ?? [])
setNextRoleCursor(rolePage.nextCursor ?? '')
const nextSelected = rolePage.roles.find(role => role.roleId === preferredId) ?? rolePage.roles[0] ?? null
if (nextSelected) selectRole(nextSelected, descriptors, sequence)
else setSelectedId('')
// DR10:目录失败是部分失败态,需在角色选中(selectRole 会清空提示)之后仍可见。
if (catalogResult.status === 'rejected') setErr(prev => prev || `权限目录加载失败:${catalogResult.reason}`)
}
const selectRole = (role: RoleRecord, descriptors = catalog, sequence?: number) => {
const nextSequence = sequence ?? ++requestSequence.current
if (!shouldAcceptRoleResponse(requestSequence.current, nextSequence)) return
setSelectedId(role.roleId)
setName(role.name)
setDescription(role.description)
// DR9:草稿按 roleId 隔离保存;翻页、过滤、视图切换回到该角色时保留,
// 切换角色永不转移他人草稿。仅首次选中时以服务端值为基线。
setDrafts(prev => prev[role.roleId] ? prev
: { ...prev, [role.roleId]: createRolePermissionDraft(roleValues(role, descriptors), nextSequence) })
setNotice('')
setErr('')
}
// DR9:离开管理区须显式决定;带未保存草稿时刷新走浏览器原生确认。
useEffect(() => {
if (!dirty || !selected) { roleDraftLeaveGuard.current = null; return }
const message = `角色「${selected.name}」有未保存的权限修改,离开将放弃这些修改。确定离开吗?`
roleDraftLeaveGuard.current = () => window.confirm(message)
return () => { roleDraftLeaveGuard.current = null }
}, [dirty, selected?.roleId, selected?.name])
useEffect(() => {
if (!dirty) return
const handler = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = '' }
window.addEventListener('beforeunload', handler)
return () => window.removeEventListener('beforeunload', handler)
}, [dirty])
useEffect(() => {
setNextRoleCursor(''); setRoleLoading(true)
const timer = window.setTimeout(() => { void load(roleCursor) }, 180)
return () => { window.clearTimeout(timer); requestSequence.current++ }
}, [query, mode, status, actionFilter, roleCursor, canViewPermissions])
const create = async () => {
if (!newName.trim()) return
requestSequence.current++; setRoleLoading(false)
setBusy(true); setErr(''); setNotice('')
try {
const role = await api.createRole(newName.trim(), newDescription.trim())
setNewName(''); setNewDescription('')
setQuery(''); setStatus('all'); setActionFilter(''); setRoleCursor(''); setRoleTrail([])
setRoles(current => [role, ...(current ?? []).filter(item => item.roleId !== role.roleId)])
selectRole(role, catalog, ++requestSequence.current)
setNotice(`已创建角色 ${role.name}`)
await load('', true, role.roleId, { query: '', mode, status: 'all', action: '' })
setNotice(`已创建角色 ${role.name}`)
} catch (error) { setErr(String(error)) } finally { setBusy(false) }
}
const saveMetadata = async () => {
if (!selected) return
const roleId = selected.roleId
const operationSequence = ++requestSequence.current
setRoleLoading(false)
setBusy(true); setErr(''); setNotice('')
try {
await api.patchRole(selected.roleId, selected.roleVersion, { name: name.trim(), description: description.trim() })
await load(); setNotice('角色资料已保存')
} catch (error) {
if (String(error).includes('409')) {
try {
const fresh = await api.getRole(roleId)
if (requestSequence.current !== operationSequence || selectedIdRef.current !== roleId) return
setRoles(current => current?.some(role => role.roleId === fresh.roleId)
? current.map(role => role.roleId === fresh.roleId ? fresh : role) : [fresh, ...(current ?? [])])
setErr('角色资料版本冲突;已读取最新版本并保留本地名称与描述,请核对后重试')
} catch (refreshError) { setErr(String(refreshError)) }
} else setErr(String(error))
}
finally { setBusy(false) }
}
const setEnabled = async () => {
if (!selected) return
const roleId = selected.roleId
const operationSequence = ++requestSequence.current
setRoleLoading(false)
setBusy(true); setErr(''); setNotice('')
try {
await api.patchRole(selected.roleId, selected.roleVersion, { enabled: !selected.enabled })
await load(); setNotice(selected.enabled ? '角色已停用,现有绑定不再授予权限' : '角色已启用')
} catch (error) {
if (String(error).includes('409')) {
try {
const fresh = await api.getRole(roleId)
if (requestSequence.current !== operationSequence || selectedIdRef.current !== roleId) return
setRoles(current => current?.some(role => role.roleId === fresh.roleId)
? current.map(role => role.roleId === fresh.roleId ? fresh : role) : [fresh, ...(current ?? [])])
setErr('角色状态版本冲突;已刷新当前状态,请重新确认')
} catch (refreshError) { setErr(String(refreshError)) }
} else setErr(String(error))
} finally { setBusy(false) }
}
const rebasePermissionConflict = async (roleId: string, currentDraft: RolePermissionDraft, operationSequence: number) => {
const fresh = await api.getRole(roleId)
if (requestSequence.current !== operationSequence || selectedIdRef.current !== roleId) return
setRoles(current => current?.some(role => role.roleId === roleId)
? current.map(role => role.roleId === roleId ? fresh : role) : [fresh, ...(current ?? [])])
// DR9:409 只重放同一角色的草稿;基于最新服务端值回放本地相对基线的变化。
setDrafts(prev => ({ ...prev, [roleId]: rebaseRoleDraft(currentDraft, roleValues(fresh, catalog)) }))
setErr('权限版本冲突;已基于最新版本重放本地草稿,请核对后再次保存')
}
const savePermissions = async () => {
if (!selected || !draft) return
const roleId = selected.roleId
const operationSequence = ++requestSequence.current
setRoleLoading(false)
const invalid = validateRolePermissionDraft(draft)
if (invalid) { setErr(`${invalid.actionId} 的默认权限不能超过角色上限`); return }
const full = Object.entries(draft.values).map(([actionId, value]) => ({ actionId, ...value }))
setBusy(true); setErr(''); setNotice('')
try {
await api.replaceRolePermissions(selected.roleId, selected.roleVersion, full, crypto.randomUUID())
setDrafts(current => {
if (!(roleId in current)) return current
const next = { ...current }; delete next[roleId]; return next
})
await load(roleCursor, true, selected.roleId); setNotice('角色权限已保存')
} catch (error) {
if (String(error).includes('409')) {
try { await rebasePermissionConflict(roleId, draft, operationSequence) } catch (refreshError) { setErr(String(refreshError)) }
} else setErr(String(error))
}
finally {
setBusy(false)
// DR10:保存/冲突/失败后焦点稳定——若焦点因按钮短暂 disabled 掉回 body,则归还。
requestAnimationFrame(() => {
if (savePermsRef.current && !savePermsRef.current.disabled && document.activeElement === document.body) savePermsRef.current.focus()
})
}
}
const editValue = (actionId: string, field: 'allowed' | 'capAllowed', checked: boolean) => {
const current = drafts[selectedId]
if (!current) return
const value0 = current.values[actionId] ?? { allowed: false, capAllowed: false }
const value = field === 'allowed'
? { allowed: checked, capAllowed: checked ? true : value0.capAllowed }
: { allowed: checked ? value0.allowed : false, capAllowed: checked }
setDrafts(prev => prev[selectedId] ? { ...prev, [selectedId]: updateRolePermission(prev[selectedId], actionId, value) } : prev)
}
const editVisible = (field: 'allowed' | 'capAllowed', checked: boolean) => {
const start = drafts[selectedId]
if (!start) return
let next = start
for (const item of editablePageItems) {
const current = next.values[item.actionId] ?? { allowed: false, capAllowed: false }
const value = field === 'allowed'
? { allowed: checked, capAllowed: checked ? true : current.capAllowed }
: { allowed: checked ? current.allowed : false, capAllowed: checked }
next = updateRolePermission(next, item.actionId, value)
}
setDrafts(prev => prev[selectedId] ? { ...prev, [selectedId]: next } : prev)
}
const matrix = () => <div className="rp-matrix-wrap">
<table className="ad-table rp-matrix">
<thead><tr><th>角色</th>{pageItems.map(item => <th key={item.actionId}>{item.label}</th>)}</tr></thead>
<tbody>{filtered.map(role => <tr key={role.roleId} className={role.roleId === selectedId ? 'rp-selected' : ''}>
<td><button className="ad-mini" disabled={busy || roleLoading} onClick={() => selectRole(role)}>{role.name}</button><span className="ad-dim"> · {role.bindingCount} 人</span></td>
{pageItems.map(item => {
const value = role.roleId === selectedId && draft ? draft.values[item.actionId] : roleValue(role, item.actionId)
return <td key={item.actionId}>
{item.protected ? <span className="ad-dim">系统授权</span> : <>
<label className="ad-check"><input type="checkbox" aria-label={`${role.name} ${item.label} 默认允许`} checked={value?.allowed ?? false}
disabled={busy || roleLoading || !canManagePermissions || role.roleId !== selectedId} onChange={event => editValue(item.actionId, 'allowed', event.target.checked)} />默认</label>
<label className="ad-check"><input type="checkbox" aria-label={`${role.name} ${item.label} 角色上限`} checked={value?.capAllowed ?? false}
disabled={busy || roleLoading || !canManagePermissions || role.roleId !== selectedId} onChange={event => editValue(item.actionId, 'capAllowed', event.target.checked)} />上限</label>
</>}
</td>
})}
</tr>)}</tbody>
</table>
</div>
return <div className="rp-panel">
{canManageRoles && <div className="ad-create">
<div className="ad-row">
<input className="lg-input" aria-label="新角色名称" placeholder="角色名称" value={newName} onChange={event => setNewName(event.target.value)} />
<input className="lg-input" aria-label="新角色描述" placeholder="描述(可空)" value={newDescription} onChange={event => setNewDescription(event.target.value)} />
<button className="lg-submit ad-create-btn" disabled={busy || roleLoading || !newName.trim()} onClick={create}>创建角色</button>
</div>
</div>}
<Collapsible id="role-permission-filters" trigger="搜索与过滤">
<div className="ad-row rp-filters">
<input className="lg-input" aria-label="搜索角色" placeholder="角色名称、描述或 ID" value={query} disabled={busy} onChange={event => { setQuery(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }} />
<select className="lg-input" aria-label="角色匹配方式" value={mode} disabled={busy} onChange={event => { setMode(event.target.value as typeof mode); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }}>
<option value="fuzzy">模糊匹配</option><option value="exact">精确匹配</option>
</select>
<select className="lg-input" aria-label="角色状态" value={status} disabled={busy} onChange={event => { setStatus(event.target.value as typeof status); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }}>
<option value="all">全部状态</option><option value="enabled">启用</option><option value="disabled">停用</option>
</select>
<select className="lg-input" aria-label="权限分组" value={group} onChange={event => { setGroup(event.target.value); setPage(0) }}>
<option value="">全部权限</option>{groups.map(value => <option key={value}>{value}</option>)}
</select>
{canViewPermissions && <select className="lg-input" aria-label="按权限过滤角色" value={actionFilter} disabled={busy}
onChange={event => { setActionFilter(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }}>
<option value="">全部角色权限</option>{catalog.filter(item => !item.protected).map(item => <option key={item.actionId} value={item.actionId}>{item.label}</option>)}
</select>}
</div>
</Collapsible>
<div className="ad-row rp-view-toggle">
<button className={`ad-mini ${view === 'single' ? 'on' : ''}`} onClick={() => setView('single')}>单角色配置</button>
{canViewPermissions && <button className={`ad-mini ${view === 'matrix' ? 'on' : ''}`} onClick={() => setView('matrix')}>角色矩阵</button>}
<button className="ad-mini" disabled={page === 0} onClick={() => setPage(value => value - 1)}>上一组</button>
<button className="ad-mini" disabled={(page + 1) * 6 >= catalog.filter(item => !group || item.group === group).length} onClick={() => setPage(value => value + 1)}>下一组</button>
<button className="ad-mini" disabled={busy || roleLoading || !roleTrail.length} onClick={() => { setRoleCursor(roleTrail[roleTrail.length - 1]); setRoleTrail(roleTrail.slice(0, -1)) }}>上一页角色</button>
<button className="ad-mini" disabled={busy || roleLoading || !nextRoleCursor} onClick={() => { setRoleTrail([...roleTrail, roleCursor]); setRoleCursor(nextRoleCursor) }}>下一页角色</button>
{view === 'matrix' && canManagePermissions && <>
<button className="ad-mini" disabled={busy || roleLoading || !editablePageItems.length} onClick={() => editVisible('allowed', true)}>当前页默认全选</button>
<button className="ad-mini" disabled={busy || roleLoading || !editablePageItems.length} onClick={() => editVisible('allowed', false)}>当前页默认清除</button>
<button className="ad-mini" disabled={busy || roleLoading || !editablePageItems.length} onClick={() => editVisible('capAllowed', true)}>当前页上限全开</button>
<button className="ad-mini" disabled={busy || roleLoading || !editablePageItems.length} onClick={() => editVisible('capAllowed', false)}>当前页上限关闭</button>
</>}
</div>
{notice && <div className="ad-notice" role="status"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
{view === 'matrix' ? matrix() : <div className="rp-single">
<aside className="rp-role-list">
{(filtered).map(role => <button key={role.roleId} disabled={busy || roleLoading} className={`az-nav-item ${role.roleId === selectedId ? 'on' : ''}`} onClick={() => selectRole(role)}>
<span>{role.name}</span><span className="ad-dim">{role.bindingCount} 人</span>
</button>)}
</aside>
<section className="rp-editor">
{selected && draft ? <>
<div className="ad-row">
<input className="lg-input" aria-label="角色名称" value={name} disabled={!canManageRoles || busy || roleLoading} onChange={event => setName(event.target.value)} />
<input className="lg-input" aria-label="角色描述" value={description} disabled={!canManageRoles || busy || roleLoading} onChange={event => setDescription(event.target.value)} />
{canManageRoles && <button className="ad-mini" disabled={busy || roleLoading} onClick={saveMetadata}>保存资料</button>}
{canManageRoles && (selected.enabled
? <button className="ad-mini" disabled={busy || roleLoading} onClick={() => setConfirmDisable(true)}>停用角色</button>
: <button className="ad-mini" disabled={busy || roleLoading} onClick={setEnabled}>启用角色</button>)}
</div>
{confirmDisable && selected.enabled && <div className="rp-disable-confirm" role="alertdialog" aria-label={`确认停用角色 ${selected.name}`}>
<span>停用角色「{selected.name}」?该角色现有 {selected.bindingCount} 个用户绑定,停用后这些绑定将不再授予权限。</span>
<button className="ad-mini" disabled={busy || roleLoading} onClick={() => { setConfirmDisable(false); void setEnabled() }}>确认停用</button>
<button className="ad-mini" disabled={busy} onClick={() => setConfirmDisable(false)}>取消</button>
</div>}
{canViewPermissions ? <><table className="ad-table"><thead><tr><th>权限</th><th>默认允许</th><th>角色上限</th><th>说明</th></tr></thead>
<tbody>{pageItems.map(item => {
const value = draft.values[item.actionId] ?? { allowed: false, capAllowed: false }
return <tr key={item.actionId}><td>{item.label}<div className="ad-dim ad-mono">{item.actionId}</div></td>
<td><input type="checkbox" aria-label={`${item.label} 默认允许`} checked={value.allowed} disabled={busy || roleLoading || !canManagePermissions || item.protected}
onChange={event => editValue(item.actionId, 'allowed', event.target.checked)} /></td>
<td><input type="checkbox" aria-label={`${item.label} 角色上限`} checked={value.capAllowed} disabled={busy || roleLoading || !canManagePermissions || item.protected}
onChange={event => editValue(item.actionId, 'capAllowed', event.target.checked)} /></td>
<td className="ad-dim">{item.protected ? '受保护系统授权,不能由普通角色授予' : item.description}</td></tr>
})}</tbody></table>
<Collapsible id={`role-diff-${selected.roleId}`} trigger={`变更预览(${changes.length})`}>
{changes.length === 0 ? <p className="ad-hint">没有权限变化</p> : <ul className="rp-diff">{changes.map(change =>
<li key={change.actionId}><span className="ad-mono">{change.actionId}</span>:默认 {change.allowed ? '允许' : '拒绝'},上限 {change.capAllowed ? '允许' : '拒绝'}</li>)}</ul>}
</Collapsible>
{canManagePermissions && <button ref={savePermsRef} className="lg-submit ad-create-btn" disabled={busy || roleLoading || changes.length === 0 || editablePageItems.length === 0} onClick={savePermissions}>保存权限</button>}
</> : <p className="ad-hint">当前账号没有查看角色权限目录的系统权限</p>}
</> : <p className="ad-hint">请选择角色</p>}
</section>
</div>}
</div>
}
+1 -1
View File
@@ -280,7 +280,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
<MenuItem icon="sun" onClick={() => { store.setUI({ theme: 'light' }); setMenu(null) }}>浅色主题{theme === 'light' ? ' · 当前' : ''}</MenuItem> <MenuItem icon="sun" onClick={() => { store.setUI({ theme: 'light' }); setMenu(null) }}>浅色主题{theme === 'light' ? ' · 当前' : ''}</MenuItem>
<MenuItem onClick={() => { store.setUI({ theme: 'molv' }); setMenu(null) }}>墨绿主题{theme === 'molv' ? ' · 当前' : ''}</MenuItem> <MenuItem onClick={() => { store.setUI({ theme: 'molv' }); setMenu(null) }}>墨绿主题{theme === 'molv' ? ' · 当前' : ''}</MenuItem>
<MenuItem onClick={() => { store.setUI({ theme: 'system' }); setMenu(null) }}>跟随系统{theme === 'system' ? ' · 当前' : ''}</MenuItem> <MenuItem onClick={() => { store.setUI({ theme: 'system' }); setMenu(null) }}>跟随系统{theme === 'system' ? ' · 当前' : ''}</MenuItem>
{(me?.isAdmin || adminSections.length > 0) && <MenuItem icon="shield" onClick={() => { store.setUI({ adminZoneOpen: true }); setMenu(null) }}>系统管理…</MenuItem>} {(me?.isAdmin || (me?.systemActions?.length ?? 0) > 0 || adminSections.length > 0) && <MenuItem icon="shield" onClick={() => { store.setUI({ adminZoneOpen: true }); setMenu(null) }}>系统管理…</MenuItem>}
{me?.auth && <> {me?.auth && <>
<div className="tsb-menu-div" /> <div className="tsb-menu-div" />
<MenuItem danger icon="logout" onClick={() => { api.logout().catch(() => undefined); auth.logout() }}>退出登录</MenuItem> <MenuItem danger icon="logout" onClick={() => { api.logout().catch(() => undefined); auth.logout() }}>退出登录</MenuItem>
+4 -136
View File
@@ -1,150 +1,18 @@
// U6/KTD5:RDM 管理与项目页面组件。系统管理「RDM 角色权限」+ 项目设置「成员 / // RDM 项目设置组件:成员 / 文档职责。全部数据经 api.rdmCommand(命令总线),载荷构造与能力
// 文档职责」。全部数据经 api.rdmCommand(命令总线),载荷构造与能力 // 门控集中在 src/rdmManagement.ts;分区可见性由 ProjectSettingsModal 按 store 的
// 门控集中在 src/rdmManagement.ts;分区可见性由 AdminZone/ProjectSettingsModal // rdm*Available 门控(命令面不齐不渲染,普通基座零暴露)。样式只复用
// 按 store 的 rdm*Available 门控(命令面不齐不渲染,普通基座零暴露)。样式只复用
// 既有 ad-*/lg-*/settings-* 类(design-guard 棘轮:不新增裸色值/字号)。 // 既有 ad-*/lg-*/settings-* 类(design-guard 棘轮:不新增裸色值/字号)。
import { useEffect, useState, useRef, useCallback } from 'react' import { useEffect, useState, useRef, useCallback } from 'react'
import { api } from '../api' import { api } from '../api'
import type { Project } from '../contracts' import type { Project } from '../contracts'
import { projectWorkError } from '../projectmeta' import { projectWorkError } from '../projectmeta'
import { useStore } from '../store' import { useStore } from '../store'
import { Btn, Collapsible, EmptyState, Icon, Picker } from '@ui' import { Btn, Collapsible, Icon, Picker } from '@ui'
import { import {
ROLE_ACTIONS,
roleCreatePayload, roleActionPayload, roleAssignPayload, roleDisablePayload,
memberAddPayload, memberRemovePayload, memberSetRolePayload, memberAddPayload, memberRemovePayload, memberSetRolePayload,
permCheckPayload, describeSource, permCheckPayload, describeSource,
rolesListPlaceholder, describeRoleListError,
} from '../rdmManagement' } from '../rdmManagement'
// ---- 系统管理:RDM 角色权限(仅管理员 + rdmRolesAvailable) ----
interface RoleView {
roleId: string
name: string
disabled: boolean
version: number
actions: Record<string, [boolean, boolean]> | null
accounts: string[] | null
}
export function RdmRolesPanel() {
const me = useStore(s => s.me)
const [roles, setRoles] = useState<RoleView[] | null>(null)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const [newName, setNewName] = useState('')
// 动作编辑器
const [editRole, setEditRole] = useState('')
const [editAction, setEditAction] = useState<string>(ROLE_ACTIONS[0])
const [editDef, setEditDef] = useState(false)
const [editCap, setEditCap] = useState(true)
// 绑定
const [assignOwner, setAssignOwner] = useState('')
const refresh = async () => {
try {
const out = await api.rdmCommand<{ roles: RoleView[] }>('rdm.role.list')
setErr(''); setRoles(out.roles ?? [])
} catch (e) { setRoles(null); setErr(describeRoleListError(String(e))) }
}
useEffect(() => { refresh() }, [])
const run = async (label: string, fn: () => Promise<unknown>) => {
setErr(''); setNotice(''); setBusy(true)
try { await fn(); setNotice(label); await refresh() }
catch (e) { setErr(describeRoleListError(String(e))) } finally { setBusy(false) }
}
if (!me?.isAdmin) return <EmptyState icon="shield">仅平台管理员可管理专业角色</EmptyState>
// 占位行单一出口:加载中仅在无错误时成立(拒绝后永远转圈是误导——见
// rolesListPlaceholder 注释)。
const placeholder = rolesListPlaceholder(roles, err)
const sel = roles?.find(r => r.roleId === editRole)
const editCurrent = sel?.actions?.[editAction]
return (
<div>
<div className="ad-create">
<div className="ad-row">
<input className="lg-input" placeholder="新角色名(如:编制工程师)" value={newName}
onChange={e => setNewName(e.target.value)} />
<button className="lg-submit ad-create-btn" disabled={busy || !newName.trim()}
onClick={() => run(`已创建角色 ${newName.trim()}`, async () => {
await api.rdmCommand('rdm.role.create', roleCreatePayload(newName.trim()))
setNewName('')
})}>创建角色</button>
</div>
<p className="ad-hint">新角色动作未配置=全部拒绝(fail-closed),创建后经下方编辑器逐动作配置默认与上限。</p>
</div>
<table className="ad-table">
<thead><tr><th>角色</th><th>状态</th><th>版本</th><th>动作配置</th><th>绑定账号</th><th></th></tr></thead>
<tbody>
{(roles ?? []).map(r => (
<tr key={r.roleId}>
<td><span className="ad-mono">{r.name}</span><br /><span className="ad-dim">{r.roleId}</span></td>
<td>{r.disabled ? <span className="ad-chip ad-chip-warn">已停用</span> : <span className="ad-chip">生效</span>}</td>
<td className="ad-dim">v{r.version}</td>
<td>{Object.keys(r.actions ?? {}).length} 项</td>
<td className="ad-mono">{(r.accounts ?? []).join('、') || '—'}</td>
<td><button className="ad-mini" disabled={busy}
onClick={() => run(r.disabled ? `已启用 ${r.name}` : `已停用 ${r.name}(绑定账号回落内置默认)`,
() => api.rdmCommand('rdm.role.disable', roleDisablePayload(r.roleId, !r.disabled)))}>
{r.disabled ? '启用' : '停用'}</button></td>
</tr>
))}
{roles !== null && roles.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无专业角色</td></tr>}
{placeholder && <tr><td colSpan={6} className="ad-dim">{placeholder}</td></tr>}
</tbody>
</table>
<h4 className="settings-group-title" style={{ marginTop: 18 }}>动作配置(默认 ∩ 上限)</h4>
<div className="ad-row">
<select className="lg-input" value={editRole} onChange={e => setEditRole(e.target.value)}>
<option value="">选择角色…</option>
{(roles ?? []).map(r => <option key={r.roleId} value={r.roleId}>{r.name}</option>)}
</select>
<select className="lg-input" value={editAction}
onChange={e => {
setEditAction(e.target.value)
const cur = sel?.actions?.[e.target.value]
setEditDef(!!cur?.[0]); setEditCap(cur ? cur[1] : true)
}}>
{ROLE_ACTIONS.map(a => <option key={a} value={a}>{a}</option>)}
</select>
<label className="ad-check"><input type="checkbox" checked={editDef}
onChange={e => setEditDef(e.target.checked)} />默认允许</label>
<label className="ad-check"><input type="checkbox" checked={editCap}
onChange={e => setEditCap(e.target.checked)} />上限允许</label>
<button className="lg-submit ad-create-btn" disabled={busy || !editRole}
onClick={() => run(`已配置 ${editRole} · ${editAction}`,
() => api.rdmCommand('rdm.role.action', roleActionPayload(editRole, editAction, editDef, editCap)))}>保存动作</button>
</div>
{sel && editCurrent && <p className="ad-hint">当前值:默认={String(editCurrent[0])},上限={String(editCurrent[1])}</p>}
<h4 className="settings-group-title" style={{ marginTop: 18 }}>绑定账号(每账号一个生效角色)</h4>
<div className="ad-row">
<input className="lg-input lg-mono" placeholder="账号(owner)" value={assignOwner}
onChange={e => setAssignOwner(e.target.value)} />
<select className="lg-input" value={editRole} onChange={e => setEditRole(e.target.value)}>
<option value="">选择角色…</option>
{(roles ?? []).filter(r => !r.disabled).map(r => <option key={r.roleId} value={r.roleId}>{r.name}</option>)}
</select>
<button className="lg-submit ad-create-btn" disabled={busy || !assignOwner.trim() || !editRole}
onClick={() => run(`已把 ${assignOwner.trim()} 绑定到 ${editRole}`,
() => api.rdmCommand('rdm.role.assign', roleAssignPayload(assignOwner.trim(), editRole)))}>绑定</button>
</div>
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
</div>
)
}
// ---- 项目设置:成员(P7:可搜索人员选择器 + 专业角色/项目身份两列 + 显式身份调整)---- // ---- 项目设置:成员(P7:可搜索人员选择器 + 专业角色/项目身份两列 + 显式身份调整)----
import { import {
+70
View File
@@ -0,0 +1,70 @@
export type RolePermissionValue = { allowed: boolean; capAllowed: boolean }
type RolePermissionChange = { actionId: string; allowed: boolean; capAllowed: boolean }
export interface RolePermissionDraft {
baseline: Record<string, RolePermissionValue>
values: Record<string, RolePermissionValue>
generation: number
}
function copy(values: Record<string, RolePermissionValue>): Record<string, RolePermissionValue> {
return Object.fromEntries(Object.entries(values).map(([actionId, value]) => [actionId, { ...value }]))
}
export function createRolePermissionDraft(values: Record<string, RolePermissionValue>, generation = 0): RolePermissionDraft {
return { baseline: copy(values), values: copy(values), generation }
}
export function updateRolePermission(
draft: RolePermissionDraft,
actionId: string,
value: RolePermissionValue,
): RolePermissionDraft {
return { ...draft, values: { ...draft.values, [actionId]: { ...value } }, generation: draft.generation + 1 }
}
export function mergeRoleDraft(
current: RolePermissionDraft,
serverValues: Record<string, RolePermissionValue>,
responseGeneration: number,
): RolePermissionDraft {
if (responseGeneration < current.generation) return current
return createRolePermissionDraft(serverValues, responseGeneration)
}
// Rebase local edits on a newer server baseline after an optimistic conflict.
// Only fields changed from the old baseline are replayed.
export function rebaseRoleDraft(
current: RolePermissionDraft,
serverValues: Record<string, RolePermissionValue>,
): RolePermissionDraft {
const values = copy(serverValues)
for (const [actionId, local] of Object.entries(current.values)) {
const old = current.baseline[actionId]
if (!old || old.allowed !== local.allowed || old.capAllowed !== local.capAllowed) values[actionId] = { ...local }
}
return { baseline: copy(serverValues), values, generation: current.generation + 1 }
}
export function visiblePatch(draft: RolePermissionDraft, visibleActionIds: string[]): RolePermissionChange[] {
const seen = new Set<string>()
const changes: RolePermissionChange[] = []
for (const actionId of visibleActionIds) {
if (seen.has(actionId)) continue
seen.add(actionId)
const value = draft.values[actionId]
if (!value) continue
const baseline = draft.baseline[actionId]
if (baseline && baseline.allowed === value.allowed && baseline.capAllowed === value.capAllowed) continue
changes.push({ actionId, ...value })
}
return changes
}
export function validateRolePermissionDraft(draft: RolePermissionDraft): { actionId: string; reason: string } | null {
for (const actionId of Object.keys(draft.values).sort()) {
const value = draft.values[actionId]
if (value.allowed && !value.capAllowed) return { actionId, reason: 'default-exceeds-cap' }
}
return null
}
+1 -1
View File
@@ -90,7 +90,7 @@ export interface UIState {
projectSettingsId: string | null projectSettingsId: string | null
} }
export interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; mustChangePassword?: boolean } export interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; mustChangePassword?: boolean; systemActions?: string[] }
interface AppState { interface AppState {
contextObs: Record<string, ContextObs> contextObs: Record<string, ContextObs>
+22
View File
@@ -517,6 +517,28 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.ad-table th { text-align: left; font-weight: 600; color: var(--text-dim); font-size: var(--fs-ui-sm); padding: 8px; border-bottom: 1px solid var(--border); } .ad-table th { text-align: left; font-weight: 600; color: var(--text-dim); font-size: var(--fs-ui-sm); padding: 8px; border-bottom: 1px solid var(--border); }
.ad-table td { padding: 9px 8px; border-bottom: 1px solid var(--border-soft); } .ad-table td { padding: 9px 8px; border-bottom: 1px solid var(--border-soft); }
.ad-table tr:last-child td { border-bottom: none; } .ad-table tr:last-child td { border-bottom: none; }
.rp-panel { display: flex; flex-direction: column; gap: 12px; }
.rp-filters .lg-input { min-width: 140px; }
.rp-view-toggle { justify-content: flex-end; }
.rp-view-toggle .ad-mini.on { background: var(--bg-active); color: var(--text); }
.rp-single { display: grid; grid-template-columns: minmax(180px, 240px) minmax(0, 1fr); gap: 16px; align-items: start; }
.rp-disable-confirm { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-top: 8px; font-size: var(--fs-ui-sm); color: var(--warn-text); background: var(--warn-soft); border: 1px solid color-mix(in srgb, var(--warn) 35%, transparent); border-radius: var(--radius); padding: 8px 11px; }
.rp-role-list { display: flex; flex-direction: column; gap: 2px; padding: 8px; border: 1px solid var(--border-soft); border-radius: var(--radius-lg); background: var(--bg-panel); }
.rp-role-list .az-nav-item { justify-content: space-between; width: 100%; }
.rp-editor { min-width: 0; display: flex; flex-direction: column; gap: 12px; }
.rp-matrix-wrap { overflow-x: auto; border: 1px solid var(--border-soft); border-radius: var(--radius-lg); }
.rp-matrix { min-width: 760px; }
.rp-matrix th:first-child, .rp-matrix td:first-child { position: sticky; left: 0; background: var(--bg-panel); z-index: 1; }
.rp-matrix tr.rp-selected td { background: var(--bg-active); }
.rp-matrix tr.rp-selected td:first-child { background: var(--bg-active); }
.rp-diff { margin: 0; padding-left: 20px; color: var(--text-dim); font-size: var(--fs-ui-sm); }
.rp-grants { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 8px; margin-bottom: 12px; }
.rp-grants .ad-check { align-items: flex-start; padding: 8px; border: 1px solid var(--border-soft); border-radius: var(--radius); background: var(--bg-raised); }
.rp-grants .ad-mono { margin-left: auto; }
@media (max-width: 820px) {
.rp-single { grid-template-columns: 1fr; }
.rp-role-list { max-height: 220px; overflow-y: auto; }
}
.ad-mono { font-family: var(--mono); font-size: var(--fs-ui-sm); } .ad-mono { font-family: var(--mono); font-size: var(--fs-ui-sm); }
.ad-dim { color: var(--text-dim); } .ad-dim { color: var(--text-dim); }
.ad-chip { font-size: var(--fs-ui-xs); border-radius: var(--radius-xs); padding: 2px 7px; font-weight: 700; letter-spacing: .5px; background: var(--bg-hover); color: var(--text-dim); } .ad-chip { font-size: var(--fs-ui-xs); border-radius: var(--radius-xs); padding: 2px 7px; font-weight: 700; letter-spacing: .5px; background: var(--bg-hover); color: var(--text-dim); }
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import type { PermissionDescriptor, RoleRecord } from '../src/api'
import {
filterRoles, groupPermissionPage, hasSystemAction, shouldAcceptRoleResponse,
} from '../src/components/RolePermissionsPanel'
const roles: RoleRecord[] = [
{ roleId: 'role_engineer', name: '软件工程师', description: '研发', enabled: true, roleVersion: 1, bindingCount: 2, permissions: [] },
{ roleId: 'role_reviewer', name: '审核工程师', description: '质量', enabled: false, roleVersion: 1, bindingCount: 1, permissions: [] },
]
const catalog: PermissionDescriptor[] = [
{ actionId: 'project.create', label: '创建项目', description: '', group: '项目入口', scope: 'system', protected: false },
{ actionId: 'document.read', label: '读取文档', description: '', group: '文档动作', scope: 'project', protected: false },
{ actionId: 'document.author', label: '编制文档', description: '', group: '文档动作', scope: 'project', protected: false },
]
describe('role permission UI helpers', () => {
it('combines exact or fuzzy search with the role status filter', () => {
expect(filterRoles(roles, '软件工程师', 'exact', 'enabled').map(role => role.roleId)).toEqual(['role_engineer'])
expect(filterRoles(roles, '工程师', 'fuzzy', 'all').map(role => role.roleId)).toEqual(['role_engineer', 'role_reviewer'])
expect(filterRoles(roles, '', 'fuzzy', 'disabled').map(role => role.roleId)).toEqual(['role_reviewer'])
})
it('groups and pages matrix columns without changing their stable action IDs', () => {
expect(groupPermissionPage(catalog, '文档动作', 0, 1).map(item => item.actionId)).toEqual(['document.read'])
expect(groupPermissionPage(catalog, '文档动作', 1, 1).map(item => item.actionId)).toEqual(['document.author'])
})
it('drops late results and checks explicit system actions', () => {
expect(shouldAcceptRoleResponse(4, 3)).toBe(false)
expect(shouldAcceptRoleResponse(4, 4)).toBe(true)
expect(hasSystemAction(['system.roles.view'], 'system.roles.view')).toBe(true)
expect(hasSystemAction(['system.roles.view'], 'system.permissions.manage')).toBe(false)
})
})
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import {
createRolePermissionDraft, mergeRoleDraft, rebaseRoleDraft, updateRolePermission, validateRolePermissionDraft, visiblePatch,
type RolePermissionValue,
} from '../src/rolePermissions'
const initial: Record<string, RolePermissionValue> = {
'project.create': { allowed: false, capAllowed: true },
'document.read': { allowed: true, capAllowed: true },
'document.author': { allowed: false, capAllowed: true },
}
describe('role permission draft', () => {
it('retains hidden edits and patches only explicit visible actions', () => {
let draft = createRolePermissionDraft(initial)
draft = updateRolePermission(draft, 'project.create', { allowed: true, capAllowed: true })
draft = updateRolePermission(draft, 'document.author', { allowed: true, capAllowed: true })
expect(visiblePatch(draft, ['project.create'])).toEqual([
{ actionId: 'project.create', allowed: true, capAllowed: true },
])
expect(draft.values['document.author']).toEqual({ allowed: true, capAllowed: true })
})
it('produces the same patch for single-role and matrix edits', () => {
const single = updateRolePermission(createRolePermissionDraft(initial), 'document.read', { allowed: false, capAllowed: true })
const matrix = updateRolePermission(createRolePermissionDraft(initial), 'document.read', { allowed: false, capAllowed: true })
expect(visiblePatch(single, Object.keys(initial))).toEqual(visiblePatch(matrix, Object.keys(initial)))
})
it('discards a stale server refresh without replacing newer edits', () => {
const edited = updateRolePermission(createRolePermissionDraft(initial), 'project.create', { allowed: true, capAllowed: true })
const merged = mergeRoleDraft(edited, initial, edited.generation - 1)
expect(merged).toBe(edited)
})
it('rejects defaults that exceed the role cap', () => {
const invalid = updateRolePermission(createRolePermissionDraft(initial), 'document.read', { allowed: true, capAllowed: false })
expect(validateRolePermissionDraft(invalid)).toEqual({ actionId: 'document.read', reason: 'default-exceeds-cap' })
})
it('rebases local changes on a newer role version without losing the draft', () => {
const edited = updateRolePermission(createRolePermissionDraft(initial), 'project.create', { allowed: true, capAllowed: true })
const server = { ...initial, 'document.read': { allowed: false, capAllowed: true } }
const rebased = rebaseRoleDraft(edited, server)
expect(rebased.baseline['document.read']).toEqual({ allowed: false, capAllowed: true })
expect(rebased.values['project.create']).toEqual({ allowed: true, capAllowed: true })
expect(rebased.values['document.read']).toEqual({ allowed: false, capAllowed: true })
})
})