diff --git a/e2e/iam-role-authority.playwright.ts b/e2e/iam-role-authority.playwright.ts new file mode 100644 index 0000000..f1268f1 --- /dev/null +++ b/e2e/iam-role-authority.playwright.ts @@ -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(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 { + 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 { + 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 = { 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 = {} + const calls: { method: string; path: string; query: Record; body?: Record }[] = [] + const holds = new Map; release: ReturnType }>() + const failNext = new Map() + 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 : 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 } + 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 + +async function openAdmin(page: Page, me: Me): Promise { + 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) +}) diff --git a/src/api.ts b/src/api.ts index 4446860..90745e5 100644 --- a/src/api.ts +++ b/src/api.ts @@ -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(res: Response): Promise { } 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(r)) + }, + getRole: (roleId: string) => auth.fetch(`${BASE}/api/admin/roles/${encodeURIComponent(roleId)}`).then(r => json(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(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(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(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(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(r)), createProject: (name: string) => auth.fetch(`${BASE}/api/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json(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(r)), @@ -79,7 +128,7 @@ export const api = { projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json(r)), /** 单页权威快照(含 identity + spec),供列表水合与预览渲染。 */ surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json(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(r)) + }, + createManagedUser: (owner: string, profile: Partial) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile }) }).then(r => json(r)), + adminGetUser: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}`).then(r => json(r)), + adminPatchProfile: (userId: string, expectedProfileVersion: number, patch: Partial) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/profile`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedProfileVersion, patch }) }).then(r => json(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(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 ?? []), diff --git a/src/components/AccountUsersPanel.tsx b/src/components/AccountUsersPanel.tsx new file mode 100644 index 0000000..4b5dc56 --- /dev/null +++ b/src/components/AccountUsersPanel.tsx @@ -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
{fields.map(field =>
+ + onChange({ ...profile, [field.key]: event.target.value })} /> +
)}
+} + +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(null) + const [selected, setSelected] = useState(null) + const [roleView, setRoleView] = useState(null) + const [roles, setRoles] = useState([]) + const [roleQuery, setRoleQuery] = useState('') + const [roleCursor, setRoleCursor] = useState('') + const [roleTrail, setRoleTrail] = useState([]) + const [nextRoleCursor, setNextRoleCursor] = useState('') + const [roleLoading, setRoleLoading] = useState(false) + const [catalog, setCatalog] = useState([]) + const [grants, setGrants] = useState([]) + const [authorityEpoch, setAuthorityEpoch] = useState(0) + const [draft, setDraft] = useState(emptyProfile) + const [newOwner, setNewOwner] = useState('') + const [newProfile, setNewProfile] = useState(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([]) + 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 = {} + 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

当前账号没有查看用户的系统权限

+ return
+ {allowed('system.users.create') &&
+
setNewOwner(event.target.value)} /> +
+ + + +
} + {notice &&
{notice}
} + {err &&
{err}
} + {selected ?
+
+ {selected.owner}{selected.userId}
+

{selected.enabled ? '有效账号' : '停用账号'} · 当前角色 {canViewRoles ? (roleView?.role?.name ?? '未分配') : '无角色查看权限'}

+ {allowed('system.users.edit') && + + + } +
+ {canAssignRoles && <>} + {allowed('system.users.disable') && } +
+ {canAssignRoles &&
+ { setRoleQuery(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }} /> + + +
} + {canViewGrants && +
{catalog.filter(item => item.scope === 'system').map(item => )}
+ {canManageGrants && } +
} + {!canViewGrants && allowed('system.permissions.manage') &&

需要同时具备系统权限查看权,才能安全编辑现有授权

} +
: <> +
+ { setQuery(event.target.value); setCursor(''); setTrail([]) }} /> + + + +
+ + {(accounts ?? []).map(account => + )}
登录名姓名工号状态
{account.owner}{account.profile.name || '—'}{account.profile.staffNo || '—'}{account.enabled ? '有效' : '停用'}
+
+
+ } +
+} diff --git a/src/components/AdminZone.tsx b/src/components/AdminZone.tsx index 1f6a8e0..ca65289 100644 --- a/src/components/AdminZone.tsx +++ b/src/components/AdminZone.tsx @@ -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 } +/** 账号目录(按系统权限开放)+ 登录凭据与恢复提案(根管理员专属,旧 /api/admin/users 列表与密码端点)。 */ +function UsersSection() { + const isAdmin = useStore(s => !!s.me?.isAdmin) + return <> + + {isAdmin && <> +
+

登录凭据与恢复

+

建号并发放初始密码、重置临时密码、发起恢复提案(仅根管理员)

+
+ + } + +} + const SECTIONS: AdminSectionDef[] = [ - { key: 'users', label: '用户管理', icon: 'users', desc: '建号、发放初始密码、重置密码(管理员发放制)', render: () => }, - // U6/KTD5:RDM 专业角色管理——命令面齐备(rdmRolesAvailable)才显示;普通基座零暴露。 - { key: 'rdm-roles', label: 'RDM 角色权限', icon: 'shield', desc: '专业角色:动作默认/上限、账号绑定、停用(仅管理员)', render: () => }, - { key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', render: () => }, + { key: 'users', label: '用户管理', icon: 'users', desc: '可选资料、账号状态、唯一生效角色与明确系统权限;根管理员另可发放与恢复登录凭据', requiredAction: 'system.users.view', render: () => }, + { key: 'roles', label: '角色与权限', icon: 'shield', desc: '职位角色、默认权限与上限;支持单角色和角色矩阵配置', requiredAction: 'system.roles.view', render: () => }, + { key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', rootOnly: true, render: () => }, // P2 受管:模型/密钥/Tavily/遥测/运行基础设施的唯一写入面(Backend PR 188 系统设置 API)。 - { key: 'system-settings', label: '模型与服务配置', icon: 'settings', desc: '系统受管项:模型、密钥、Tavily、遥测与运行基础设施(仅管理员)', render: () => }, - { key: 'plugins', label: '插件管理', icon: 'puzzle', desc: '已装载插件清单与热重载(装卸不影响核心)', render: () => }, - { key: 'python-env', label: 'Python 环境', icon: 'package', desc: '基础层、扩展申请、回收和 wheel 上传(仅管理员)', render: () => }, - { key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', render: () => }, + { key: 'system-settings', label: '模型与服务配置', icon: 'settings', desc: '系统受管项:模型、密钥、Tavily、遥测与运行基础设施(仅管理员)', rootOnly: true, render: () => }, + { key: 'plugins', label: '插件管理', icon: 'puzzle', desc: '已装载插件清单与热重载(装卸不影响核心)', rootOnly: true, render: () => }, + { key: 'python-env', label: 'Python 环境', icon: 'package', desc: '基础层、扩展申请、回收和 wheel 上传(仅管理员)', rootOnly: true, render: () => }, + { key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', rootOnly: true, render: () => }, ] /** 声明分区 → 内置分区组件(按声明 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 (
- 系统管理 - {isAdmin ? '仅管理员可见' : '按插件声明开放'} · 变更即时生效 + {isAdmin ? '根管理员' : '按系统权限开放'} · 角色与权限由服务端判定 {me?.owner ?? ''}
diff --git a/src/components/RolePermissionsPanel.tsx b/src/components/RolePermissionsPanel.tsx new file mode 100644 index 0000000..a70acac --- /dev/null +++ b/src/components/RolePermissionsPanel.tsx @@ -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 { + 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(null) + const [catalog, setCatalog] = useState([]) + const [selectedId, setSelectedId] = useState('') + const [drafts, setDrafts] = useState>({}) + 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([]) + 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(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 = () =>
+ + {pageItems.map(item => )} + {filtered.map(role => + + {pageItems.map(item => { + const value = role.roleId === selectedId && draft ? draft.values[item.actionId] : roleValue(role, item.actionId) + return + })} + )} +
角色{item.label}
· {role.bindingCount} 人 + {item.protected ? 系统授权 : <> + + + } +
+
+ + return
+ {canManageRoles &&
+
+ setNewName(event.target.value)} /> + setNewDescription(event.target.value)} /> + +
+
} + +
+ { setQuery(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }} /> + + + + {canViewPermissions && } +
+
+
+ + {canViewPermissions && } + + + + + {view === 'matrix' && canManagePermissions && <> + + + + + } +
+ {notice &&
{notice}
} + {err &&
{err}
} + {view === 'matrix' ? matrix() :
+ +
+ {selected && draft ? <> +
+ setName(event.target.value)} /> + setDescription(event.target.value)} /> + {canManageRoles && } + {canManageRoles && (selected.enabled + ? + : )} +
+ {confirmDisable && selected.enabled &&
+ 停用角色「{selected.name}」?该角色现有 {selected.bindingCount} 个用户绑定,停用后这些绑定将不再授予权限。 + + +
} + {canViewPermissions ? <> + {pageItems.map(item => { + const value = draft.values[item.actionId] ?? { allowed: false, capAllowed: false } + return + + + + })}
权限默认允许角色上限说明
{item.label}
{item.actionId}
editValue(item.actionId, 'allowed', event.target.checked)} /> editValue(item.actionId, 'capAllowed', event.target.checked)} />{item.protected ? '受保护系统授权,不能由普通角色授予' : item.description}
+ + {changes.length === 0 ?

没有权限变化

:
    {changes.map(change => +
  • {change.actionId}:默认 {change.allowed ? '允许' : '拒绝'},上限 {change.capAllowed ? '允许' : '拒绝'}
  • )}
} +
+ {canManagePermissions && } + :

当前账号没有查看角色权限目录的系统权限

} + :

请选择角色

} +
+
} +
+} diff --git a/src/components/TaskSidebar.tsx b/src/components/TaskSidebar.tsx index 37e53be..374f5d6 100644 --- a/src/components/TaskSidebar.tsx +++ b/src/components/TaskSidebar.tsx @@ -280,7 +280,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen, { store.setUI({ theme: 'light' }); setMenu(null) }}>浅色主题{theme === 'light' ? ' · 当前' : ''} { store.setUI({ theme: 'molv' }); setMenu(null) }}>墨绿主题{theme === 'molv' ? ' · 当前' : ''} { store.setUI({ theme: 'system' }); setMenu(null) }}>跟随系统{theme === 'system' ? ' · 当前' : ''} - {(me?.isAdmin || adminSections.length > 0) && { store.setUI({ adminZoneOpen: true }); setMenu(null) }}>系统管理…} + {(me?.isAdmin || (me?.systemActions?.length ?? 0) > 0 || adminSections.length > 0) && { store.setUI({ adminZoneOpen: true }); setMenu(null) }}>系统管理…} {me?.auth && <>
{ api.logout().catch(() => undefined); auth.logout() }}>退出登录 diff --git a/src/components/adminRdmSections.tsx b/src/components/adminRdmSections.tsx index d81bcc5..9ae19b3 100644 --- a/src/components/adminRdmSections.tsx +++ b/src/components/adminRdmSections.tsx @@ -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 | null - accounts: string[] | null -} - -export function RdmRolesPanel() { - const me = useStore(s => s.me) - const [roles, setRoles] = useState(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(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) => { - setErr(''); setNotice(''); setBusy(true) - try { await fn(); setNotice(label); await refresh() } - catch (e) { setErr(describeRoleListError(String(e))) } finally { setBusy(false) } - } - - if (!me?.isAdmin) return 仅平台管理员可管理专业角色 - - // 占位行单一出口:加载中仅在无错误时成立(拒绝后永远转圈是误导——见 - // rolesListPlaceholder 注释)。 - const placeholder = rolesListPlaceholder(roles, err) - - const sel = roles?.find(r => r.roleId === editRole) - const editCurrent = sel?.actions?.[editAction] - - return ( -
-
-
- setNewName(e.target.value)} /> - -
-

新角色动作未配置=全部拒绝(fail-closed),创建后经下方编辑器逐动作配置默认与上限。

-
- - - - - {(roles ?? []).map(r => ( - - - - - - - - - ))} - {roles !== null && roles.length === 0 && } - {placeholder && } - -
角色状态版本动作配置绑定账号
{r.name}
{r.roleId}
{r.disabled ? 已停用 : 生效}v{r.version}{Object.keys(r.actions ?? {}).length} 项{(r.accounts ?? []).join('、') || '—'}
暂无专业角色
{placeholder}
- -

动作配置(默认 ∩ 上限)

-
- - - - - -
- {sel && editCurrent &&

当前值:默认={String(editCurrent[0])},上限={String(editCurrent[1])}

} - -

绑定账号(每账号一个生效角色)

-
- setAssignOwner(e.target.value)} /> - - -
- - {notice &&
{notice}
} - {err &&
{err}
} -
- ) -} - // ---- 项目设置:成员(P7:可搜索人员选择器 + 专业角色/项目身份两列 + 显式身份调整)---- import { diff --git a/src/rolePermissions.ts b/src/rolePermissions.ts new file mode 100644 index 0000000..999106e --- /dev/null +++ b/src/rolePermissions.ts @@ -0,0 +1,70 @@ +export type RolePermissionValue = { allowed: boolean; capAllowed: boolean } +type RolePermissionChange = { actionId: string; allowed: boolean; capAllowed: boolean } + +export interface RolePermissionDraft { + baseline: Record + values: Record + generation: number +} + +function copy(values: Record): Record { + return Object.fromEntries(Object.entries(values).map(([actionId, value]) => [actionId, { ...value }])) +} + +export function createRolePermissionDraft(values: Record, 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, + 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, +): 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() + 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 +} diff --git a/src/store.ts b/src/store.ts index 328f4c3..13451ec 100644 --- a/src/store.ts +++ b/src/store.ts @@ -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 diff --git a/src/styles.css b/src/styles.css index 2998d40..7229246 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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); } diff --git a/test/role-permissions-ui.test.ts b/test/role-permissions-ui.test.ts new file mode 100644 index 0000000..560b4b1 --- /dev/null +++ b/test/role-permissions-ui.test.ts @@ -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) + }) +}) diff --git a/test/role-permissions.test.ts b/test/role-permissions.test.ts new file mode 100644 index 0000000..fabfb76 --- /dev/null +++ b/test/role-permissions.test.ts @@ -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 = { + '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 }) + }) +})