Add the IAM U4 browser acceptance slice and admin draft guards
Why: stage 7 (DR6-DR10) requires repeatable browser evidence; the reviewed flows lacked a disable confirmation, role-scoped draft lifetime with an explicit leave decision, and status announcements. Evidence: 16/16 deterministic Playwright scenarios pass on an isolated preview (route-driven, no live services); 283 unit tests and build/knip stay green; live-layer cases ride the same file under IAM_E2E_LIVE. Impact: RolePermissionsPanel keeps per-roleId drafts, guards zone leave/refresh, adds a disable confirm strip (name + binding count + impact + cancel) and restores save focus; AdminZone consults the leave guard; AccountUsersPanel notice becomes role=status; new e2e/iam-role-authority.playwright.ts. Refs: feature/iam-u4-role-permission-ui
This commit is contained in:
@@ -0,0 +1,617 @@
|
|||||||
|
// 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()
|
||||||
|
const firstDefault = page.locator('input[aria-label$="默认允许"]:not([disabled])').first()
|
||||||
|
// 真实目录来自被测 Backend:非受保护动作的默认允许复选框已渲染。
|
||||||
|
await expect(firstDefault).toBeVisible({ timeout: 15_000 })
|
||||||
|
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 })
|
||||||
|
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)
|
||||||
|
})
|
||||||
@@ -181,7 +181,7 @@ export function AccountUsersPanel() {
|
|||||||
<ProfileInputs profile={newProfile} onChange={setNewProfile} prefix="new-user" />
|
<ProfileInputs profile={newProfile} onChange={setNewProfile} prefix="new-user" />
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
</div>}
|
</div>}
|
||||||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</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>}
|
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
|
||||||
{selected ? <div className="rp-editor">
|
{selected ? <div className="rp-editor">
|
||||||
<div className="ad-row"><button className="ad-mini" onClick={() => { sequence.current++; setSelected(null) }}>返回列表</button>
|
<div className="ad-row"><button className="ad-mini" onClick={() => { sequence.current++; setSelected(null) }}>返回列表</button>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { EmptyState, Icon, type IconName } from '@ui'
|
|||||||
import { ProfilesSection, PluginsSection, PluginWorkspacesPanel, SystemSettingsPanel } from './adminSections'
|
import { ProfilesSection, PluginsSection, PluginWorkspacesPanel, SystemSettingsPanel } from './adminSections'
|
||||||
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
|
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
|
||||||
import { AccountUsersPanel } from './AccountUsersPanel'
|
import { AccountUsersPanel } from './AccountUsersPanel'
|
||||||
import { RolePermissionsPanel, hasSystemAction } from './RolePermissionsPanel'
|
import { RolePermissionsPanel, roleDraftLeaveGuard, hasSystemAction } from './RolePermissionsPanel'
|
||||||
import type { AdminZoneSection } from '../api'
|
import type { AdminZoneSection } from '../api'
|
||||||
|
|
||||||
interface AdminSectionDef {
|
interface AdminSectionDef {
|
||||||
@@ -85,7 +85,11 @@ export function AdminZone() {
|
|||||||
return (
|
return (
|
||||||
<div className="az" role="dialog" aria-label="系统管理">
|
<div className="az" role="dialog" aria-label="系统管理">
|
||||||
<div className="az-head">
|
<div className="az-head">
|
||||||
<button className="btn ghost" onClick={() => store.setUI({ adminZoneOpen: false })}>
|
<button className="btn ghost" onClick={() => {
|
||||||
|
// DR9:带未保存权限草稿离开须显式决定;守卫返回 false 表示用户取消。
|
||||||
|
if (roleDraftLeaveGuard.current && !roleDraftLeaveGuard.current()) return
|
||||||
|
store.setUI({ adminZoneOpen: false })
|
||||||
|
}}>
|
||||||
<Icon name="chevron-down" size={14} /> 返回工作台
|
<Icon name="chevron-down" size={14} /> 返回工作台
|
||||||
</button>
|
</button>
|
||||||
<span className="az-title">系统管理</span>
|
<span className="az-title">系统管理</span>
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import {
|
|||||||
import { Collapsible, Icon } from '@ui'
|
import { Collapsible, Icon } from '@ui'
|
||||||
import { useStore } from '../store'
|
import { useStore } from '../store'
|
||||||
|
|
||||||
|
/** DR9 离开管理区守卫:面板有未保存权限草稿时注册;AdminZone「返回工作台」消费。 */
|
||||||
|
export const roleDraftLeaveGuard: { current: (() => boolean) | null } = { current: null }
|
||||||
|
|
||||||
export function filterRoles(
|
export function filterRoles(
|
||||||
roles: RoleRecord[], query: string, mode: 'exact' | 'fuzzy', status: 'all' | 'enabled' | 'disabled',
|
roles: RoleRecord[], query: string, mode: 'exact' | 'fuzzy', status: 'all' | 'enabled' | 'disabled',
|
||||||
): RoleRecord[] {
|
): RoleRecord[] {
|
||||||
@@ -55,7 +58,8 @@ export function RolePermissionsPanel() {
|
|||||||
const [roles, setRoles] = useState<RoleRecord[] | null>(null)
|
const [roles, setRoles] = useState<RoleRecord[] | null>(null)
|
||||||
const [catalog, setCatalog] = useState<PermissionDescriptor[]>([])
|
const [catalog, setCatalog] = useState<PermissionDescriptor[]>([])
|
||||||
const [selectedId, setSelectedId] = useState('')
|
const [selectedId, setSelectedId] = useState('')
|
||||||
const [draft, setDraft] = useState<RolePermissionDraft | null>(null)
|
const [drafts, setDrafts] = useState<Record<string, RolePermissionDraft>>({})
|
||||||
|
const [confirmDisable, setConfirmDisable] = useState(false)
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
@@ -76,14 +80,18 @@ export function RolePermissionsPanel() {
|
|||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
const requestSequence = useRef(0)
|
const requestSequence = useRef(0)
|
||||||
const selectedIdRef = useRef('')
|
const selectedIdRef = useRef('')
|
||||||
|
const savePermsRef = useRef<HTMLButtonElement>(null)
|
||||||
selectedIdRef.current = selectedId
|
selectedIdRef.current = selectedId
|
||||||
|
|
||||||
const selected = roles?.find(role => role.roleId === selectedId) ?? null
|
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 groups = useMemo(() => [...new Set(catalog.map(item => item.group))], [catalog])
|
||||||
const filtered = roles ?? []
|
const filtered = roles ?? []
|
||||||
const pageItems = useMemo(() => groupPermissionPage(catalog, group, page, 6), [catalog, group, page])
|
const pageItems = useMemo(() => groupPermissionPage(catalog, group, page, 6), [catalog, group, page])
|
||||||
const editablePageItems = pageItems.filter(item => !item.protected)
|
const editablePageItems = pageItems.filter(item => !item.protected)
|
||||||
const changes = draft ? visiblePatch(draft, catalog.filter(item => !item.protected).map(item => item.actionId)) : []
|
const changes = draft ? visiblePatch(draft, allEditableIds) : []
|
||||||
|
|
||||||
const load = async (pageCursor = roleCursor, refreshCatalog = false, preferredId = selectedId,
|
const load = async (pageCursor = roleCursor, refreshCatalog = false, preferredId = selectedId,
|
||||||
filters = { query, mode, status, action: actionFilter }) => {
|
filters = { query, mode, status, action: actionFilter }) => {
|
||||||
@@ -103,13 +111,14 @@ export function RolePermissionsPanel() {
|
|||||||
setErr(String(roleResult.reason))
|
setErr(String(roleResult.reason))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (catalogResult.status === 'rejected') setErr(`权限目录加载失败:${catalogResult.reason}`)
|
|
||||||
const rolePage = roleResult.value
|
const rolePage = roleResult.value
|
||||||
setRoles(rolePage.roles ?? [])
|
setRoles(rolePage.roles ?? [])
|
||||||
setNextRoleCursor(rolePage.nextCursor ?? '')
|
setNextRoleCursor(rolePage.nextCursor ?? '')
|
||||||
const nextSelected = rolePage.roles.find(role => role.roleId === preferredId) ?? rolePage.roles[0] ?? null
|
const nextSelected = rolePage.roles.find(role => role.roleId === preferredId) ?? rolePage.roles[0] ?? null
|
||||||
if (nextSelected) selectRole(nextSelected, descriptors, sequence)
|
if (nextSelected) selectRole(nextSelected, descriptors, sequence)
|
||||||
else { setSelectedId(''); setDraft(null) }
|
else setSelectedId('')
|
||||||
|
// DR10:目录失败是部分失败态,需在角色选中(selectRole 会清空提示)之后仍可见。
|
||||||
|
if (catalogResult.status === 'rejected') setErr(prev => prev || `权限目录加载失败:${catalogResult.reason}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectRole = (role: RoleRecord, descriptors = catalog, sequence?: number) => {
|
const selectRole = (role: RoleRecord, descriptors = catalog, sequence?: number) => {
|
||||||
@@ -118,11 +127,28 @@ export function RolePermissionsPanel() {
|
|||||||
setSelectedId(role.roleId)
|
setSelectedId(role.roleId)
|
||||||
setName(role.name)
|
setName(role.name)
|
||||||
setDescription(role.description)
|
setDescription(role.description)
|
||||||
setDraft(createRolePermissionDraft(roleValues(role, descriptors), nextSequence))
|
// DR9:草稿按 roleId 隔离保存;翻页、过滤、视图切换回到该角色时保留,
|
||||||
|
// 切换角色永不转移他人草稿。仅首次选中时以服务端值为基线。
|
||||||
|
setDrafts(prev => prev[role.roleId] ? prev
|
||||||
|
: { ...prev, [role.roleId]: createRolePermissionDraft(roleValues(role, descriptors), nextSequence) })
|
||||||
setNotice('')
|
setNotice('')
|
||||||
setErr('')
|
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(() => {
|
useEffect(() => {
|
||||||
setNextRoleCursor(''); setRoleLoading(true)
|
setNextRoleCursor(''); setRoleLoading(true)
|
||||||
const timer = window.setTimeout(() => { void load(roleCursor) }, 180)
|
const timer = window.setTimeout(() => { void load(roleCursor) }, 180)
|
||||||
@@ -195,7 +221,8 @@ export function RolePermissionsPanel() {
|
|||||||
if (requestSequence.current !== operationSequence || selectedIdRef.current !== roleId) return
|
if (requestSequence.current !== operationSequence || selectedIdRef.current !== roleId) return
|
||||||
setRoles(current => current?.some(role => role.roleId === roleId)
|
setRoles(current => current?.some(role => role.roleId === roleId)
|
||||||
? current.map(role => role.roleId === roleId ? fresh : role) : [fresh, ...(current ?? [])])
|
? current.map(role => role.roleId === roleId ? fresh : role) : [fresh, ...(current ?? [])])
|
||||||
setDraft(rebaseRoleDraft(currentDraft, roleValues(fresh, catalog)))
|
// DR9:409 只重放同一角色的草稿;基于最新服务端值回放本地相对基线的变化。
|
||||||
|
setDrafts(prev => ({ ...prev, [roleId]: rebaseRoleDraft(currentDraft, roleValues(fresh, catalog)) }))
|
||||||
setErr('权限版本冲突;已基于最新版本重放本地草稿,请核对后再次保存')
|
setErr('权限版本冲突;已基于最新版本重放本地草稿,请核对后再次保存')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,27 +237,39 @@ export function RolePermissionsPanel() {
|
|||||||
setBusy(true); setErr(''); setNotice('')
|
setBusy(true); setErr(''); setNotice('')
|
||||||
try {
|
try {
|
||||||
await api.replaceRolePermissions(selected.roleId, selected.roleVersion, full, crypto.randomUUID())
|
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('角色权限已保存')
|
await load(roleCursor, true, selected.roleId); setNotice('角色权限已保存')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (String(error).includes('409')) {
|
if (String(error).includes('409')) {
|
||||||
try { await rebasePermissionConflict(roleId, draft, operationSequence) } catch (refreshError) { setErr(String(refreshError)) }
|
try { await rebasePermissionConflict(roleId, draft, operationSequence) } catch (refreshError) { setErr(String(refreshError)) }
|
||||||
} else setErr(String(error))
|
} else setErr(String(error))
|
||||||
}
|
}
|
||||||
finally { setBusy(false) }
|
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 editValue = (actionId: string, field: 'allowed' | 'capAllowed', checked: boolean) => {
|
||||||
if (!draft) return
|
const current = drafts[selectedId]
|
||||||
const current = draft.values[actionId] ?? { allowed: false, capAllowed: false }
|
if (!current) return
|
||||||
|
const value0 = current.values[actionId] ?? { allowed: false, capAllowed: false }
|
||||||
const value = field === 'allowed'
|
const value = field === 'allowed'
|
||||||
? { allowed: checked, capAllowed: checked ? true : current.capAllowed }
|
? { allowed: checked, capAllowed: checked ? true : value0.capAllowed }
|
||||||
: { allowed: checked ? current.allowed : false, capAllowed: checked }
|
: { allowed: checked ? value0.allowed : false, capAllowed: checked }
|
||||||
setDraft(updateRolePermission(draft, actionId, value))
|
setDrafts(prev => prev[selectedId] ? { ...prev, [selectedId]: updateRolePermission(prev[selectedId], actionId, value) } : prev)
|
||||||
}
|
}
|
||||||
|
|
||||||
const editVisible = (field: 'allowed' | 'capAllowed', checked: boolean) => {
|
const editVisible = (field: 'allowed' | 'capAllowed', checked: boolean) => {
|
||||||
if (!draft) return
|
const start = drafts[selectedId]
|
||||||
let next = draft
|
if (!start) return
|
||||||
|
let next = start
|
||||||
for (const item of editablePageItems) {
|
for (const item of editablePageItems) {
|
||||||
const current = next.values[item.actionId] ?? { allowed: false, capAllowed: false }
|
const current = next.values[item.actionId] ?? { allowed: false, capAllowed: false }
|
||||||
const value = field === 'allowed'
|
const value = field === 'allowed'
|
||||||
@@ -238,7 +277,7 @@ export function RolePermissionsPanel() {
|
|||||||
: { allowed: checked ? current.allowed : false, capAllowed: checked }
|
: { allowed: checked ? current.allowed : false, capAllowed: checked }
|
||||||
next = updateRolePermission(next, item.actionId, value)
|
next = updateRolePermission(next, item.actionId, value)
|
||||||
}
|
}
|
||||||
setDraft(next)
|
setDrafts(prev => prev[selectedId] ? { ...prev, [selectedId]: next } : prev)
|
||||||
}
|
}
|
||||||
|
|
||||||
const matrix = () => <div className="rp-matrix-wrap">
|
const matrix = () => <div className="rp-matrix-wrap">
|
||||||
@@ -301,7 +340,7 @@ export function RolePermissionsPanel() {
|
|||||||
<button className="ad-mini" disabled={busy || roleLoading || !editablePageItems.length} onClick={() => editVisible('capAllowed', false)}>当前页上限关闭</button>
|
<button className="ad-mini" disabled={busy || roleLoading || !editablePageItems.length} onClick={() => editVisible('capAllowed', false)}>当前页上限关闭</button>
|
||||||
</>}
|
</>}
|
||||||
</div>
|
</div>
|
||||||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</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>}
|
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
|
||||||
{view === 'matrix' ? matrix() : <div className="rp-single">
|
{view === 'matrix' ? matrix() : <div className="rp-single">
|
||||||
<aside className="rp-role-list">
|
<aside className="rp-role-list">
|
||||||
@@ -315,8 +354,15 @@ export function RolePermissionsPanel() {
|
|||||||
<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={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)} />
|
<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 && <button className="ad-mini" disabled={busy || roleLoading} onClick={saveMetadata}>保存资料</button>}
|
||||||
{canManageRoles && <button className="ad-mini" disabled={busy || roleLoading} onClick={setEnabled}>{selected.enabled ? '停用角色' : '启用角色'}</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>
|
</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>
|
{canViewPermissions ? <><table className="ad-table"><thead><tr><th>权限</th><th>默认允许</th><th>角色上限</th><th>说明</th></tr></thead>
|
||||||
<tbody>{pageItems.map(item => {
|
<tbody>{pageItems.map(item => {
|
||||||
const value = draft.values[item.actionId] ?? { allowed: false, capAllowed: false }
|
const value = draft.values[item.actionId] ?? { allowed: false, capAllowed: false }
|
||||||
@@ -331,7 +377,7 @@ export function RolePermissionsPanel() {
|
|||||||
{changes.length === 0 ? <p className="ad-hint">没有权限变化</p> : <ul className="rp-diff">{changes.map(change =>
|
{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>}
|
<li key={change.actionId}><span className="ad-mono">{change.actionId}</span>:默认 {change.allowed ? '允许' : '拒绝'},上限 {change.capAllowed ? '允许' : '拒绝'}</li>)}</ul>}
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
{canManagePermissions && <button className="lg-submit ad-create-btn" disabled={busy || roleLoading || changes.length === 0 || editablePageItems.length === 0} onClick={savePermissions}>保存权限</button>}
|
{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>}
|
||||||
</> : <p className="ad-hint">请选择角色</p>}
|
</> : <p className="ad-hint">请选择角色</p>}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -522,6 +522,7 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
|||||||
.rp-view-toggle { justify-content: flex-end; }
|
.rp-view-toggle { justify-content: flex-end; }
|
||||||
.rp-view-toggle .ad-mini.on { background: var(--bg-active); color: var(--text); }
|
.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-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 { 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-role-list .az-nav-item { justify-content: space-between; width: 100%; }
|
||||||
.rp-editor { min-width: 0; display: flex; flex-direction: column; gap: 12px; }
|
.rp-editor { min-width: 0; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user