Add IAM U4 role and permission management UI #111
@@ -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
@@ -14,6 +14,18 @@ const BASE = ''
|
||||
* Registry.AddAdminZone 声明;入口不做前端权限合并,区内数据能力由组件
|
||||
* 自身命令面 fail-closed。 */
|
||||
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 加固):
|
||||
* passwordChangeMode 由服务端从会话派生;grant_setup 免当前密码,
|
||||
@@ -33,8 +45,14 @@ export interface AccountProfile { name: string; phone: string; email: string; st
|
||||
export interface AccountRecord {
|
||||
userId: string; owner: string; profile: AccountProfile; profileVersion: number
|
||||
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 {
|
||||
configured: boolean
|
||||
@@ -60,6 +78,37 @@ async function json<T>(res: Response): Promise<T> {
|
||||
}
|
||||
|
||||
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)),
|
||||
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)),
|
||||
@@ -79,7 +128,7 @@ export const api = {
|
||||
projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json<SurfaceMeta[]>(r)),
|
||||
/** 单页权威快照(含 identity + spec),供列表水合与预览渲染。 */
|
||||
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)----
|
||||
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()}`)
|
||||
@@ -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)),
|
||||
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,客户端方法随之移除;
|
||||
// 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 ?? []),
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -10,7 +10,8 @@ import { EmptyState, Icon, type IconName } from '@ui'
|
||||
import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel, SystemSettingsPanel } from './adminSections'
|
||||
import { ExecEnvPanel } from './ExecEnvPanel'
|
||||
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
|
||||
import { RdmRolesPanel } from './adminRdmSections'
|
||||
import { AccountUsersPanel } from './AccountUsersPanel'
|
||||
import { RolePermissionsPanel, roleDraftLeaveGuard, hasSystemAction } from './RolePermissionsPanel'
|
||||
import type { AdminZoneSection } from '../api'
|
||||
|
||||
interface AdminSectionDef {
|
||||
@@ -18,6 +19,8 @@ interface AdminSectionDef {
|
||||
label: string
|
||||
icon: IconName
|
||||
desc: string
|
||||
requiredAction?: string
|
||||
rootOnly?: boolean
|
||||
/** 懒渲染:进入分区才挂载(每个面板自带数据加载)。 */
|
||||
render: () => React.ReactNode
|
||||
}
|
||||
@@ -27,16 +30,30 @@ function ProfilesPanelWrapper() {
|
||||
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[] = [
|
||||
{ key: 'users', label: '用户管理', icon: 'users', desc: '建号、发放初始密码、重置密码(管理员发放制)', render: () => <UsersPanel /> },
|
||||
// U6/KTD5:RDM 专业角色管理——命令面齐备(rdmRolesAvailable)才显示;普通基座零暴露。
|
||||
{ key: 'rdm-roles', label: 'RDM 角色权限', icon: 'shield', desc: '专业角色:动作默认/上限、账号绑定、停用(仅管理员)', render: () => <RdmRolesPanel /> },
|
||||
{ key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', render: () => <ProfilesPanelWrapper /> },
|
||||
{ key: 'users', label: '用户管理', icon: 'users', desc: '可选资料、账号状态、唯一生效角色与明确系统权限;根管理员另可发放与恢复登录凭据', requiredAction: 'system.users.view', render: () => <UsersSection /> },
|
||||
{ key: 'roles', label: '角色与权限', icon: 'shield', desc: '职位角色、默认权限与上限;支持单角色和角色矩阵配置', requiredAction: 'system.roles.view', render: () => <RolePermissionsPanel /> },
|
||||
{ key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', rootOnly: true, render: () => <ProfilesPanelWrapper /> },
|
||||
// P2 受管:模型/密钥/Tavily/遥测/运行基础设施的唯一写入面(Backend PR 188 系统设置 API)。
|
||||
{ key: 'system-settings', label: '模型与服务配置', icon: 'settings', desc: '系统受管项:模型、密钥、Tavily、遥测与运行基础设施(仅管理员)', render: () => <SystemSettingsPanel /> },
|
||||
{ key: 'plugins', label: '插件管理', icon: 'puzzle', desc: '已装载插件清单与热重载(装卸不影响核心)', render: () => <PluginsSection /> },
|
||||
{ key: 'python-env', label: 'Python 环境', icon: 'package', desc: '基础层、扩展申请、回收和 wheel 上传(仅管理员)', render: () => <ExecEnvPanel /> },
|
||||
{ key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', render: () => <PluginWorkspacesPanel /> },
|
||||
{ key: 'system-settings', label: '模型与服务配置', icon: 'settings', desc: '系统受管项:模型、密钥、Tavily、遥测与运行基础设施(仅管理员)', rootOnly: true, render: () => <SystemSettingsPanel /> },
|
||||
{ key: 'plugins', label: '插件管理', icon: 'puzzle', desc: '已装载插件清单与热重载(装卸不影响核心)', rootOnly: true, render: () => <PluginsSection /> },
|
||||
{ key: 'python-env', label: 'Python 环境', icon: 'package', desc: '基础层、扩展申请、回收和 wheel 上传(仅管理员)', rootOnly: true, render: () => <ExecEnvPanel /> },
|
||||
{ key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', rootOnly: true, render: () => <PluginWorkspacesPanel /> },
|
||||
]
|
||||
|
||||
/** 声明分区 → 内置分区组件(按声明 ID 映射;AdminZone 不硬编码领域逻辑)。 */
|
||||
@@ -64,14 +81,15 @@ function DeclaredSection({ section }: { section: AdminZoneSection }) {
|
||||
export function AdminZone() {
|
||||
const me = useStore(s => s.me)
|
||||
const declared = useStore(s => s.adminSections)
|
||||
const rdmRolesAvailable = useStore(s => s.rdmRolesAvailable)
|
||||
const [sectionKey, setSectionKey] = useState('users')
|
||||
const isAdmin = !!me?.isAdmin
|
||||
const systemActions = me?.systemActions ?? []
|
||||
const canOpenSystem = isAdmin || systemActions.length > 0
|
||||
// 门控:基座系统分区仅 isAdmin(纵深防御与菜单层一致);声明分区对全部
|
||||
// 认证主体开放(区内能力由组件命令面判定)。两者皆无时不渲染。
|
||||
if (!isAdmin && declared.length === 0) return null
|
||||
if (!canOpenSystem && declared.length === 0) return null
|
||||
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 => ({
|
||||
key: `decl:${s.id}`, label: s.title, icon: declaredIcon(s.icon),
|
||||
desc: s.desc || '插件声明的管理分区(区内数据能力由插件命令面判定)',
|
||||
@@ -84,11 +102,15 @@ export function AdminZone() {
|
||||
return (
|
||||
<div className="az" role="dialog" aria-label="系统管理">
|
||||
<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} /> 返回工作台
|
||||
</button>
|
||||
<span className="az-title">系统管理</span>
|
||||
<span className="az-sub">{isAdmin ? '仅管理员可见' : '按插件声明开放'} · 变更即时生效</span>
|
||||
<span className="az-sub">{isAdmin ? '根管理员' : '按系统权限开放'} · 角色与权限由服务端判定</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span className="az-tenant">{me?.owner ?? ''}</span>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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 onClick={() => { store.setUI({ theme: 'molv' }); setMenu(null) }}>墨绿主题{theme === 'molv' ? ' · 当前' : ''}</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 && <>
|
||||
<div className="tsb-menu-div" />
|
||||
<MenuItem danger icon="logout" onClick={() => { api.logout().catch(() => undefined); auth.logout() }}>退出登录</MenuItem>
|
||||
|
||||
@@ -1,150 +1,18 @@
|
||||
// U6/KTD5:RDM 管理与项目页面组件。系统管理「RDM 角色权限」+ 项目设置「成员 /
|
||||
// 文档职责」。全部数据经 api.rdmCommand(命令总线),载荷构造与能力
|
||||
// 门控集中在 src/rdmManagement.ts;分区可见性由 AdminZone/ProjectSettingsModal
|
||||
// 按 store 的 rdm*Available 门控(命令面不齐不渲染,普通基座零暴露)。样式只复用
|
||||
// RDM 项目设置组件:成员 / 文档职责。全部数据经 api.rdmCommand(命令总线),载荷构造与能力
|
||||
// 门控集中在 src/rdmManagement.ts;分区可见性由 ProjectSettingsModal 按 store 的
|
||||
// rdm*Available 门控(命令面不齐不渲染,普通基座零暴露)。样式只复用
|
||||
// 既有 ad-*/lg-*/settings-* 类(design-guard 棘轮:不新增裸色值/字号)。
|
||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import { api } from '../api'
|
||||
import type { Project } from '../contracts'
|
||||
import { projectWorkError } from '../projectmeta'
|
||||
import { useStore } from '../store'
|
||||
import { Btn, Collapsible, EmptyState, Icon, Picker } from '@ui'
|
||||
import { Btn, Collapsible, Icon, Picker } from '@ui'
|
||||
import {
|
||||
ROLE_ACTIONS,
|
||||
roleCreatePayload, roleActionPayload, roleAssignPayload, roleDisablePayload,
|
||||
memberAddPayload, memberRemovePayload, memberSetRolePayload,
|
||||
permCheckPayload, describeSource,
|
||||
rolesListPlaceholder, describeRoleListError,
|
||||
} 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:可搜索人员选择器 + 专业角色/项目身份两列 + 显式身份调整)----
|
||||
|
||||
import {
|
||||
|
||||
@@ -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
@@ -90,7 +90,7 @@ export interface UIState {
|
||||
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 {
|
||||
contextObs: Record<string, ContextObs>
|
||||
|
||||
@@ -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 td { padding: 9px 8px; border-bottom: 1px solid var(--border-soft); }
|
||||
.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-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); }
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user