Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46a63f0221 | ||
|
|
128aafed46 | ||
|
|
054f134111 | ||
|
|
c37c33bcac | ||
|
|
7d9ac7c093 | ||
|
|
1d907c16a1 | ||
|
|
a6ce695d6c | ||
|
|
d965732106 | ||
|
|
4be7362842 |
@@ -13,7 +13,7 @@ jobs:
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
UI_DEPLOY_KEY: ${{ secrets.UI_DEPLOY_KEY }}
|
||||
UI_SHA: fadabceadeec210d52cda7e9cec71e988d2e4482
|
||||
UI_SHA: 5c2b1036dd1aff89ec4eeae0c96493694e33ad4c
|
||||
run: |
|
||||
set -euo pipefail
|
||||
printf 'context server=%s repository=%s sha=%s\n' "${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}" "${GITHUB_SHA}"
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
// permux 活体浏览器验收:真实 Backend + 真实 Frontend dist(vite preview,/api 代理到被测 Backend)。
|
||||
// 由外部 harness 注入 PLAYWRIGHT_BASE_URL、PERMUX_LIVE=1、PERMUX_OWNER/PERMUX_PASSWORD(已设密管理员);
|
||||
// 全程不拦截任何路由,服务端事实一律经同一 baseURL 的 API 核对。
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { test, expect, type APIRequestContext, type APIResponse, type BrowserContext, type Page } from '@playwright/test'
|
||||
|
||||
interface Perm { actionId: string; label: string; description: string; group: string; category?: 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; stateVersion: number; profile: { name: string } }
|
||||
interface UserRoleView { binding?: { roleId: string; active: boolean }; role?: Role }
|
||||
interface Me { owner: string; isAdmin?: boolean; linkedCapabilities?: { projectCreate: boolean; source?: string[]; linkedActions?: string[] } }
|
||||
|
||||
const LIVE = process.env.PERMUX_LIVE === '1' && !!process.env.PLAYWRIGHT_BASE_URL
|
||||
const SUFFIX = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
||||
const DOC_CATEGORY = '文档工作'
|
||||
const PROJECT_CREATE = 'project.create'
|
||||
const LINKED = ['project.settings', 'project.members.manage', 'project.permissions.manage', 'document.duties.assign']
|
||||
const LINKED_NOTE = '创建项目时自动具备 · 仅本人创建的项目'
|
||||
|
||||
/** 与 rolePermissions.permissionCategory 同义:缺分类的非保护系统动作归入「项目与协作」。 */
|
||||
const categoryOf = (item: Perm) => item.category || (item.scope === 'system' ? '项目与协作' : item.group)
|
||||
const permKey = (role: Role) => role.permissions.filter(item => item.allowed || item.capAllowed)
|
||||
.map(item => `${item.actionId}=${item.allowed}/${item.capAllowed}`).sort()
|
||||
const rowOf = (role: Role, actionId: string) => role.permissions.find(item => item.actionId === actionId)
|
||||
|
||||
async function ok(res: APIResponse) {
|
||||
expect(res.ok(), `${res.url()} → ${res.status()} ${await res.text().catch(() => '')}`).toBe(true)
|
||||
return res
|
||||
}
|
||||
const getJSON = async <T>(api: APIRequestContext, path: string) => (await ok(await api.get(path))).json() as Promise<T>
|
||||
|
||||
async function roleByName(api: APIRequestContext, name: string): Promise<Role> {
|
||||
const page = await getJSON<{ roles: Role[] }>(api, `/api/admin/roles?query=${encodeURIComponent(name)}&mode=exact&status=all&limit=50`)
|
||||
const role = (page.roles ?? []).find(item => item.name === name)
|
||||
expect(role, `角色 ${name} 应存在`).toBeTruthy()
|
||||
return role!
|
||||
}
|
||||
const getRole = (api: APIRequestContext, roleId: string) => getJSON<Role>(api, `/api/admin/roles/${encodeURIComponent(roleId)}`)
|
||||
const getUser = (api: APIRequestContext, userId: string) => getJSON<Account>(api, `/api/admin/users/${encodeURIComponent(userId)}`)
|
||||
const userRoleId = async (api: APIRequestContext, userId: string) =>
|
||||
(await getJSON<UserRoleView>(api, `/api/admin/users/${encodeURIComponent(userId)}/role`)).role?.roleId ?? ''
|
||||
|
||||
async function userByOwner(api: APIRequestContext, owner: string): Promise<Account> {
|
||||
const page = await getJSON<{ accounts: Account[] }>(api,
|
||||
`/api/admin/users/search?query=${encodeURIComponent(owner)}&mode=exact&status=all&fields=owner&limit=20`)
|
||||
const account = (page.accounts ?? []).find(item => item.owner === owner)
|
||||
expect(account, `用户 ${owner} 应存在`).toBeTruthy()
|
||||
return account!
|
||||
}
|
||||
|
||||
async function assignRoleApi(api: APIRequestContext, userId: string, roleId: string) {
|
||||
const { stateVersion } = await getUser(api, userId)
|
||||
await ok(await api.put(`/api/admin/users/${encodeURIComponent(userId)}/role`, {
|
||||
data: { roleId, expectedStateVersion: stateVersion, idempotencyKey: randomUUID() },
|
||||
}))
|
||||
}
|
||||
|
||||
/** API 登录;首登受限会话先改密再以新密码重登,返回可用的完整会话。 */
|
||||
async function apiLogin(api: APIRequestContext, owner: string, password: string, nextPassword: string) {
|
||||
const first = await (await ok(await api.post('/api/auth/login', { data: { owner, password } }))).json() as { mustChangePassword?: boolean; sessionScope?: string }
|
||||
if (!first.mustChangePassword && first.sessionScope !== 'set_password_only') return
|
||||
await ok(await api.post('/api/auth/password', { data: { current: password, next: nextPassword } }))
|
||||
await ok(await api.post('/api/auth/login', { data: { owner, password: nextPassword } }))
|
||||
}
|
||||
|
||||
async function expand(page: Page, trigger: string) {
|
||||
const button = page.getByRole('button', { name: trigger, exact: true })
|
||||
await expect(button).toBeVisible({ timeout: 15_000 })
|
||||
if (await button.getAttribute('aria-expanded') !== 'true') await button.click()
|
||||
await expect(button).toHaveAttribute('aria-expanded', 'true')
|
||||
}
|
||||
|
||||
test.describe.serial('permux 活体主流程(真实 Backend + 真实 Frontend dist)', () => {
|
||||
test.skip(!LIVE, 'set PERMUX_LIVE=1 and PLAYWRIGHT_BASE_URL (vite preview proxied to the backend under test)')
|
||||
test.describe.configure({ timeout: 180_000 })
|
||||
|
||||
let context: BrowserContext
|
||||
let page: Page
|
||||
let api: APIRequestContext
|
||||
let catalog: Perm[] = []
|
||||
let actA: Perm, actB: Perm, keep: Perm, projectCreate: Perm
|
||||
let linked: Perm[] = []
|
||||
let roleA: Role, roleB: Role, roleC: Role
|
||||
const names = {
|
||||
roleA: `权限甲-${SUFFIX}`, roleB: `权限乙-${SUFFIX}`, roleC: `权限丙-${SUFFIX}`, roleD: `权限丁-${SUFFIX}`,
|
||||
sameName: `同名测试·${SUFFIX}`, owner1: `permux-u1-${SUFFIX}`, owner2: `permux-u2-${SUFFIX}`, linkedOwner: `permux-linked-${SUFFIX}`,
|
||||
}
|
||||
const label1 = () => `${names.sameName}(${names.owner1})`
|
||||
const label2 = () => `${names.sameName}(${names.owner2})`
|
||||
|
||||
const status = (text: string) => page.getByRole('status').filter({ hasText: text })
|
||||
const savebar = () => page.getByRole('region', { name: '未保存的权限修改' })
|
||||
const previewPanel = () => page.getByRole('region', { name: '保存前预览' })
|
||||
const cell = (role: Role, item: Perm) => page.getByLabel(`${role.name} ${item.label}`, { exact: true })
|
||||
const category = (name: string) => page.getByRole('group', { name: '权限分类' }).getByRole('button', { name, exact: true })
|
||||
const matrixRow = (role: Role) => page.locator('tr').filter({ has: page.getByRole('button', { name: role.name, exact: true }) })
|
||||
const userCheckbox = (label: string) => page.getByRole('checkbox', { name: `选择 ${label}`, exact: true })
|
||||
const userRow = (label: string) => page.locator('tr').filter({ has: userCheckbox(label) })
|
||||
const selectedBar = () => page.getByRole('region', { name: '已选用户' })
|
||||
|
||||
async function openSection(name: '角色与权限' | '用户管理') {
|
||||
const zone = page.getByRole('dialog', { name: '系统管理' })
|
||||
if (!await zone.isVisible()) {
|
||||
await page.locator('.tsb-user').click()
|
||||
await page.getByRole('button', { name: '系统管理…' }).click()
|
||||
await expect(zone).toBeVisible()
|
||||
}
|
||||
await zone.getByRole('button', { name, exact: true }).click()
|
||||
}
|
||||
|
||||
/** 角色面板:矩阵视图 + 搜索本轮后缀,只呈现本轮创建的角色。 */
|
||||
async function rolesMatrix(query = SUFFIX) {
|
||||
await openSection('角色与权限')
|
||||
await expect(page.getByRole('group', { name: '权限分类' })).toBeVisible({ timeout: 15_000 })
|
||||
const matrixButton = page.getByRole('button', { name: '角色矩阵', exact: true })
|
||||
if (await matrixButton.getAttribute('aria-pressed') !== 'true') await matrixButton.click()
|
||||
await expand(page, '搜索与过滤')
|
||||
await page.getByLabel('搜索角色').fill(query)
|
||||
}
|
||||
|
||||
async function openPreview() {
|
||||
await savebar().getByRole('button', { name: '保存', exact: true }).click()
|
||||
await expect(previewPanel()).toBeVisible({ timeout: 15_000 })
|
||||
await expect(previewPanel()).toContainText('请确认以下变化后保存')
|
||||
}
|
||||
|
||||
async function confirmSave(roles: number) {
|
||||
const confirm = savebar().getByRole('button', { name: '确认保存', exact: true })
|
||||
await expect(confirm).toBeEnabled()
|
||||
await confirm.click()
|
||||
await expect(status(`已保存 ${roles} 个角色的权限设置`)).toBeVisible({ timeout: 15_000 })
|
||||
await expect(savebar()).toHaveCount(0)
|
||||
}
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
if (!LIVE) return
|
||||
context = await browser.newContext({ baseURL: process.env.PLAYWRIGHT_BASE_URL, locale: 'zh-CN' })
|
||||
page = await context.newPage()
|
||||
api = context.request
|
||||
})
|
||||
|
||||
test.afterAll(async () => { await context?.close() })
|
||||
|
||||
test('登录:管理员经登录表单进入工作台,/api/me 为管理员', async () => {
|
||||
await page.goto('/')
|
||||
await page.getByPlaceholder('由管理员分配').fill(process.env.PERMUX_OWNER ?? '')
|
||||
await page.getByPlaceholder('初始密码或常用密码').fill(process.env.PERMUX_PASSWORD ?? '')
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
await expect(page.locator('.tsb-user')).toBeVisible({ timeout: 15_000 })
|
||||
const res = await api.get('/api/me')
|
||||
expect(res.status()).toBe(200)
|
||||
const me = await res.json() as Me
|
||||
expect(me.isAdmin).toBe(true)
|
||||
expect(me.owner).toBe(process.env.PERMUX_OWNER)
|
||||
|
||||
catalog = (await getJSON<{ permissions: Perm[] }>(api, '/api/admin/permissions/catalog')).permissions ?? []
|
||||
const editable = catalog.filter(item => !item.protected)
|
||||
const docActions = editable.filter(item => categoryOf(item) === DOC_CATEGORY && item.scope === 'project' && !LINKED.includes(item.actionId))
|
||||
expect(docActions.length, '「文档工作」至少需要两个可编辑项目动作').toBeGreaterThanOrEqual(2)
|
||||
actA = docActions[0]
|
||||
actB = docActions[1]
|
||||
keep = docActions[2] ?? editable.find(item => item.scope === 'project' && !LINKED.includes(item.actionId)
|
||||
&& item.actionId !== actA.actionId && item.actionId !== actB.actionId)!
|
||||
expect(keep, '需要第三个可编辑项目动作用于验证未提及行保留').toBeTruthy()
|
||||
projectCreate = editable.find(item => item.actionId === PROJECT_CREATE)!
|
||||
expect(projectCreate, '目录应含 project.create').toBeTruthy()
|
||||
linked = LINKED.map(id => editable.find(item => item.actionId === id)!)
|
||||
expect(linked.every(Boolean), '目录应含四个创建联动动作').toBe(true)
|
||||
})
|
||||
|
||||
test('C01+C02 多角色矩阵编辑:跨分类与搜索保留草稿,预览后一次提交', async () => {
|
||||
await rolesMatrix()
|
||||
for (const name of [names.roleA, names.roleB]) {
|
||||
await page.getByLabel('新角色名称').fill(name)
|
||||
await page.getByLabel('新角色描述').fill('permux 活体验收')
|
||||
await page.getByRole('button', { name: '创建角色', exact: true }).click()
|
||||
await expect(status(`已创建角色 ${name}`)).toBeVisible({ timeout: 15_000 })
|
||||
}
|
||||
roleA = await roleByName(api, names.roleA)
|
||||
roleB = await roleByName(api, names.roleB)
|
||||
expect(roleA.enabled && roleB.enabled).toBe(true)
|
||||
|
||||
// 预置第三动作行(默认允许):界面提交未提及它,提交后必须原样保留。
|
||||
await ok(await api.post('/api/admin/roles/permissions/batch', { data: { idempotencyKey: randomUUID(), roles: [roleA, roleB].map(role => ({
|
||||
roleId: role.roleId, expectedRoleVersion: role.roleVersion, changes: [{ actionId: keep.actionId, allowed: true, capAllowed: true }],
|
||||
})) } }))
|
||||
// 切走再回到分区使面板重挂载,草稿基线取预置后的最新版本。
|
||||
await openSection('用户管理')
|
||||
await rolesMatrix()
|
||||
|
||||
await category(DOC_CATEGORY).click()
|
||||
await expect(category(DOC_CATEGORY)).toHaveAttribute('aria-pressed', 'true')
|
||||
await cell(roleA, actA).selectOption('default')
|
||||
await cell(roleB, actB).selectOption('project')
|
||||
await expect(savebar()).toContainText('已修改 2 个角色、2 项设置')
|
||||
|
||||
const other = (await page.getByRole('group', { name: '权限分类' }).getByRole('button').allInnerTexts()).find(text => text.trim() !== DOC_CATEGORY)
|
||||
expect(other, '应存在第二个权限分类').toBeTruthy()
|
||||
await category(other!.trim()).click()
|
||||
await expect(cell(roleA, actA)).toHaveCount(0)
|
||||
await category(DOC_CATEGORY).click()
|
||||
await expect(cell(roleA, actA)).toHaveValue('default')
|
||||
await expect(cell(roleB, actB)).toHaveValue('project')
|
||||
await expect(savebar()).toContainText('已修改 2 个角色、2 项设置')
|
||||
|
||||
await page.getByLabel('搜索角色').fill(roleA.name)
|
||||
await expect(cell(roleB, actB)).toHaveCount(0, { timeout: 15_000 })
|
||||
await expect(savebar()).toContainText('已修改 2 个角色、2 项设置')
|
||||
await page.getByLabel('搜索角色').fill('')
|
||||
await expect(savebar()).toContainText('已修改 2 个角色、2 项设置')
|
||||
await page.getByLabel('搜索角色').fill(SUFFIX)
|
||||
await expect(cell(roleA, actA)).toHaveValue('default', { timeout: 15_000 })
|
||||
await expect(cell(roleB, actB)).toHaveValue('project')
|
||||
await expect(savebar()).toContainText('已修改 2 个角色、2 项设置')
|
||||
|
||||
await openPreview()
|
||||
const preview = previewPanel()
|
||||
for (const text of [roleA.name, roleB.name, actA.label, actB.label, '不允许', '默认允许', '按项目授权']) await expect(preview).toContainText(text)
|
||||
await confirmSave(2)
|
||||
|
||||
const freshA = await getRole(api, roleA.roleId)
|
||||
const freshB = await getRole(api, roleB.roleId)
|
||||
expect(rowOf(freshA, actA.actionId)).toMatchObject({ allowed: true, capAllowed: true })
|
||||
expect(rowOf(freshB, actB.actionId)).toMatchObject({ allowed: false, capAllowed: true })
|
||||
expect(rowOf(freshA, actB.actionId)?.capAllowed ?? false).toBe(false)
|
||||
expect(rowOf(freshB, actA.actionId)?.capAllowed ?? false).toBe(false)
|
||||
for (const role of [freshA, freshB]) expect(rowOf(role, keep.actionId)).toMatchObject({ allowed: true, capAllowed: true })
|
||||
})
|
||||
|
||||
test('C03 创建联动:开启创建项目自动补齐四项按项目授权,运行时 /api/me 联动生效', async ({ request }) => {
|
||||
await rolesMatrix()
|
||||
await category(categoryOf(projectCreate)).click()
|
||||
await cell(roleA, projectCreate).selectOption(projectCreate.scope === 'system' ? 'on' : 'default')
|
||||
await expect(savebar()).toContainText('已修改 1 个角色、1 项设置')
|
||||
await category(categoryOf(linked[0])).click()
|
||||
await expect(matrixRow(roleA).getByText(LINKED_NOTE).first()).toBeVisible()
|
||||
await expect(matrixRow(roleB).getByText(LINKED_NOTE)).toHaveCount(0)
|
||||
|
||||
await openPreview()
|
||||
const expansion = previewPanel().locator('.rp-linked')
|
||||
await expect(expansion).toContainText('系统将自动补齐')
|
||||
for (const item of linked) await expect(expansion).toContainText(item.label)
|
||||
await confirmSave(1)
|
||||
|
||||
const fresh = await getRole(api, roleA.roleId)
|
||||
expect(rowOf(fresh, PROJECT_CREATE)).toMatchObject({ allowed: true })
|
||||
for (const id of LINKED) expect(rowOf(fresh, id), id).toMatchObject({ allowed: false, capAllowed: true })
|
||||
|
||||
const password = `Permux!${SUFFIX}Aa9`
|
||||
const nextPassword = `Permux!${SUFFIX}Bb8`
|
||||
const created = await (await ok(await api.post('/api/admin/users', {
|
||||
data: { owner: names.linkedOwner, profile: { name: '联动验证' }, password },
|
||||
}))).json() as Account
|
||||
await assignRoleApi(api, created.userId, roleA.roleId)
|
||||
expect(await userRoleId(api, created.userId)).toBe(roleA.roleId)
|
||||
|
||||
await apiLogin(request, names.linkedOwner, password, nextPassword)
|
||||
const meRes = await request.get('/api/me')
|
||||
expect(meRes.status()).toBe(200)
|
||||
const me = await meRes.json() as Me
|
||||
expect(me.owner).toBe(names.linkedOwner)
|
||||
expect(me.linkedCapabilities?.projectCreate).toBe(true)
|
||||
for (const id of LINKED) expect(me.linkedCapabilities?.linkedActions ?? []).toContain(id)
|
||||
})
|
||||
|
||||
test('C04 断链拒绝:创建项目开启时联动项不允许不可保存,恢复按项目授权后保存成功', async () => {
|
||||
const before = await getRole(api, roleA.roleId)
|
||||
const broken = linked[0]
|
||||
await rolesMatrix()
|
||||
await category(DOC_CATEGORY).click()
|
||||
await cell(roleA, actB).selectOption('project')
|
||||
await category(categoryOf(broken)).click()
|
||||
await expect(cell(roleA, broken)).toHaveValue('project')
|
||||
await cell(roleA, broken).selectOption('denied')
|
||||
await expect(savebar()).toContainText('已修改 1 个角色、2 项设置')
|
||||
|
||||
await openPreview()
|
||||
const confirm = savebar().getByRole('button', { name: '确认保存', exact: true })
|
||||
const blockedNote = previewPanel().getByRole('alert')
|
||||
if (await blockedNote.isVisible()) {
|
||||
await expect(blockedNote).toContainText('存在冲突或错误,调整后才能保存')
|
||||
await expect(previewPanel()).toContainText(broken.label)
|
||||
await expect(confirm).toBeDisabled()
|
||||
} else {
|
||||
await expect(confirm).toBeEnabled()
|
||||
await confirm.click()
|
||||
await expect(page.locator('.rp-status').getByRole('alert')).toContainText(broken.label, { timeout: 15_000 })
|
||||
await expect(savebar()).toContainText('已修改 1 个角色、2 项设置')
|
||||
}
|
||||
const unchanged = await getRole(api, roleA.roleId)
|
||||
expect(unchanged.roleVersion).toBe(before.roleVersion)
|
||||
expect(permKey(unchanged)).toEqual(permKey(before))
|
||||
|
||||
await cell(roleA, broken).selectOption('project')
|
||||
await expect(savebar()).toContainText('已修改 1 个角色、1 项设置')
|
||||
await openPreview()
|
||||
await confirmSave(1)
|
||||
const fresh = await getRole(api, roleA.roleId)
|
||||
expect(rowOf(fresh, actB.actionId)).toMatchObject({ allowed: false, capAllowed: true })
|
||||
expect(rowOf(fresh, PROJECT_CREATE)).toMatchObject({ allowed: true })
|
||||
for (const id of LINKED) expect(rowOf(fresh, id), id).toMatchObject({ allowed: false, capAllowed: true })
|
||||
})
|
||||
|
||||
test('C07 同名辨识:两名同名用户按账号辨识,勾选互不串行', async () => {
|
||||
await openSection('用户管理')
|
||||
for (const owner of [names.owner1, names.owner2]) {
|
||||
await page.getByLabel('新用户登录名').fill(owner)
|
||||
await expand(page, '可选资料:姓名、电话、邮箱、工号、部门、备注')
|
||||
await page.locator('#new-user-name').fill(names.sameName)
|
||||
await page.getByRole('button', { name: '创建用户', exact: true }).click()
|
||||
await expect(status(`已创建 ${owner}`)).toBeVisible({ timeout: 15_000 })
|
||||
await page.getByRole('button', { name: '返回列表', exact: true }).click()
|
||||
}
|
||||
await page.getByLabel('搜索用户').fill(names.sameName)
|
||||
await expect(userRow(label1())).toBeVisible({ timeout: 15_000 })
|
||||
await expect(userRow(label2())).toBeVisible()
|
||||
await expect(page.getByText(`存在同名用户「${names.sameName}」`)).toBeVisible()
|
||||
for (const [label, owner] of [[label1(), names.owner1], [label2(), names.owner2]]) {
|
||||
await expect(userRow(label)).toContainText(names.sameName)
|
||||
await expect(userRow(label)).toContainText(owner)
|
||||
await expect(userRow(label)).toContainText('同名 · 按账号辨识')
|
||||
}
|
||||
|
||||
await userCheckbox(label1()).check()
|
||||
await expect(userCheckbox(label1())).toBeChecked()
|
||||
await expect(userCheckbox(label2())).not.toBeChecked()
|
||||
await expect(userRow(label2())).toContainText(names.owner2)
|
||||
await expect(userRow(label2())).not.toContainText(names.owner1)
|
||||
await expect(selectedBar()).toContainText('已选 1 人')
|
||||
})
|
||||
|
||||
test('C08 批量分配:两人预览 未分配 → 角色乙 后一次提交', async () => {
|
||||
await userCheckbox(label2()).check()
|
||||
await expect(selectedBar()).toContainText('已选 2 人')
|
||||
await selectedBar().getByRole('button', { name: '为选中的 2 人分配角色', exact: true }).click()
|
||||
const batch = page.getByRole('region', { name: '为选中的 2 人分配角色' })
|
||||
await batch.getByLabel('搜索可分配角色').fill(roleB.name)
|
||||
const target = batch.getByLabel('批量分配的目标角色')
|
||||
await expect(target.locator('option', { hasText: roleB.name })).toHaveCount(1, { timeout: 15_000 })
|
||||
await target.selectOption({ label: roleB.name })
|
||||
for (const owner of [names.owner1, names.owner2]) {
|
||||
await expect(batch.locator('li', { hasText: owner })).toContainText(`未分配 → ${roleB.name}`, { timeout: 15_000 })
|
||||
}
|
||||
await batch.getByRole('button', { name: '确认分配', exact: true }).click()
|
||||
await expect(status(`已为 2 人分配角色「${roleB.name}」`)).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
for (const owner of [names.owner1, names.owner2]) {
|
||||
const account = await userByOwner(api, owner)
|
||||
expect(await userRoleId(api, account.userId), owner).toBe(roleB.roleId)
|
||||
}
|
||||
await expect(userRow(label1())).toContainText(roleB.name)
|
||||
await expect(userRow(label2())).toContainText(roleB.name)
|
||||
})
|
||||
|
||||
test('C09 冲突整批不写:确认前他人改动一人角色,409 后整批未写入且保留选择', async () => {
|
||||
roleC = await (await ok(await api.post('/api/admin/roles', { data: { name: names.roleC, description: 'permux 并发改动' } }))).json() as Role
|
||||
const user1 = await userByOwner(api, names.owner1)
|
||||
const user2 = await userByOwner(api, names.owner2)
|
||||
|
||||
await userCheckbox(label1()).check()
|
||||
await userCheckbox(label2()).check()
|
||||
await expect(selectedBar()).toContainText('已选 2 人')
|
||||
await selectedBar().getByRole('button', { name: '为选中的 2 人分配角色', exact: true }).click()
|
||||
const batch = page.getByRole('region', { name: '为选中的 2 人分配角色' })
|
||||
await batch.getByLabel('搜索可分配角色').fill(roleA.name)
|
||||
const target = batch.getByLabel('批量分配的目标角色')
|
||||
await expect(target.locator('option', { hasText: roleA.name })).toHaveCount(1, { timeout: 15_000 })
|
||||
await target.selectOption({ label: roleA.name })
|
||||
for (const owner of [names.owner1, names.owner2]) {
|
||||
await expect(batch.locator('li', { hasText: owner })).toContainText(`${roleB.name} → ${roleA.name}`, { timeout: 15_000 })
|
||||
}
|
||||
const confirm = batch.getByRole('button', { name: '确认分配', exact: true })
|
||||
await expect(confirm).toBeEnabled()
|
||||
|
||||
await assignRoleApi(api, user2.userId, roleC.roleId)
|
||||
await confirm.click()
|
||||
const alert = page.getByRole('alert').filter({ hasText: '整批均未写入' })
|
||||
await expect(alert).toBeVisible({ timeout: 15_000 })
|
||||
await expect(alert).toContainText('请重新预览')
|
||||
await expect(userCheckbox(label1())).toBeChecked()
|
||||
await expect(userCheckbox(label2())).toBeChecked()
|
||||
await expect(selectedBar()).toContainText('已选 2 人')
|
||||
|
||||
expect(await userRoleId(api, user1.userId)).toBe(roleB.roleId)
|
||||
expect(await userRoleId(api, user2.userId)).toBe(roleC.roleId)
|
||||
})
|
||||
|
||||
test('C10 复制并分配:源角色权限复制到新角色并换绑,新角色独立维护', async () => {
|
||||
await selectedBar().getByRole('button', { name: '清空选择', exact: true }).click()
|
||||
await expect(selectedBar()).toHaveCount(0)
|
||||
const user1 = await userByOwner(api, names.owner1)
|
||||
const sourceBefore = await getRole(api, roleB.roleId)
|
||||
|
||||
await userRow(label1()).getByRole('button', { name: '查看与管理', exact: true }).click()
|
||||
await expect(page.getByRole('button', { name: '返回列表', exact: true })).toBeVisible()
|
||||
await expect(page.getByText(`当前角色 ${roleB.name}`)).toBeVisible({ timeout: 15_000 })
|
||||
await expand(page, '复制角色并分配…')
|
||||
await page.getByLabel('新角色名称(必填)').fill(names.roleD)
|
||||
await page.getByLabel('新角色描述').fill('permux 复制并分配')
|
||||
await expect(page.getByText(`相对「${roleB.name}」调整 0 项`)).toBeVisible()
|
||||
await page.getByRole('button', { name: '确认复制并分配', exact: true }).click()
|
||||
const done = status('新角色独立维护')
|
||||
await expect(done).toBeVisible({ timeout: 15_000 })
|
||||
await expect(done).toContainText(names.roleD)
|
||||
await expect(page.getByText(`当前角色 ${names.roleD}`)).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
const copied = await roleByName(api, names.roleD)
|
||||
expect(copied.roleId).not.toBe(roleB.roleId)
|
||||
expect(copied.description).toBe('permux 复制并分配')
|
||||
expect(permKey(copied)).toEqual(permKey(sourceBefore))
|
||||
expect(await userRoleId(api, user1.userId)).toBe(copied.roleId)
|
||||
const sourceAfter = await getRole(api, roleB.roleId)
|
||||
expect(sourceAfter.roleVersion).toBe(sourceBefore.roleVersion)
|
||||
expect(permKey(sourceAfter)).toEqual(permKey(sourceBefore))
|
||||
// 源角色仅失去被换绑的这一人,其余绑定不受影响。
|
||||
expect(sourceAfter.bindingCount).toBe(sourceBefore.bindingCount - 1)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
// 管理员发放一次性登录密码与登录入口回归:管理员生成一次性密码
|
||||
// → 用户密码登录 → 强制改密;登录页不再提供一次性 Key 登录 tab。
|
||||
// 管理员随机临时密码与登录入口回归:管理员重置 → 用户密码登录 → 强制改密;
|
||||
// 登录页不再提供一次性 Key 登录 tab。Key 的分发/管理 API 与非登录页流程保留。
|
||||
// 驱动:任意 auth-on 实例(PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE;
|
||||
// scripts/e2e.sh 第 4 阶段顺路覆盖,或对隔离实例直连——见 Dev 任务卡
|
||||
// feature/svc-login-recovery-flow 的真实浏览器验收记录)。
|
||||
@@ -51,17 +51,17 @@ async function adminLogin(page: Page) {
|
||||
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
|
||||
}
|
||||
|
||||
test('管理员生成一次性登录密码:用户登录后强制改密;登录页不提供 Key 登录', async ({ page }) => {
|
||||
test('管理员随机重置:临时密码登录后强制改密;登录页不提供 Key 登录', async ({ page }) => {
|
||||
// 全流程含多次 argon2 登录与两道门,30s 默认预算不够。
|
||||
test.setTimeout(120_000)
|
||||
// 1) 管理员进入用户管理,为目标用户生成一次性登录密码(明文仅显示一次)。
|
||||
// 1) 管理员进入用户管理,为目标用户生成随机临时密码(明文仅显示一次)。
|
||||
await adminLogin(page)
|
||||
await page.locator('.tsb-user').click()
|
||||
await page.getByRole('button', { name: '系统管理…' }).click()
|
||||
const zone = page.getByRole('dialog', { name: '系统管理' })
|
||||
await expect(zone).toBeVisible()
|
||||
const row = zone.locator('tr', { hasText: USER })
|
||||
await row.getByRole('button', { name: '生成一次性登录密码' }).click()
|
||||
await row.getByRole('button', { name: '重置临时密码' }).click()
|
||||
const temporaryPassword = zone.locator('input[aria-label="一次性密码"]')
|
||||
await expect(temporaryPassword).toBeVisible({ timeout: 10_000 })
|
||||
const password = await temporaryPassword.inputValue()
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { readFileSync, readlinkSync } from 'node:fs'
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
const manifestPath=process.env.WORK_CHECKPOINT_HTTP_MANIFEST
|
||||
test.skip(!manifestPath,'Run explicitly with a task-owned mechanism instance manifest')
|
||||
const manifest=manifestPath?JSON.parse(readFileSync(manifestPath,'utf8')):null
|
||||
if(manifest&&(manifest.mode!=='mechanism'||!manifest.dataDir.includes('fast-slow-u2-u3-')||readlinkSync(`/proc/${manifest.pid}/exe`)!==manifest.binary||createHash('sha256').update(readFileSync(manifest.binary)).digest('hex')!==manifest.binarySHA256))throw new Error('Owned mechanism instance verification failed')
|
||||
|
||||
type Scenario='normal'|'fast-ack'|'queue-hold'|'slow-stages'|'zero-failure'|'partial-failure'|'choice'
|
||||
async function setup(request:any,scenario:Scenario='normal',awaitTask=true,options:{text?:string,small?:boolean,startFailure?:boolean}={}){
|
||||
const project=await(await request.post(manifest.base+'/api/projects',{data:{name:'browser-'+Date.now()}})).json()
|
||||
const conv=await(await request.post(manifest.base+`/api/projects/${project.id}/conversations`,{data:{title:'材料整理',profileId:'checkpoint-eval'}})).json()
|
||||
const ids:string[]=[]
|
||||
for(let i=0;i<(options.small?1:3);i++){
|
||||
const marker=i===0&&scenario!=='normal'?`[fixture:${scenario}]\n`:''
|
||||
const res=await request.post(manifest.base+`/api/projects/${project.id}/uploads`,{multipart:{file:{name:`来源${i+1}.txt`,mimeType:'text/plain',buffer:Buffer.from(marker+'上线六月。验收条件需要补齐,预算50万元尚未审批。交付包含12项需求,其中3项依赖外部接口。第一期先完成8项核心需求。\n\n'+(options.small?'':('整理记录说明:保留日期、金额、限制和待确认事项。\n\n').repeat(options.text?250:100)))}}})
|
||||
expect(res.ok()).toBeTruthy();ids.push((await res.json()).artifactId)
|
||||
}
|
||||
if(options.startFailure)expect((await request.post(manifest.base+'/__fixture/material-start-failure',{data:{enabled:true}})).status()).toBe(204)
|
||||
let sendBody:any
|
||||
try{
|
||||
const sent=await request.post(manifest.base+`/api/conversations/${conv.id}/messages`,{data:{text:options.text??'',attachmentIds:ids,requestId:'browser-'+Date.now()}})
|
||||
expect(sent.ok()).toBeTruthy()
|
||||
sendBody=await sent.json()
|
||||
await expect.poll(async()=> (await(await request.get(manifest.base+`/api/runs/${sendBody.run.id}`)).json()).run.status).toBe('completed')
|
||||
}finally{
|
||||
if(options.startFailure)expect((await request.post(manifest.base+'/__fixture/material-start-failure',{data:{enabled:false}})).status()).toBe(204)
|
||||
}
|
||||
if(awaitTask)await expect.poll(async()=>{
|
||||
const out=await(await request.get(manifest.base+`/api/conversations/${conv.id}/cards`)).json()
|
||||
return out.cards.find((c:any)=>c.task)?.task.executionState
|
||||
}).toBe(scenario.endsWith('failure')?'failed':'completed')
|
||||
return {project,conv,ids,sendRunId:sendBody.run.id}
|
||||
}
|
||||
async function waitForFailedWorkCard(request:any,seed:any){
|
||||
await expect.poll(async()=>{
|
||||
const out=await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()
|
||||
const card=out.cards.find((c:any)=>c.task)
|
||||
return card?.task.executionState==='failed'&&card.status==='failed'&&Boolean(card.work)
|
||||
}).toBe(true)
|
||||
}
|
||||
async function expectOnlySendRun(request:any,seed:any){
|
||||
const current=await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}`)).json()
|
||||
expect(current.runs.filter((r:any)=>r.kind==='conversation').map((r:any)=>r.id)).toEqual([seed.sendRunId])
|
||||
}
|
||||
async function open(page:any,seed:any){
|
||||
await page.addInitScript(({id}:{id:string})=>{localStorage.setItem('as.conv',id);localStorage.setItem('as.ui',JSON.stringify({theme:'light',rightCollapsed:true,fontSize:14}))},{id:seed.conv.id})
|
||||
await page.goto('/')
|
||||
await expect(page.locator('[data-conversation-work]')).toContainText('上线六月')
|
||||
}
|
||||
test('attachment-only intake gives a safe first reply before sourced findings',async({page,request})=>{
|
||||
const seed=await setup(request,'fast-ack',false)
|
||||
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
|
||||
expect(cards.filter((c:any)=>c.task)).toHaveLength(1)
|
||||
expect(cards.find((c:any)=>c.task).task.checkpointSeq??0).toBe(0)
|
||||
await open(page,seed)
|
||||
const reply=page.getByText(/已收到 3 份材料.*待确认/)
|
||||
await expect(reply).toBeVisible()
|
||||
await expect(page.getByText(/剩余\s*4\s*项.*含\s*3\s*项依赖接口/)).toHaveCount(0)
|
||||
await page.setViewportSize({width:390,height:844})
|
||||
await expect(reply).toBeVisible()
|
||||
if(process.env.WORK_CHECKPOINT_SCREENSHOT)await page.screenshot({path:process.env.WORK_CHECKPOINT_SCREENSHOT.replace(/\.png$/, '-mobile.png'),fullPage:true})
|
||||
await page.reload()
|
||||
await expect(reply).toBeVisible()
|
||||
})
|
||||
test('text with long material keeps the user focus in the host reply',async({page,request})=>{
|
||||
const seed=await setup(request,'fast-ack',false,{text:'重点核对预算审批'})
|
||||
await open(page,seed)
|
||||
await expect(page.getByText(/你提出的工作重点已带入整理任务.*待确认/)).toBeVisible()
|
||||
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
|
||||
expect(cards.filter((c:any)=>c.task)).toHaveLength(1)
|
||||
})
|
||||
test('running and completed resends reuse one material card',async({page,request})=>{
|
||||
const seed=await setup(request,'slow-stages',false)
|
||||
const resend=async(key:string)=>{
|
||||
const response=await request.post(manifest.base+`/api/conversations/${seed.conv.id}/messages`,{data:{text:'',attachmentIds:seed.ids,requestId:key}})
|
||||
expect(response.ok()).toBeTruthy()
|
||||
const body=await response.json()
|
||||
await expect.poll(async()=> (await(await request.get(manifest.base+`/api/runs/${body.run.id}`)).json()).run.status).toBe('completed')
|
||||
}
|
||||
await resend('active-resend-'+Date.now())
|
||||
await open(page,seed)
|
||||
await expect(page.getByText(/这批材料仍在整理.*待确认/)).toBeVisible()
|
||||
await expect.poll(async()=>{
|
||||
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
|
||||
return cards.find((c:any)=>c.task)?.task.executionState
|
||||
}).toBe('completed')
|
||||
await resend('completed-resend-'+Date.now())
|
||||
await expect(page.getByText(/这批材料已整理完成.*待确认/)).toBeVisible()
|
||||
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
|
||||
expect(cards.filter((c:any)=>c.task)).toHaveLength(1)
|
||||
})
|
||||
test('text with a small attachment retains the ordinary reply path',async({page,request})=>{
|
||||
const seed=await setup(request,'normal',true,{text:'请先解释材料要点',small:true})
|
||||
await open(page,seed)
|
||||
await expect(page.getByText('已收到材料,正在后台整理;请结合阶段发现继续工作。').first()).toBeVisible()
|
||||
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
|
||||
expect(cards.filter((c:any)=>c.task)).toHaveLength(1)
|
||||
})
|
||||
test('real HTTP task recovery, source navigation and changed-version limitation',async({page,request})=>{
|
||||
const seed=await setup(request);const errors:string[]=[];page.on('pageerror',e=>errors.push(e.message));await open(page,seed)
|
||||
await expect(page.locator('[data-conversation-work]')).toContainText('验收条件需要补齐')
|
||||
await expect(page.locator('[data-conversation-work]')).toContainText('材料未说明具体归属')
|
||||
await page.reload();await expect(page.locator('[data-conversation-work]')).toContainText('上线六月')
|
||||
await page.getByRole('button',{name:'查看依据',exact:true}).first().click()
|
||||
await page.getByRole('button',{name:/查看来源/}).first().click()
|
||||
await expect(page.locator('.preview-pane')).toBeVisible();await expect(page.getByText('任务处理时引用的来源版本;下方是本项发现引用的片段')).toBeVisible()
|
||||
const card=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards.find((c:any)=>c.task)
|
||||
const source=card.task.findings[0].sources[0]
|
||||
const update=await request.put(manifest.base+`/api/artifacts/${source.artifactId}/content`,{data:'上线七月。文件内容已经更新。',headers:{'Content-Type':'text/plain'}});expect(update.ok()).toBeTruthy()
|
||||
const old=await request.get(manifest.base+`/api/artifacts/${source.artifactId}/content?expectedHash=${source.contentHash}`);expect(old.status()).toBe(409)
|
||||
await page.reload();await expect(page.locator('[data-conversation-work]')).toContainText('依据文件已更新')
|
||||
await expect(page.locator('[data-conversation-work] .acard-findings').first()).not.toContainText('上线六月')
|
||||
expect(errors).toEqual([])
|
||||
if(process.env.WORK_CHECKPOINT_SCREENSHOT)await page.screenshot({path:process.env.WORK_CHECKPOINT_SCREENSHOT,fullPage:true})
|
||||
})
|
||||
test('background replay does not steal the current file, focus or viewport',async({page,request})=>{
|
||||
const seed=await setup(request);await open(page,seed)
|
||||
await page.getByRole('button',{name:'查看依据',exact:true}).first().click();await page.getByRole('button',{name:/查看来源/}).first().click();await expect(page.locator('.preview-pane')).toBeVisible()
|
||||
const tabs=await page.locator('.tab-strip').textContent()
|
||||
const composer=page.locator('textarea').last();await composer.fill('正在记录自己的想法');await composer.focus()
|
||||
const focus=await composer.evaluate((el:any)=>document.activeElement===el);expect(focus).toBe(true)
|
||||
const sent=await request.post(manifest.base+`/api/conversations/${seed.conv.id}/messages`,{data:{text:'重点核对预算',attachmentIds:seed.ids,requestId:'another-'+Date.now()}});expect(sent.ok()).toBeTruthy()
|
||||
await expect.poll(async()=>{
|
||||
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
|
||||
return cards.filter((c:any)=>c.task?.executionState==='completed').length
|
||||
}).toBe(2)
|
||||
await expect(composer).toBeFocused();await expect(composer).toHaveValue('正在记录自己的想法')
|
||||
expect(await page.locator('.tab-strip').textContent()).toBe(tabs)
|
||||
})
|
||||
test('two sourced stages appear while the material task is still running',async({page,request})=>{
|
||||
const seed=await setup(request,'slow-stages',false)
|
||||
await open(page,seed)
|
||||
const cards=async()=>(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
|
||||
await expect.poll(async()=>{const card=(await cards()).find((c:any)=>c.task);return card?.task.executionState}).toBe('running')
|
||||
await expect(page.locator('[data-conversation-work]')).toContainText('上线六月')
|
||||
await expect.poll(async()=>{const card=(await cards()).find((c:any)=>c.task);return card?.task.executionState==='running'&&card.task.checkpointSeq>=2&&card.task.findings?.length>1}).toBe(true)
|
||||
await expect(page.locator('[data-conversation-work]')).toContainText('验收条件需要补齐')
|
||||
await expect.poll(async()=>{const card=(await cards()).find((c:any)=>c.task);return card?.task.executionState}).toBe('completed')
|
||||
})
|
||||
for(const scenario of ['zero-failure','partial-failure'] as const){
|
||||
test(`${scenario} shows the saved scope without a deliverable choice`,async({page,request})=>{
|
||||
const seed=await setup(request,scenario)
|
||||
await waitForFailedWorkCard(request,seed)
|
||||
await page.addInitScript(({id}:{id:string})=>{localStorage.setItem('as.conv',id);localStorage.setItem('as.ui',JSON.stringify({theme:'light',rightCollapsed:true,fontSize:14}))},{id:seed.conv.id})
|
||||
await page.goto('/')
|
||||
const work=page.locator('[data-conversation-work]')
|
||||
await expect(work).toContainText('执行失败')
|
||||
if(scenario==='zero-failure')await expect(work).toContainText('暂无阶段结论记录')
|
||||
else await expect(work).toContainText('上线六月')
|
||||
await expect(page.locator('.work-choice')).toHaveCount(0)
|
||||
await expect(page.locator('.hitl-choice')).toHaveCount(0)
|
||||
await expectOnlySendRun(request,seed)
|
||||
})
|
||||
}
|
||||
test('completed sourced work offers only its grounded suggestions',async({page,request})=>{
|
||||
const seed=await setup(request,'choice')
|
||||
await open(page,seed)
|
||||
const work=page.locator('[data-conversation-work]')
|
||||
await expect(work.locator('.work-choice')).toHaveCount(3)
|
||||
for(const label of ['会议摘要','验收清单','联调计划'])await expect(work.locator('.work-choice').filter({hasText:label})).toHaveCount(1)
|
||||
await expect(work.locator('.work-decision legend')).toContainText('接下来需要做什么?')
|
||||
await page.reload()
|
||||
await expect(work.locator('.work-choice')).toHaveCount(3)
|
||||
})
|
||||
test('failed material registration reports no started task or unsupported finding',async({page,request})=>{
|
||||
const seed=await setup(request,'normal',false,{startFailure:true})
|
||||
await page.addInitScript(({id}:{id:string})=>{localStorage.setItem('as.conv',id);localStorage.setItem('as.ui',JSON.stringify({theme:'light',rightCollapsed:true,fontSize:14}))},{id:seed.conv.id})
|
||||
await page.goto('/')
|
||||
await expect(page.getByText(/后台整理未能启动/).first()).toBeVisible()
|
||||
const conversation=await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}`)).json()
|
||||
expect(conversation.messages.some((m:any)=>m.role==='assistant'&&(m.parts??[]).some((p:any)=>p.type==='text'&&p.text?.includes('未能启动')))).toBe(true)
|
||||
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards
|
||||
expect(cards.filter((c:any)=>c.task)).toHaveLength(0)
|
||||
await expect(page.getByText(/剩余\s*4\s*项.*含\s*3\s*项依赖接口/)).toHaveCount(0)
|
||||
})
|
||||
test('saturated material slots show queued status without claiming execution',async({page,request})=>{
|
||||
const seed=await setup(request,'queue-hold',false)
|
||||
const sendAnother=async(label:string,hold:boolean)=>{
|
||||
const uploaded=await request.post(manifest.base+`/api/projects/${seed.project.id}/uploads`,{
|
||||
multipart:{file:{name:`${label}.txt`,mimeType:'text/plain',buffer:Buffer.from((hold?'[fixture:queue-hold]\n':'')+'上线六月。验收条件需要补齐。'+label)}}
|
||||
})
|
||||
expect(uploaded.ok()).toBeTruthy()
|
||||
const artifactId=(await uploaded.json()).artifactId
|
||||
const sent=await request.post(manifest.base+`/api/conversations/${seed.conv.id}/messages`,{data:{text:'',attachmentIds:[artifactId],requestId:'queue-'+label}})
|
||||
expect(sent.ok()).toBeTruthy()
|
||||
const runId=(await sent.json()).run.id
|
||||
await expect.poll(async()=> (await(await request.get(manifest.base+`/api/runs/${runId}`)).json()).run.status).toBe('completed')
|
||||
}
|
||||
await sendAnother('占用二',true)
|
||||
await sendAnother('占用三',true)
|
||||
await sendAnother('等待四',false)
|
||||
await page.addInitScript(({id}:{id:string})=>{localStorage.setItem('as.conv',id);localStorage.setItem('as.ui',JSON.stringify({theme:'light',rightCollapsed:true,fontSize:14}))},{id:seed.conv.id})
|
||||
await page.goto('/')
|
||||
await expect(page.locator('[data-conversation-work]')).toContainText('排队中')
|
||||
await expect(page.getByText(/整理任务正在排队.*待确认/).first()).toBeVisible()
|
||||
const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards.filter((c:any)=>c.task)
|
||||
expect(cards).toHaveLength(4)
|
||||
expect(cards.filter((c:any)=>c.task.executionState==='queued')).toHaveLength(1)
|
||||
await expect(page.getByText(/剩余\s*4\s*项.*含\s*3\s*项依赖接口/)).toHaveCount(0)
|
||||
})
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"exports": 58
|
||||
"exports": 51
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
// 账号管理动作呈现门控(超级管理员 R3/R4/R8):只决定 UI 暴露哪些入口,
|
||||
// 服务端授权仍是唯一权威——隐藏的入口经直连 API 调用同样会被拒绝。
|
||||
interface AccountIdentity { isAdmin?: boolean; isSuperAdmin?: boolean }
|
||||
|
||||
type AccountIdentityKind = 'super' | 'admin' | 'user'
|
||||
|
||||
export const accountIdentityKind = (account: AccountIdentity): AccountIdentityKind =>
|
||||
account.isSuperAdmin ? 'super' : account.isAdmin ? 'admin' : 'user'
|
||||
|
||||
/** 创建管理员账号与任命/撤销管理员仅对超级管理员开放。 */
|
||||
export const canAppointAdmins = (me: AccountIdentity | null | undefined) => !!me?.isSuperAdmin
|
||||
|
||||
interface AccountActionPolicy {
|
||||
/** 资料、角色与系统权限维护。 */
|
||||
maintain: boolean
|
||||
toggleEnabled: boolean
|
||||
/** 管理员重置临时密码与恢复提案。 */
|
||||
resetPassword: boolean
|
||||
adminChange: 'promote' | 'demote' | null
|
||||
protectedReason: string
|
||||
}
|
||||
|
||||
export function accountActionPolicy(me: AccountIdentity | null | undefined, target: AccountIdentity): AccountActionPolicy {
|
||||
if (target.isSuperAdmin) {
|
||||
const self = !!me?.isSuperAdmin
|
||||
return {
|
||||
maintain: self, toggleEnabled: false, resetPassword: false, adminChange: null,
|
||||
protectedReason: self
|
||||
? '超级管理员账号不能停用、降级或由管理员重置密码;本人密码请在「设置 → 账号」修改'
|
||||
: '超级管理员账号受保护:普通管理员不能编辑、停用、降级或重置其密码',
|
||||
}
|
||||
}
|
||||
return {
|
||||
maintain: true, toggleEnabled: true, resetPassword: true,
|
||||
adminChange: canAppointAdmins(me) ? (target.isAdmin ? 'demote' : 'promote') : null,
|
||||
protectedReason: '',
|
||||
}
|
||||
}
|
||||
+108
-26
@@ -14,8 +14,26 @@ 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 }
|
||||
/** category:角色矩阵业务分类(项目与协作/文档工作/项目内容);受保护系统授权为空。 */
|
||||
export interface PermissionDescriptor { actionId: string; label: string; description: string; group: string; category?: string; scope: 'system' | 'project'; protected: boolean }
|
||||
export interface RolePermissionRecord { actionId: string; allowed: boolean; capAllowed: boolean; permissionVersion: number }
|
||||
export interface RolePermissionChangeInput { actionId: string; allowed: boolean; capAllowed: boolean }
|
||||
/** changes 为显式 patch:只含相对基线变化的动作,服务端合并保留未提及行。 */
|
||||
export interface RolePermissionPatchEntry { roleId: string; expectedRoleVersion: number; changes: RolePermissionChangeInput[] }
|
||||
/** 预览模式:缺省为服务端默认 replace;显式 patch 提交须以 patch 预览。 */
|
||||
export type RolePermissionPreviewMode = 'replace' | 'patch'
|
||||
/** before/after 为中文状态名:不允许/按项目授权/默认允许。 */
|
||||
export interface RolePermissionStateChange { actionId: string; label: string; before: string; after: string }
|
||||
export interface RolePermissionPreview {
|
||||
roleId: string; roleName: string; expectedRoleVersion: number; currentRoleVersion: number; versionMatches: boolean
|
||||
changes: RolePermissionStateChange[]; linkedExpansion: string[]; linkedExpansionState?: string; keptLinkedActions: string[]
|
||||
migrationNote?: string; conflicts: { actionId: string; reason: string }[]; error?: string; errorActionId?: string
|
||||
}
|
||||
export interface RolePermissionBatchReceipt {
|
||||
roleId: string; previousRoleVersion: number; roleVersion: number; linkedExpansion?: string[]; keptLinkedActions: string[]
|
||||
migrationNote?: string; changes: RolePermissionStateChange[]
|
||||
}
|
||||
export interface RolePermissionBatchResult { receiptId: string; authorityEpoch: number; receipts: RolePermissionBatchReceipt[] }
|
||||
export interface RoleRecord {
|
||||
roleId: string; name: string; description: string; enabled: boolean; roleVersion: number
|
||||
bindingCount: number; permissions: RolePermissionRecord[]
|
||||
@@ -28,29 +46,56 @@ export interface UserRoleBinding {
|
||||
export interface UserRoleView { binding?: UserRoleBinding; role?: RoleRecord }
|
||||
|
||||
/** 账户链路(2026-10-13 恢复合同 + 2026-10-14 current_required 加固):
|
||||
* passwordChangeMode 由服务端从会话派生;grant_setup/one_time_password 免当前密码,
|
||||
* passwordChangeMode 由服务端从会话派生;grant_setup 免当前密码,
|
||||
* current_required 必须校验当前密码。 */
|
||||
export type PasswordChangeMode = 'grant_setup' | 'current_required' | 'one_time_password'
|
||||
export type PasswordChangeMode = 'grant_setup' | 'current_required'
|
||||
|
||||
/** /api/me 联动资格(L1 联动后由服务端给出;旧服务无此字段)。actions/conflicts 为动作 id。 */
|
||||
export interface LinkedCapabilities { projectCreate: boolean; source?: string; actions: string[]; conflicts?: string[] }
|
||||
|
||||
/** 恢复提案(属主确认制):管理员只能提案,确认须用提案前已存在的合格完整密码会话。 */
|
||||
export interface RecoveryProposal {
|
||||
id: string; userId: string; owner: string
|
||||
status: 'pending' | 'confirmed' | 'completed' | 'revoked' | 'expired'
|
||||
version: number; generation: number; createdBy: string
|
||||
createdAt: string; expiresAt: string
|
||||
}
|
||||
|
||||
/** 账号资料与档案视图(backend accountProfileJSON 契约)。 */
|
||||
export interface AccountProfile { name: string; phone: string; email: string; staffNo: string; department: string; notes: string }
|
||||
export interface AccountRecord {
|
||||
userId: string; owner: string; profile: AccountProfile; profileVersion: number
|
||||
enabled?: boolean; stateVersion?: number; isAdmin?: boolean; isSuperAdmin?: boolean; passwordSet?: boolean
|
||||
enabled?: boolean; stateVersion?: number; isAdmin?: boolean; passwordSet?: boolean
|
||||
initializationState?: string; initializationGeneration?: number; createdAt?: string
|
||||
}
|
||||
/** 用户目录角色投影:actor 具备 system.roles.view 时返回(无绑定为 null);无查看权时字段缺省。 */
|
||||
export interface AccountRoleSummary { roleId: string; name: string; enabled: boolean }
|
||||
/** 管理端用户目录/详情视图:账号状态与版本字段必有(乐观并发依赖 stateVersion)。 */
|
||||
export interface ManagedAccountRecord extends AccountRecord {
|
||||
enabled: boolean; stateVersion: number; isAdmin: boolean; isSuperAdmin: boolean; passwordSet: boolean
|
||||
enabled: boolean; stateVersion: number; isAdmin: boolean; passwordSet: boolean
|
||||
initializationState: string; initializationGeneration: number
|
||||
role?: AccountRoleSummary | null
|
||||
}
|
||||
interface AccountSearchPage { accounts: ManagedAccountRecord[]; nextCursor?: string; directoryVersion: string }
|
||||
export interface AdminIssuedOneTimePassword {
|
||||
owner: string
|
||||
id: string
|
||||
oneTimePassword: string
|
||||
mustChangePassword: boolean
|
||||
/** 批量角色分配条目:一切以 userId/roleId 为稳定标识。 */
|
||||
export interface UserRoleAssignmentInput { userId: string; roleId: string; expectedStateVersion: number }
|
||||
/** error 为服务端中文原因(停用/版本变化/角色无效);任何一项有 error 或 stateMatches=false 都不得提交。 */
|
||||
export interface UserRoleAssignmentPreview {
|
||||
userId: string; owner: string; name: string; currentRoleId: string; currentRoleName: string
|
||||
targetRoleId: string; targetRoleName: string; stateMatches: boolean; error?: string
|
||||
}
|
||||
interface UserRoleAssignmentResult { userId: string; bindingId: string; roleId: string; previousRoleId: string; userStateVersion: number }
|
||||
interface UserRoleAssignmentBatch { receiptId?: string; authorityEpoch?: number; bindings: UserRoleAssignmentResult[] }
|
||||
export interface RoleMember {
|
||||
userId: string; owner: string; name: string; enabled: boolean; stateVersion: number; assignedAt: string; bindingVersion: number
|
||||
}
|
||||
interface RoleMemberPage { members: RoleMember[]; nextCursor?: string }
|
||||
/** 复制角色并分配:新角色尚不存在,分配条目只带 userId 与状态版本(服务端严格拒绝未知字段)。 */
|
||||
export interface RoleCopyAssignRequest {
|
||||
idempotencyKey: string; source: { roleId: string; expectedRoleVersion: number }; name: string; description: string
|
||||
overrides?: RolePermissionChangeInput[]; assignments: { userId: string; expectedStateVersion: number }[]
|
||||
}
|
||||
interface RoleCopyAssignResult { receiptId?: string; authorityEpoch?: number; role: RoleRecord; bindings: UserRoleAssignmentResult[] }
|
||||
|
||||
export interface ExecEnvView {
|
||||
configured: boolean
|
||||
@@ -95,13 +140,35 @@ export const api = {
|
||||
patchRole: (roleId: string, expectedRoleVersion: number, patch: { name?: string; description?: string; enabled?: boolean }) =>
|
||||
auth.fetch(`${BASE}/api/admin/roles/${encodeURIComponent(roleId)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ expectedRoleVersion, ...patch }) }).then(r => json<RoleRecord>(r)),
|
||||
replaceRolePermissions: (roleId: string, expectedRoleVersion: number, changes: { actionId: string; allowed: boolean; capAllowed: boolean }[], idempotencyKey: string) =>
|
||||
auth.fetch(`${BASE}/api/admin/roles/${encodeURIComponent(roleId)}/permissions`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ expectedRoleVersion, changes, idempotencyKey }) }).then(r => json<{ receiptId: string; roleVersion: number; authorityEpoch: number }>(r)),
|
||||
previewRolePermissions: (roles: RolePermissionPatchEntry[], options: { mode?: RolePermissionPreviewMode } = {}) =>
|
||||
auth.fetch(`${BASE}/api/admin/roles/permissions/preview`, { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ roles: options.mode ? roles.map(role => ({ ...role, mode: options.mode })) : roles }) }).then(r => json<{ previews: RolePermissionPreview[] }>(r)).then(d => d.previews ?? []),
|
||||
batchPatchRolePermissions: (roles: RolePermissionPatchEntry[], idempotencyKey: string) =>
|
||||
auth.fetch(`${BASE}/api/admin/roles/permissions/batch`, { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ idempotencyKey, roles }) }).then(r => json<RolePermissionBatchResult>(r)),
|
||||
getUserRole: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/role`).then(r => json<UserRoleView>(r)),
|
||||
assignUserRole: (userId: string, roleId: string, expectedStateVersion: number, idempotencyKey: string) =>
|
||||
auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/role`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ roleId, expectedStateVersion, idempotencyKey }) }).then(r => json<UserRoleBinding>(r)),
|
||||
previewUserRoleAssignments: (assignments: UserRoleAssignmentInput[]) =>
|
||||
auth.fetch(`${BASE}/api/admin/users/role-assignments/preview`, { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ assignments }) }).then(r => json<{ previews: UserRoleAssignmentPreview[] }>(r)).then(d => d.previews ?? []),
|
||||
assignUserRolesBatch: (assignments: UserRoleAssignmentInput[], idempotencyKey: string) =>
|
||||
auth.fetch(`${BASE}/api/admin/users/role-assignments`, { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ idempotencyKey, assignments }) }).then(r => json<UserRoleAssignmentBatch>(r)),
|
||||
listRoleMembers: (roleId: string, request: { query?: string; mode?: 'exact' | 'fuzzy'; status?: 'all' | 'enabled' | 'disabled'; 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.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/${encodeURIComponent(roleId)}/members${query ? `?${query}` : ''}`).then(r => json<RoleMemberPage>(r))
|
||||
},
|
||||
createRoleCopyAndAssign: (request: RoleCopyAssignRequest) =>
|
||||
auth.fetch(`${BASE}/api/admin/roles/copy-assign`, { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request) }).then(r => json<RoleCopyAssignResult>(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' },
|
||||
@@ -126,11 +193,19 @@ export const api = {
|
||||
projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json<SurfaceMeta[]>(r)),
|
||||
/** 单页权威快照(含 identity + spec),供列表水合与预览渲染。 */
|
||||
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<SurfaceSnapshot>(r)),
|
||||
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; sessionScope?: string; passwordChangeMode?: PasswordChangeMode; isAdmin?: boolean; isSuperAdmin?: boolean; mustChangePassword?: boolean; passwordSet?: boolean; systemActions?: string[] }>(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[]; linkedCapabilities?: LinkedCapabilities }>(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()}`)
|
||||
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string; passwordChangeMode?: PasswordChangeMode }
|
||||
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string; passwordChangeMode?: PasswordChangeMode }
|
||||
auth.beginSession()
|
||||
return me
|
||||
}),
|
||||
// 一次性登录 Key 兑换(恢复/激活):请求只带 Key,身份由服务端绑定记录决定;
|
||||
// 成功建立 set_password_only 受限会话(HttpOnly cookie),明文 Key 不落任何存储。
|
||||
redeemLoginKey: (key: string) => auth.fetch(`${BASE}/api/auth/recovery-grants/redeem`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }) }).then(async r => {
|
||||
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
|
||||
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string; passwordChangeMode?: PasswordChangeMode }
|
||||
auth.beginSession()
|
||||
return me
|
||||
}),
|
||||
@@ -141,28 +216,37 @@ export const api = {
|
||||
},
|
||||
changePassword: (current: string, next: string) => auth.fetch(`${BASE}/api/auth/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ current, next }) }).then(async r => {
|
||||
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
|
||||
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; reloginRequired: boolean }>
|
||||
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; reloginRequired: boolean }>
|
||||
}),
|
||||
listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ userId: string; owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number; profileVersion: number; initializationState: string }[]>(r)),
|
||||
listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ userId: string; owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number; profileVersion: number; initializationState: string }[]>(r)),
|
||||
// 管理员建号:可提供初始密码;省略密码时由服务端生成并仅返回一次。
|
||||
// isAdmin 仅超级管理员可用(R3/R4),普通管理员携带时由服务端拒绝。
|
||||
createAdminUser: (owner: string, name: string, password?: string, isAdmin?: boolean) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile: { name }, ...(password ? { password } : {}), ...(isAdmin ? { isAdmin: true } : {}) }) }).then(r => json<{ userId: string; owner: string; passwordSet: boolean; mustChangePassword: boolean; initializationState: string; initialPassword?: string }>(r)),
|
||||
adminIssueOneTimePassword: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/one-time-password`, { method: 'POST' }).then(r => json<AdminIssuedOneTimePassword>(r)),
|
||||
createAdminUser: (owner: string, name: string, password?: string) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile: { name }, ...(password ? { password } : {}) }) }).then(r => json<{ userId: string; owner: string; passwordSet: boolean; mustChangePassword: boolean; initializationState: string; initialPassword?: string }>(r)),
|
||||
adminResetUserPassword: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }).then(r => json<{ owner: string; temporaryPassword: string; mustChangePassword: boolean }>(r)),
|
||||
searchAdminUsers: (request: { query: string; mode: 'exact' | 'fuzzy'; status: 'all' | 'enabled' | 'disabled'; fields: string[]; cursor?: string; limit?: number }) => {
|
||||
const params = new URLSearchParams({ query: request.query, mode: request.mode, status: request.status,
|
||||
fields: request.fields.join(','), limit: String(request.limit ?? 20) })
|
||||
if (request.cursor) params.set('cursor', request.cursor)
|
||||
return auth.fetch(`${BASE}/api/admin/users/search?${params}`).then(r => json<AccountSearchPage>(r))
|
||||
},
|
||||
createManagedUser: (owner: string, profile: Partial<AccountProfile>, isAdmin?: boolean) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile, ...(isAdmin ? { isAdmin: true } : {}) }) }).then(r => json<ManagedAccountRecord>(r)),
|
||||
createManagedUser: (owner: string, profile: Partial<AccountProfile>) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile }) }).then(r => json<ManagedAccountRecord>(r)),
|
||||
adminGetUser: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}`).then(r => json<ManagedAccountRecord>(r)),
|
||||
adminPatchProfile: (userId: string, expectedProfileVersion: number, patch: Partial<AccountProfile>) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/profile`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedProfileVersion, patch }) }).then(r => json<ManagedAccountRecord>(r)),
|
||||
adminSetUserEnabled: (userId: string, expectedStateVersion: number, enabled: boolean) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/state`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedStateVersion, enabled }) }).then(r => json<ManagedAccountRecord>(r)),
|
||||
// 任命/撤销管理员:仅超级管理员(R3);超级管理员本身不可降级(R5)。
|
||||
adminSetUserAdmin: (userId: string, expectedStateVersion: number, isAdmin: boolean) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/admin`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedStateVersion, isAdmin }) }).then(r => json<ManagedAccountRecord>(r)),
|
||||
// ---- 本人:资料(须完整密码会话)----
|
||||
// ---- 管理员:一次性登录 Key(HTTP 签发端点已被恢复提案契约替换为 410,客户端方法随之移除;
|
||||
// list/revoke 仍有效,供恢复提案视图复用;兑换入口不变)----
|
||||
adminListLoginKeys: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`).then(r => json<{ keys: { id: string; owner: string; purpose: string; status: string; expiresAt: string; createdAt: string; createdBy?: string }[] }>(r)).then(d => d.keys ?? []),
|
||||
adminRevokeLoginKey: (owner: string, id: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys/${encodeURIComponent(id)}`, { method: 'DELETE' }).then(r => json<{ ok: boolean }>(r)),
|
||||
// ---- 管理员:恢复提案(属主确认制,替换已 410 的直接签发 Key 入口)----
|
||||
createAdminRecoveryProposal: (userId: string, expectedStateVersion: number, idempotencyKey: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/recovery-proposals`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedStateVersion, idempotencyKey }) }).then(r => json<RecoveryProposal>(r)),
|
||||
listAdminRecoveryProposals: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/recovery-proposals`).then(r => json<{ proposals: RecoveryProposal[] }>(r)).then(d => d.proposals ?? []),
|
||||
adminRevokeRecoveryProposal: (userId: string, id: string, expectedVersion: number) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/recovery-proposals/${encodeURIComponent(id)}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedVersion }) }).then(r => json<RecoveryProposal>(r)),
|
||||
// ---- 本人:资料与恢复提案确认(须完整密码会话)----
|
||||
meProfile: () => auth.fetch(`${BASE}/api/me/profile`).then(r => json<AccountRecord>(r)),
|
||||
patchMeProfile: (expectedProfileVersion: number, patch: Partial<AccountProfile>) => auth.fetch(`${BASE}/api/me/profile`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedProfileVersion, patch }) }).then(r => json<AccountRecord>(r)),
|
||||
listMyRecoveryProposals: () => auth.fetch(`${BASE}/api/me/recovery-proposals`).then(r => json<{ proposals: RecoveryProposal[] }>(r)).then(d => d.proposals ?? []),
|
||||
getMyRecoveryProposal: (id: string) => auth.fetch(`${BASE}/api/me/recovery-proposals/${encodeURIComponent(id)}`).then(r => json<RecoveryProposal>(r)),
|
||||
confirmMyRecoveryProposal: (id: string, expectedVersion: number) => auth.fetch(`${BASE}/api/auth/recovery-proposals/${encodeURIComponent(id)}/confirm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedVersion }) }).then(r => json<{ proposal: RecoveryProposal; grant?: string }>(r)),
|
||||
rotateMyRecoveryGrant: (id: string, expectedVersion: number, expectedGeneration: number) => auth.fetch(`${BASE}/api/auth/recovery-proposals/${encodeURIComponent(id)}/rotate-grant`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedVersion, expectedGeneration }) }).then(r => json<{ proposal: RecoveryProposal; grant?: string }>(r)),
|
||||
adminExecEnv: () => auth.fetch(`${BASE}/api/admin/exec-env`).then(r => json<ExecEnvView>(r)),
|
||||
adminExecEnvDecision: (id: string, allow: boolean, isolate: boolean) => auth.fetch(`${BASE}/api/admin/exec-env/requests/${encodeURIComponent(id)}/decision`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ allow, isolate }) }).then(r => json<{ status: string; detail?: string; layerId?: string }>(r)),
|
||||
adminExecEnvRebase: () => auth.fetch(`${BASE}/api/admin/exec-env/rebase`, { method: 'POST' }).then(r => json<{ base: string }>(r)),
|
||||
@@ -199,8 +283,6 @@ export const api = {
|
||||
auth.fetch(`${BASE}/api/hitl/${cardId}/resolve`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(resp) })
|
||||
.then(r => json<{ resolved: boolean; pivoted: boolean; pivotText: string }>(r)),
|
||||
|
||||
taskSource: (cardId:string,sourceId:string) => auth.fetch(`${BASE}/api/cards/${encodeURIComponent(cardId)}/sources/${encodeURIComponent(sourceId)}`).then(r=>json<{source:import('./contracts').TaskSourceRef;versionState:string;artifact?:Artifact}>(r)),
|
||||
listCardsSnapshot: (conversationId: string): Promise<{cards: ActivityCard[]; replaySeq?: number}> => auth.fetch(`${BASE}/api/conversations/${conversationId}/cards`).then(r => json<{cards: ActivityCard[]; replaySeq?: number}>(r)),
|
||||
listCards: (conversationId: string) =>
|
||||
auth.fetch(`${BASE}/api/conversations/${conversationId}/cards`).then(r => json<{ cards: ActivityCard[] }>(r)).then(d => d.cards ?? []),
|
||||
cardAction: (cardId: string, actionId: string, payload?: string | WorkCardAction) =>
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
api, type AccountProfile, type ManagedAccountRecord, type PermissionDescriptor, type RoleRecord, type UserRoleView,
|
||||
api, type AccountProfile, type ManagedAccountRecord, type PermissionDescriptor, type RoleRecord, type UserRoleAssignmentPreview, type UserRoleView,
|
||||
} from '../api'
|
||||
import { useStore } from '../store'
|
||||
import { Collapsible, Icon } from '@ui'
|
||||
import { hasSystemAction } from './RolePermissionsPanel'
|
||||
import { accountActionPolicy, accountIdentityKind, canAppointAdmins } from '../accountAuthority'
|
||||
|
||||
type IdentityFlags = { isAdmin?: boolean; isSuperAdmin?: boolean }
|
||||
import { cellKind, hasSystemAction } from './RolePermissionsPanel'
|
||||
import {
|
||||
ROLE_PERMISSION_STATES, categoryActions, permissionCategories, stateToValue, valueToState,
|
||||
type RolePermissionStateName, type RolePermissionValue,
|
||||
} from '../rolePermissions'
|
||||
import {
|
||||
accountLabel, accountName, clearSelection, copyAssignRequest, createSelection, duplicateNames, isSelected, memberAssignments,
|
||||
pageSelectionState, previewAllowsAssign, previewTransition, refreshStateVersions, roleColumnLabel, roleSourceValues, sameNameHint,
|
||||
selectPage, selectionCounts, selectionSummary, staleUserIds, toggleOne, type MemberSelection,
|
||||
} from '../roleMembers'
|
||||
|
||||
const emptyProfile = (): AccountProfile => ({ name: '', phone: '', email: '', staffNo: '', department: '', notes: '' })
|
||||
const fields: { key: keyof AccountProfile; label: string }[] = [
|
||||
@@ -16,7 +22,7 @@ const fields: { key: keyof AccountProfile; label: string }[] = [
|
||||
]
|
||||
|
||||
export const credentialLabel = (account: Pick<ManagedAccountRecord, 'initializationState' | 'passwordSet'>) =>
|
||||
account.passwordSet ? '已设置密码' : '待设置密码'
|
||||
account.initializationState === 'pending' ? '待可信 owner 初始化' : account.passwordSet ? '已设置密码' : '未设置密码'
|
||||
|
||||
function ProfileInputs({ profile, onChange, prefix }: { profile: AccountProfile; onChange: (value: AccountProfile) => void; prefix: string }) {
|
||||
return <div className="ad-create">{fields.map(field => <div className="ad-row" key={field.key}>
|
||||
@@ -26,25 +32,6 @@ function ProfileInputs({ profile, onChange, prefix }: { profile: AccountProfile;
|
||||
</div>)}</div>
|
||||
}
|
||||
|
||||
export function IdentityChip({ account }: { account: IdentityFlags }) {
|
||||
const kind = accountIdentityKind(account)
|
||||
if (kind === 'super') return <span className="ad-chip ad-chip-super">超级管理员</span>
|
||||
if (kind === 'admin') return <span className="ad-chip ad-chip-admin">管理员</span>
|
||||
return <span className="ad-chip">普通用户</span>
|
||||
}
|
||||
|
||||
export function CreateAdminOption({ me, checked, disabled, onChange }: { me: IdentityFlags | null; checked: boolean; disabled: boolean; onChange: (value: boolean) => void }) {
|
||||
if (!canAppointAdmins(me)) return <p className="ad-hint">仅超级管理员可创建或任命管理员;此处创建普通账号</p>
|
||||
return <label className="ad-check"><input type="checkbox" checked={checked} disabled={disabled}
|
||||
onChange={event => onChange(event.target.checked)} />创建为管理员账号</label>
|
||||
}
|
||||
|
||||
export function AdminAppointmentButton({ me, account, busy, onChange }: { me: IdentityFlags | null; account: IdentityFlags; busy: boolean; onChange: (isAdmin: boolean) => void }) {
|
||||
const change = accountActionPolicy(me, account).adminChange
|
||||
if (!change) return null
|
||||
return <button className="ad-mini" disabled={busy} onClick={() => onChange(change === 'promote')}>{change === 'promote' ? '设为管理员' : '撤销管理员'}</button>
|
||||
}
|
||||
|
||||
export function AccountUsersPanel() {
|
||||
const me = useStore(state => state.me)
|
||||
const allowed = (action: string) => !!me?.isAdmin || hasSystemAction(me?.systemActions, action)
|
||||
@@ -52,9 +39,20 @@ export function AccountUsersPanel() {
|
||||
const canAssignRoles = canViewRoles && allowed('system.roles.assign')
|
||||
const canViewGrants = allowed('system.permissions.view')
|
||||
const canManageGrants = canViewGrants && allowed('system.permissions.manage')
|
||||
const canCopyRole = canAssignRoles && canManageGrants && allowed('system.roles.manage')
|
||||
const [accounts, setAccounts] = useState<ManagedAccountRecord[] | null>(null)
|
||||
const [selection, setSelection] = useState<MemberSelection>(createSelection)
|
||||
const [known, setKnown] = useState<Record<string, ManagedAccountRecord>>({})
|
||||
const [batchOpen, setBatchOpen] = useState(false)
|
||||
const [batchRoleId, setBatchRoleId] = useState('')
|
||||
const [batchPreview, setBatchPreview] = useState<UserRoleAssignmentPreview[] | null>(null)
|
||||
const [copyName, setCopyName] = useState('')
|
||||
const [copyDescription, setCopyDescription] = useState('')
|
||||
const [copyValues, setCopyValues] = useState<Record<string, RolePermissionValue>>({})
|
||||
const [selected, setSelected] = useState<ManagedAccountRecord | null>(null)
|
||||
const [roleView, setRoleView] = useState<UserRoleView | null>(null)
|
||||
const [fullSourceRole, setFullSourceRole] = useState<RoleRecord | null>(null)
|
||||
const [sourceRoleError, setSourceRoleError] = useState('')
|
||||
const [roles, setRoles] = useState<RoleRecord[]>([])
|
||||
const [roleQuery, setRoleQuery] = useState('')
|
||||
const [roleCursor, setRoleCursor] = useState('')
|
||||
@@ -67,7 +65,6 @@ export function AccountUsersPanel() {
|
||||
const [draft, setDraft] = useState<AccountProfile>(emptyProfile)
|
||||
const [newOwner, setNewOwner] = useState('')
|
||||
const [newProfile, setNewProfile] = useState<AccountProfile>(emptyProfile)
|
||||
const [newIsAdmin, setNewIsAdmin] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [mode, setMode] = useState<'exact' | 'fuzzy'>('fuzzy')
|
||||
const [field, setField] = useState('default')
|
||||
@@ -81,6 +78,13 @@ export function AccountUsersPanel() {
|
||||
const [err, setErr] = useState('')
|
||||
const sequence = useRef(0)
|
||||
const roleSequence = useRef(0)
|
||||
const batchSequence = useRef(0)
|
||||
const pageIds = (accounts ?? []).map(account => account.userId)
|
||||
const pageState = pageSelectionState(selection, pageIds)
|
||||
const counts = selectionCounts(selection, pageIds)
|
||||
const duplicates = duplicateNames(accounts ?? [])
|
||||
const batchReady = !!batchPreview && previewAllowsAssign(batchPreview, selection.ids)
|
||||
const batchStale = batchPreview ? staleUserIds(batchPreview) : []
|
||||
|
||||
const search = async (pageCursor = cursor, afterWrite = false) => {
|
||||
const request = ++sequence.current
|
||||
@@ -89,6 +93,7 @@ export function AccountUsersPanel() {
|
||||
fields: field === 'default' ? ['owner', 'name', 'userId'] : [field], cursor: pageCursor, limit: 20 })
|
||||
if (request !== sequence.current) return
|
||||
setAccounts(page.accounts ?? []); setNextCursor(page.nextCursor ?? '')
|
||||
setKnown(current => ({ ...current, ...Object.fromEntries((page.accounts ?? []).map(account => [account.userId, account])) }))
|
||||
} catch (error) {
|
||||
if (request === sequence.current) setErr(afterWrite ? `操作已完成,但用户列表暂未刷新:${String(error)}` : String(error))
|
||||
}
|
||||
@@ -105,6 +110,7 @@ export function AccountUsersPanel() {
|
||||
roleSequence.current++
|
||||
setRoleQuery(''); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(false)
|
||||
setSelected(null); setRoleView(null); setErr(''); setNotice('')
|
||||
setCopyName(''); setCopyDescription(''); setCopyValues({}); setFullSourceRole(null); setSourceRoleError('')
|
||||
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 }
|
||||
@@ -119,6 +125,13 @@ export function AccountUsersPanel() {
|
||||
if (roleResult.status === 'fulfilled' && roleResult.value) {
|
||||
const currentRole = roleResult.value as UserRoleView
|
||||
setRoleView(currentRole); setSelectedRoleId(currentRole.role?.roleId ?? '')
|
||||
if (canCopyRole && currentRole.role) {
|
||||
void api.getRole(currentRole.role.roleId).then(role => {
|
||||
if (request !== sequence.current) return
|
||||
if (Array.isArray(role?.permissions)) setFullSourceRole(role)
|
||||
else setSourceRoleError('源角色权限读取不完整,暂不能复制,请重新打开用户')
|
||||
}).catch(error => { if (request === sequence.current) setSourceRoleError(`读取源角色权限失败:${String(error)}`) })
|
||||
}
|
||||
} else { setRoleView(null); setSelectedRoleId('') }
|
||||
if (catalogResult.status === 'fulfilled' && catalogResult.value) setCatalog(catalogResult.value)
|
||||
else setCatalog([])
|
||||
@@ -128,7 +141,7 @@ export function AccountUsersPanel() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected || !canViewRoles) { setRoles([]); setRoleLoading(false); return }
|
||||
if ((!selected && !batchOpen) || !canViewRoles) { setRoles([]); setRoleLoading(false); return }
|
||||
const request = ++roleSequence.current
|
||||
setRoleLoading(true); setNextRoleCursor('')
|
||||
const timer = window.setTimeout(() => {
|
||||
@@ -140,7 +153,7 @@ export function AccountUsersPanel() {
|
||||
.catch(error => { if (request === roleSequence.current) { setErr(String(error)); setRoleLoading(false) } })
|
||||
}, 180)
|
||||
return () => { window.clearTimeout(timer) }
|
||||
}, [selected?.userId, roleQuery, roleCursor, canViewRoles])
|
||||
}, [selected?.userId, batchOpen, roleQuery, roleCursor, canViewRoles])
|
||||
|
||||
// 写入成功会推进目录版本,旧页游标随之失效:从第一页重读,刷新失败不改写成功结果。
|
||||
const refreshFirst = async () => {
|
||||
@@ -152,10 +165,10 @@ export function AccountUsersPanel() {
|
||||
if (!newOwner.trim()) return
|
||||
setBusy(true); setErr(''); setNotice('')
|
||||
try {
|
||||
const account = await api.createManagedUser(newOwner.trim(), newProfile, canAppointAdmins(me) && newIsAdmin)
|
||||
setNewOwner(''); setNewProfile(emptyProfile()); setNewIsAdmin(false)
|
||||
const account = await api.createManagedUser(newOwner.trim(), newProfile)
|
||||
setNewOwner(''); setNewProfile(emptyProfile())
|
||||
await open(account)
|
||||
setNotice(`已创建 ${account.owner}${account.isAdmin ? '(管理员)' : ''};账号资料可空,角色另行分配`)
|
||||
setNotice(`已创建 ${account.owner};账号资料可空,角色另行分配`)
|
||||
await refreshFirst()
|
||||
} catch (error) { setErr(String(error).includes('409') ? '登录名已存在' : String(error)) }
|
||||
finally { setBusy(false) }
|
||||
@@ -205,58 +218,194 @@ export function AccountUsersPanel() {
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const setAdmin = async (isAdmin: boolean) => {
|
||||
if (!selected) return
|
||||
setBusy(true); setErr(''); setNotice('')
|
||||
// 选择集合一旦变化,旧预览不再对应提交集合,必须重新预览。
|
||||
const changeSelection = (next: MemberSelection) => {
|
||||
batchSequence.current++
|
||||
setSelection(next); setBatchPreview(null)
|
||||
}
|
||||
|
||||
const openBatch = () => {
|
||||
roleSequence.current++
|
||||
setRoleQuery(''); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor('')
|
||||
setBatchRoleId(''); setBatchPreview(null); setBatchOpen(true); setErr(''); setNotice('')
|
||||
}
|
||||
|
||||
const closeBatch = () => {
|
||||
batchSequence.current++
|
||||
setBatchOpen(false); setBatchRoleId(''); setBatchPreview(null)
|
||||
}
|
||||
|
||||
const previewBatch = async (roleId: string, versions = known) => {
|
||||
const request = ++batchSequence.current
|
||||
setBatchRoleId(roleId); setBatchPreview(null); setErr(''); setNotice('')
|
||||
if (!roleId) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const updated = await api.adminSetUserAdmin(selected.userId, selected.stateVersion, isAdmin)
|
||||
setSelected(updated); await refreshFirst(); setNotice(isAdmin ? '已任命为管理员' : '已撤销管理员')
|
||||
} catch (error) { setErr(String(error).includes('409') ? '用户状态已变化,请重新打开用户' : String(error)) }
|
||||
const assignments = memberAssignments(selection.ids, roleId,
|
||||
Object.fromEntries(Object.values(versions).map(account => [account.userId, account.stateVersion])))
|
||||
const previews = await api.previewUserRoleAssignments(assignments)
|
||||
if (request === batchSequence.current) setBatchPreview(previews)
|
||||
} catch (error) { if (request === batchSequence.current) setErr(error instanceof Error ? error.message : String(error)) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const policy = selected ? accountActionPolicy(me, selected) : null
|
||||
const refreshBatchVersions = async () => {
|
||||
if (!batchPreview) return
|
||||
setBusy(true); setErr('')
|
||||
try {
|
||||
const fresh = { ...known, ...await refreshStateVersions(api, staleUserIds(batchPreview)) }
|
||||
setKnown(fresh)
|
||||
await previewBatch(batchRoleId, fresh)
|
||||
} catch (error) { setErr(String(error)); setBusy(false) }
|
||||
}
|
||||
|
||||
const confirmBatch = async () => {
|
||||
if (!batchPreview || !previewAllowsAssign(batchPreview, selection.ids)) return
|
||||
const roleName = batchPreview[0]?.targetRoleName ?? ''
|
||||
setBusy(true); setErr(''); setNotice('')
|
||||
try {
|
||||
const assignments = memberAssignments(selection.ids, batchRoleId,
|
||||
Object.fromEntries(Object.values(known).map(account => [account.userId, account.stateVersion])))
|
||||
const result = await api.assignUserRolesBatch(assignments, crypto.randomUUID())
|
||||
changeSelection(clearSelection()); closeBatch()
|
||||
await refreshFirst()
|
||||
setNotice(`已为 ${result.bindings?.length ?? assignments.length} 人分配角色「${roleName}」`)
|
||||
} catch (error) {
|
||||
setBatchPreview(null)
|
||||
setErr(String(error).includes('409') ? '部分用户状态或目标角色已变化,整批均未写入;选择已保留,请重新预览后再确认' : `整批均未写入:${String(error)}`)
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const copyAndAssign = async () => {
|
||||
const source = fullSourceRole
|
||||
if (!selected || !source) return
|
||||
setErr(''); setNotice('')
|
||||
let request
|
||||
try {
|
||||
request = copyAssignRequest({ idempotencyKey: crypto.randomUUID(), source, name: copyName, description: copyDescription, catalog,
|
||||
values: { ...roleSourceValues(source, catalog), ...copyValues }, users: [{ userId: selected.userId, stateVersion: selected.stateVersion }] })
|
||||
} catch (error) { setErr(error instanceof Error ? error.message : String(error)); return }
|
||||
setBusy(true)
|
||||
try {
|
||||
const result = await api.createRoleCopyAndAssign(request)
|
||||
await open(selected); await refreshFirst()
|
||||
setNotice(`已创建新角色「${result.role.name}」并分配给 ${accountLabel(selected)};新角色独立维护,不再随原角色「${source.name}」更新`)
|
||||
} catch (error) {
|
||||
setErr(String(error).includes('409') ? '新角色名称已存在,或源角色/用户状态已变化;草稿已保留,请重新打开用户核对后重试' : String(error))
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const roleSearch = () => <div className="ad-row">
|
||||
<input className="lg-input" aria-label="搜索可分配角色" placeholder="搜索有效角色" value={roleQuery}
|
||||
onChange={event => { setRoleQuery(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }} />
|
||||
<button className="ad-mini" disabled={roleLoading || !roleTrail.length} onClick={() => { setRoleCursor(roleTrail[roleTrail.length - 1]); setRoleTrail(roleTrail.slice(0, -1)) }}>上一页角色</button>
|
||||
<button className="ad-mini" disabled={roleLoading || !nextRoleCursor} onClick={() => { setRoleTrail([...roleTrail, roleCursor]); setRoleCursor(nextRoleCursor) }}>下一页角色</button>
|
||||
</div>
|
||||
|
||||
const copyForm = (source: RoleRecord) => {
|
||||
const baseline = roleSourceValues(source, catalog)
|
||||
const valueOf = (actionId: string) => copyValues[actionId] ?? baseline[actionId]
|
||||
const adjusted = Object.keys(baseline).filter(actionId => {
|
||||
const value = valueOf(actionId)
|
||||
return value.allowed !== baseline[actionId].allowed || value.capAllowed !== baseline[actionId].capAllowed
|
||||
}).length
|
||||
const setValue = (actionId: string, value: RolePermissionValue) => setCopyValues(current => ({ ...current, [actionId]: value }))
|
||||
return <div className="au-copy">
|
||||
<p className="ad-hint">以「{source.name}」的当前权限为起点创建新角色,并把 {accountLabel(selected!)} 换绑到新角色。新角色独立维护,不再随原角色更新。</p>
|
||||
<div className="ad-row">
|
||||
<input className="lg-input" aria-label="新角色名称(必填)" placeholder="新角色名称(必填)" value={copyName} disabled={busy} onChange={event => setCopyName(event.target.value)} />
|
||||
<input className="lg-input" aria-label="新角色描述" placeholder="描述(可空)" value={copyDescription} disabled={busy} onChange={event => setCopyDescription(event.target.value)} />
|
||||
</div>
|
||||
{permissionCategories(catalog).map(category => <fieldset className="au-copy-group" key={category}>
|
||||
<legend>{category}</legend>
|
||||
{categoryActions(catalog, category).map(item => {
|
||||
const value = valueOf(item.actionId)
|
||||
const changed = value.allowed !== baseline[item.actionId].allowed || value.capAllowed !== baseline[item.actionId].capAllowed
|
||||
const label = `新角色 ${item.label}`
|
||||
return <div className="au-copy-row" key={item.actionId}>
|
||||
<span title={item.description}>{item.label}</span>
|
||||
{cellKind(item) === 'toggle'
|
||||
? <select className="rp-state" aria-label={label} value={value.allowed ? 'on' : 'off'} disabled={busy}
|
||||
onChange={event => setValue(item.actionId, stateToValue(event.target.value === 'on' ? 'default' : 'denied'))}>
|
||||
<option value="off">不允许</option><option value="on">允许</option>
|
||||
</select>
|
||||
: <select className="rp-state" aria-label={label} value={valueToState(value)} disabled={busy}
|
||||
onChange={event => setValue(item.actionId, stateToValue(event.target.value as RolePermissionStateName))}>
|
||||
{ROLE_PERMISSION_STATES.map(option => <option key={option.state} value={option.state}>{option.label}</option>)}
|
||||
</select>}
|
||||
{changed && <span className="rp-cell-flag">已调整</span>}
|
||||
</div>
|
||||
})}
|
||||
</fieldset>)}
|
||||
<div className="ad-row">
|
||||
<span className="ad-hint au-grow">相对「{source.name}」调整 {adjusted} 项</span>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !copyName.trim()} onClick={() => void copyAndAssign()}>确认复制并分配</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
const batchPanel = () => <section className="rp-preview au-batch" aria-label={`为选中的 ${counts.count} 人分配角色`}>
|
||||
<div className="rp-preview-head">
|
||||
<strong>为选中的 {counts.count} 人分配角色</strong>
|
||||
<button className="ad-mini" disabled={busy} onClick={closeBatch}>收起</button>
|
||||
</div>
|
||||
{roleSearch()}
|
||||
<select className="lg-input" aria-label="批量分配的目标角色" value={batchRoleId} disabled={busy || roleLoading} onChange={event => void previewBatch(event.target.value)}>
|
||||
<option value="">请选择目标角色</option>
|
||||
{batchRoleId && !roles.some(role => role.roleId === batchRoleId) && <option value={batchRoleId}>{batchPreview?.[0]?.targetRoleName ?? '已选角色'}</option>}
|
||||
{roles.map(role => <option key={role.roleId} value={role.roleId}>{role.name}</option>)}
|
||||
</select>
|
||||
{batchPreview && <ul className="au-preview-list">{batchPreview.map(item => <li key={item.userId}>
|
||||
<span>{accountLabel(item)}</span><span className="ad-dim">{previewTransition(item)}</span>
|
||||
{item.error && <span className="rp-conflict"><Icon name="alert" size={12} />{item.error}</span>}
|
||||
{!item.error && !item.stateMatches && <span className="rp-conflict"><Icon name="alert" size={12} />用户状态已变化</span>}
|
||||
</li>)}</ul>}
|
||||
{batchPreview && !batchReady && <p className="rp-conflict" role="alert"><Icon name="alert" size={12} />存在停用、版本变化或角色无效的用户,处理后才能确认分配</p>}
|
||||
<div className="ad-row">
|
||||
{batchStale.length > 0 && <button className="ad-mini" disabled={busy} onClick={() => void refreshBatchVersions()}>刷新 {batchStale.length} 人的状态并重新预览</button>}
|
||||
{batchRoleId && !batchPreview && <button className="ad-mini" disabled={busy} onClick={() => void previewBatch(batchRoleId)}>重新预览</button>}
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !batchReady} onClick={() => void confirmBatch()}>确认分配</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
if (!allowed('system.users.view')) return <p className="ad-hint">当前账号没有查看用户的系统权限</p>
|
||||
return <div>
|
||||
{allowed('system.users.create') && <div className="ad-create">
|
||||
<div className="ad-row"><input className="lg-input" aria-label="新用户登录名" placeholder="登录名" value={newOwner} onChange={event => setNewOwner(event.target.value)} />
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !newOwner.trim()} onClick={create}>创建用户</button></div>
|
||||
<CreateAdminOption me={me} checked={newIsAdmin} disabled={busy} onChange={setNewIsAdmin} />
|
||||
<Collapsible id="new-user-profile" trigger="可选资料:姓名、电话、邮箱、工号、部门、备注">
|
||||
<ProfileInputs profile={newProfile} onChange={setNewProfile} prefix="new-user" />
|
||||
</Collapsible>
|
||||
</div>}
|
||||
{notice && <div className="ad-notice" role="status"><Icon name="check" size={14} />{notice}</div>}
|
||||
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
|
||||
{selected && policy ? <div className="rp-editor">
|
||||
{selected ? <div className="rp-editor">
|
||||
<div className="ad-row"><button className="ad-mini" onClick={() => { sequence.current++; setSelected(null) }}>返回列表</button>
|
||||
<b>{selected.owner}</b><IdentityChip account={selected} /><span className="ad-dim ad-mono">{selected.userId}</span></div>
|
||||
<b>{selected.owner}</b><span className="ad-dim ad-mono">{selected.userId}</span></div>
|
||||
<p className="ad-hint">{selected.enabled ? '有效账号' : '停用账号'} · {credentialLabel(selected)} · 资料版本 {selected.profileVersion} · 当前角色 {canViewRoles ? (roleView?.role?.name ?? '未分配') : '无角色查看权限'}</p>
|
||||
{policy.protectedReason && <p className="ad-hint" role="note">{policy.protectedReason}</p>}
|
||||
{allowed('system.users.edit') && policy.maintain && <Collapsible id={`user-profile-${selected.userId}`} trigger="编辑可选资料">
|
||||
{selected.initializationState === 'pending' && <p className="ad-hint">交给可信 owner 的初始化定位:userId <span className="ad-mono">{selected.userId}</span> · 状态版本 {selected.stateVersion} · 代数 {selected.initializationGeneration}。凭据只经保护文件交付。</p>}
|
||||
{allowed('system.users.edit') && <Collapsible id={`user-profile-${selected.userId}`} trigger="编辑可选资料">
|
||||
<ProfileInputs profile={draft} onChange={setDraft} prefix="user-profile" />
|
||||
<button className="lg-submit ad-create-btn" disabled={busy} onClick={saveProfile}>保存资料</button>
|
||||
</Collapsible>}
|
||||
<div className="ad-row">
|
||||
{canAssignRoles && policy.maintain && <><select className="lg-input" aria-label="用户生效角色" value={selectedRoleId} onChange={event => setSelectedRoleId(event.target.value)}>
|
||||
{canAssignRoles && <><select className="lg-input" aria-label="用户生效角色" value={selectedRoleId} onChange={event => setSelectedRoleId(event.target.value)}>
|
||||
<option value="">请选择角色</option>{roles.map(role => <option key={role.roleId} value={role.roleId}>{role.name}</option>)}
|
||||
</select><button className="ad-mini" disabled={busy || !selectedRoleId || selectedRoleId === roleView?.role?.roleId} onClick={assignRole}>替换角色</button></>}
|
||||
{allowed('system.users.disable') && policy.toggleEnabled && <button className="ad-mini" disabled={busy} onClick={toggleState}>{selected.enabled ? '停用账号' : '启用账号'}</button>}
|
||||
<AdminAppointmentButton me={me} account={selected} busy={busy} onChange={setAdmin} />
|
||||
{allowed('system.users.disable') && <button className="ad-mini" disabled={busy} onClick={toggleState}>{selected.enabled ? '停用账号' : '启用账号'}</button>}
|
||||
</div>
|
||||
{canAssignRoles && policy.maintain && <div className="ad-row">
|
||||
<input className="lg-input" aria-label="搜索可分配角色" placeholder="搜索有效角色" value={roleQuery}
|
||||
onChange={event => { setRoleQuery(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }} />
|
||||
<button className="ad-mini" disabled={roleLoading || !roleTrail.length} onClick={() => { setRoleCursor(roleTrail[roleTrail.length - 1]); setRoleTrail(roleTrail.slice(0, -1)) }}>上一页角色</button>
|
||||
<button className="ad-mini" disabled={roleLoading || !nextRoleCursor} onClick={() => { setRoleTrail([...roleTrail, roleCursor]); setRoleCursor(nextRoleCursor) }}>下一页角色</button>
|
||||
</div>}
|
||||
{canAssignRoles && roleSearch()}
|
||||
{canCopyRole && roleView?.role && catalog.length > 0 && <Collapsible id={`copy-role-${selected.userId}`} trigger="复制角色并分配…">
|
||||
{fullSourceRole ? copyForm(fullSourceRole)
|
||||
: sourceRoleError ? <p className="lg-err" role="alert"><Icon name="alert" size={14} />{sourceRoleError}</p>
|
||||
: <p className="ad-hint">正在读取源角色权限…</p>}
|
||||
</Collapsible>}
|
||||
{canViewGrants && <Collapsible id={`system-grants-${selected.userId}`} trigger="明确系统权限">
|
||||
<div className="rp-grants">{catalog.filter(item => item.scope === 'system').map(item => <label className="ad-check" key={item.actionId}>
|
||||
<input type="checkbox" checked={grants.includes(item.actionId)} disabled={!canManageGrants || !policy.maintain || busy} onChange={event => setGrants(current => event.target.checked
|
||||
<input type="checkbox" checked={grants.includes(item.actionId)} disabled={!canManageGrants || busy} onChange={event => setGrants(current => event.target.checked
|
||||
? [...new Set([...current, item.actionId])] : current.filter(value => value !== item.actionId))} />{item.label}<span className="ad-dim ad-mono">{item.actionId}</span>
|
||||
</label>)}</div>
|
||||
{canManageGrants && policy.maintain && <button className="lg-submit ad-create-btn" disabled={busy} onClick={saveGrants}>保存系统权限</button>}
|
||||
{canManageGrants && <button className="lg-submit ad-create-btn" disabled={busy} onClick={saveGrants}>保存系统权限</button>}
|
||||
</Collapsible>}
|
||||
{!canViewGrants && allowed('system.permissions.manage') && <p className="ad-hint">需要同时具备系统权限查看权,才能安全编辑现有授权</p>}
|
||||
</div> : <>
|
||||
@@ -269,14 +418,33 @@ export function AccountUsersPanel() {
|
||||
</select>
|
||||
<select className="lg-input" aria-label="用户状态" value={status} onChange={event => { setStatus(event.target.value as typeof status); resetPaging() }}><option value="all">全部状态</option><option value="enabled">有效</option><option value="disabled">停用</option></select>
|
||||
</div>
|
||||
<table className="ad-table"><thead><tr><th>登录名</th><th>姓名</th><th>工号</th><th>身份</th><th>状态</th><th>首次凭据</th><th></th></tr></thead>
|
||||
<tbody>{(accounts ?? []).map(account => <tr key={account.userId}><td className="ad-mono">{account.owner}</td><td>{account.profile.name || '—'}</td>
|
||||
<td>{account.profile.staffNo || '—'}</td><td><IdentityChip account={account} /></td><td>{account.enabled ? '有效' : '停用'}</td>
|
||||
{sameNameHint(accounts ?? []) && <p className="ad-hint">{sameNameHint(accounts ?? [])}</p>}
|
||||
<table className="ad-table"><thead><tr>
|
||||
<th className="au-select-col"><input type="checkbox" aria-label="全选本页用户" checked={pageState === 'all'} disabled={busy || !pageIds.length}
|
||||
ref={element => { if (element) element.indeterminate = pageState === 'some' }}
|
||||
onChange={event => changeSelection(selectPage(selection, pageIds, event.target.checked))} /></th>
|
||||
<th>姓名与账号</th><th>工号</th><th>当前角色</th><th>系统身份</th><th>状态</th><th>首次凭据</th><th></th></tr></thead>
|
||||
<tbody>{(accounts ?? []).map(account => <tr key={account.userId}>
|
||||
<td className="au-select-col"><input type="checkbox" aria-label={`选择 ${accountLabel(account)}`} checked={isSelected(selection, account.userId)} disabled={busy}
|
||||
onChange={event => changeSelection(toggleOne(selection, account.userId, event.target.checked))} /></td>
|
||||
<td><div className="au-name-cell"><span>{accountName(account)}</span>
|
||||
{account.profile.name.trim() && <span className="ad-dim ad-mono">{account.owner}</span>}
|
||||
{duplicates.has(account.profile.name.trim()) && <span className="au-same-name">同名 · 按账号辨识</span>}</div></td>
|
||||
<td>{account.profile.staffNo || '—'}</td><td>{roleColumnLabel(canViewRoles ? account.role : undefined)}</td>
|
||||
<td>{account.isAdmin ? '系统管理员' : '普通账号'}</td><td>{account.enabled ? '有效' : '停用'}</td>
|
||||
<td>{credentialLabel(account)}</td><td><button className="ad-mini" onClick={() => open(account)}>查看与管理</button></td></tr>)}
|
||||
{accounts !== null && accounts.length === 0 && <tr><td colSpan={7} className="ad-dim">没有匹配用户</td></tr>}
|
||||
{accounts === null && <tr><td colSpan={7} className="ad-dim">加载中…</td></tr>}</tbody></table>
|
||||
{accounts !== null && accounts.length === 0 && <tr><td colSpan={8} className="ad-dim">没有匹配用户</td></tr>}
|
||||
{accounts === null && <tr><td colSpan={8} className="ad-dim">加载中…</td></tr>}</tbody></table>
|
||||
<div className="ad-row"><button className="ad-mini" disabled={!trail.length} onClick={() => { setCursor(trail[trail.length - 1]); setTrail(trail.slice(0, -1)) }}>上一页</button>
|
||||
<button className="ad-mini" disabled={!nextCursor} onClick={() => { setTrail([...trail, cursor]); setCursor(nextCursor) }}>下一页</button></div>
|
||||
<Collapsible open={batchOpen && canAssignRoles && counts.count > 0} onToggle={value => { if (!value) closeBatch() }}>
|
||||
{batchPanel()}
|
||||
</Collapsible>
|
||||
{counts.count > 0 && <div className="rp-savebar" role="region" aria-label="已选用户">
|
||||
<span className="rp-savebar-summary" aria-live="polite">{selectionSummary(counts)}</span>
|
||||
<button className="ad-mini" disabled={busy} onClick={() => { changeSelection(clearSelection()); closeBatch() }}>清空选择</button>
|
||||
{canAssignRoles && <button className="ad-mini" disabled={busy} aria-expanded={batchOpen} onClick={batchOpen ? closeBatch : openBatch}>为选中的 {counts.count} 人分配角色</button>}
|
||||
</div>}
|
||||
</>}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { store, useStore } from '../store'
|
||||
import { Icon } from '@ui'
|
||||
import { humanChangePasswordError } from './loginErrors'
|
||||
import { humanChangePasswordError } from './LoginPage'
|
||||
|
||||
function Shell({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
|
||||
@@ -30,15 +30,15 @@ function ProfilesPanelWrapper() {
|
||||
return <ProfilesSection profiles={profiles} />
|
||||
}
|
||||
|
||||
/** 账号目录(按系统权限开放)+ 登录凭据管理(根管理员专属)。 */
|
||||
/** 账号目录(按系统权限开放)+ 登录凭据与恢复提案(根管理员专属,旧 /api/admin/users 列表与密码端点)。 */
|
||||
function UsersSection() {
|
||||
const isAdmin = useStore(s => !!s.me?.isAdmin)
|
||||
return <>
|
||||
<AccountUsersPanel />
|
||||
{isAdmin && <>
|
||||
<div className="az-section-head" style={{ marginTop: 18 }}>
|
||||
<h3>登录凭据</h3>
|
||||
<p>建号并发放初始密码、生成一次性登录密码(仅根管理员)</p>
|
||||
<h3>登录凭据与恢复</h3>
|
||||
<p>建号并发放初始密码、重置临时密码、发起恢复提案(仅根管理员)</p>
|
||||
</div>
|
||||
<UsersPanel />
|
||||
</>}
|
||||
@@ -46,7 +46,7 @@ function UsersSection() {
|
||||
}
|
||||
|
||||
const SECTIONS: AdminSectionDef[] = [
|
||||
{ key: 'users', label: '用户管理', icon: 'users', desc: '可选资料、账号状态、唯一生效角色与明确系统权限;根管理员可发放登录凭据', requiredAction: 'system.users.view', render: () => <UsersSection /> },
|
||||
{ key: 'users', label: '用户管理', icon: 'users', desc: '可选资料、账号状态、唯一生效角色与明确系统权限;根管理员另可发放与恢复登录凭据', requiredAction: 'system.users.view', render: () => <UsersSection /> },
|
||||
{ key: 'roles', label: '角色与权限', icon: 'shield', desc: '职位角色、默认权限与上限;支持单角色和角色矩阵配置', requiredAction: 'system.roles.view', render: () => <RolePermissionsPanel /> },
|
||||
{ key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', rootOnly: true, render: () => <ProfilesPanelWrapper /> },
|
||||
// P2 受管:模型/密钥/Tavily/遥测/运行基础设施的唯一写入面(Backend PR 188 系统设置 API)。
|
||||
@@ -110,7 +110,7 @@ export function AdminZone() {
|
||||
<Icon name="chevron-down" size={14} /> 返回工作台
|
||||
</button>
|
||||
<span className="az-title">系统管理</span>
|
||||
<span className="az-sub">{me?.isSuperAdmin ? '超级管理员' : isAdmin ? '根管理员' : '按系统权限开放'} · 角色与权限由服务端判定</span>
|
||||
<span className="az-sub">{isAdmin ? '根管理员' : '按系统权限开放'} · 角色与权限由服务端判定</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span className="az-tenant">{me?.owner ?? ''}</span>
|
||||
</div>
|
||||
|
||||
@@ -29,9 +29,7 @@ export function ConversationWork() {
|
||||
if (card.kind === 'confirm' && card.anchorMessageId
|
||||
&& messages.some(message => message.role === 'user' && message.id === card.anchorMessageId)) continue
|
||||
const summary = activitySummary(card)
|
||||
const taskNotice = card.task?.limitations?.length || card.task?.phase.id === 'correction'
|
||||
|| card.task?.findings?.some(f => f.status === 'active' && (f.kind === 'needs_user' || f.corrects))
|
||||
if (taskNotice || card.status === 'action_required' || card.work?.decision?.state === 'open' || summary.failed || (card.status === 'done' && summary.remaining)) required.push(card)
|
||||
if (card.status === 'action_required' || card.work?.decision?.state === 'open' || summary.failed || (card.status === 'done' && summary.remaining)) required.push(card)
|
||||
else remaining.push(card)
|
||||
}
|
||||
// Preserve the host's newest-first order for completed results and history.
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
// 登录与改密(管理员发放制,R2 + 2026-10-13 登录恢复合同):
|
||||
// LoginPage —— 用户名+密码唯一入口;
|
||||
// ChangePasswordGate —— 受限会话强制设密:模式由服务端 passwordChangeMode 派生,
|
||||
// grant_setup 与 one_time_password 免当前密码,current_required 必须校验当前密码
|
||||
// grant_setup 免当前密码(激活/恢复 Key 兑换),current_required 必须校验当前密码
|
||||
//(初始密码首登;2026-10-14 后端加固,空 current 会被 401 拒绝)。
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api, type PasswordChangeMode } from '../api'
|
||||
import { Icon, BrandMark } from '@ui'
|
||||
import { ForcedPasswordModeSection, isForcedPasswordMode, requiresCurrentPassword, submitForcedPassword } from './passwordChangeMode'
|
||||
import { humanChangePasswordError, humanLoginError } from './loginErrors'
|
||||
|
||||
function Err({ msg }: { msg: string }) {
|
||||
if (!msg) return null
|
||||
@@ -76,6 +74,12 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
|
||||
)
|
||||
}
|
||||
|
||||
/** current_required 模式必须由用户输入当前密码(后端 CompleteForcedPasswordChecked
|
||||
* 校验);grant_setup(激活/恢复 Key 兑换的受限会话)免当前密码。 */
|
||||
export function requiresCurrentPassword(mode: PasswordChangeMode | null | undefined): boolean {
|
||||
return mode === 'current_required'
|
||||
}
|
||||
|
||||
/** 强制设密门:受限会话必须完成设密。模式不由前端猜测——进门前先经 /api/me
|
||||
* 读取服务端派生的 passwordChangeMode;读取失败或会话不再受限时拒绝放行。 */
|
||||
export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string; onDone: () => void; onCancel?: () => void }) {
|
||||
@@ -92,8 +96,8 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
|
||||
api.me().then(me => {
|
||||
if (!active) return
|
||||
if (me.owner !== owner || me.sessionScope !== 'set_password_only' ||
|
||||
!isForcedPasswordMode(me.passwordChangeMode)) {
|
||||
setErr('当前设密凭据不再有效,请重新登录。')
|
||||
(me.passwordChangeMode !== 'grant_setup' && me.passwordChangeMode !== 'current_required')) {
|
||||
setErr('当前设密凭据不再有效,请重新登录;恢复场景请先核对「设置 → 账号」中的恢复提案状态。')
|
||||
return
|
||||
}
|
||||
setMode(me.passwordChangeMode)
|
||||
@@ -109,7 +113,7 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
|
||||
if (next.length < 8) { setErr('新密码至少 8 位'); return }
|
||||
setBusy(true)
|
||||
try {
|
||||
await submitForcedPassword(mode, current, next)
|
||||
await api.changePassword(requiresCurrentPassword(mode) ? current : '', next)
|
||||
setDone(true)
|
||||
} catch (e) {
|
||||
setErr(humanChangePasswordError(String(e)))
|
||||
@@ -132,7 +136,12 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
|
||||
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
|
||||
<h2 className="lg-title">设置新密码</h2>
|
||||
<p className="lg-sub">账号 <b>{owner}</b>(服务端确认)须设置新密码后继续</p>
|
||||
<ForcedPasswordModeSection mode={mode} current={current} onCurrentChange={setCurrent} />
|
||||
{mode === 'current_required' && <>
|
||||
<label className="lg-label" htmlFor="forced-current">当前密码</label>
|
||||
<input id="forced-current" className="lg-input" type="password" value={current}
|
||||
onChange={e => setCurrent(e.target.value)} autoComplete="current-password" />
|
||||
</>}
|
||||
{mode === 'grant_setup' && <p className="lg-hint">身份经可信激活/恢复能力确认,此次无需输入当前密码。</p>}
|
||||
<label className="lg-label">新密码(≥8 位)</label>
|
||||
<input className="lg-input" type="password" value={next} onChange={e => setNext(e.target.value)} autoFocus autoComplete="new-password" />
|
||||
<label className="lg-label">确认新密码</label>
|
||||
@@ -146,3 +155,34 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 登录错误文案:把 HTTP 状态码翻成人话。 */
|
||||
export function humanLoginError(raw: string): string {
|
||||
if (raw.includes('401')) return '用户名或密码不正确'
|
||||
if (raw.includes('429')) return '失败次数过多,请稍后再试'
|
||||
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
|
||||
return raw.replace(/^\d{3}:\s*/, '')
|
||||
}
|
||||
|
||||
/** 一次性登录 Key 兑换文案:401/409/429 语义不同,逐条映射防漂移。 */
|
||||
export function humanRedeemError(raw: string): string {
|
||||
if (raw.includes('409')) return '该能力已被使用过;请由本人核对提案状态,或联系可信 owner 续办初始化'
|
||||
if (raw.includes('429')) return '尝试次数过多,请稍后再试'
|
||||
if (raw.includes('401')) return '能力无效或已过期;请核对本人提案状态或联系可信 owner 续办初始化'
|
||||
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
|
||||
return raw.replace(/^\d{3}:\s*/, '')
|
||||
}
|
||||
|
||||
/** 改密门错误文案:改密的 401 有多种真实原因(会话过期/并发失效/退避),
|
||||
* 不能沿用登录的「用户名或密码不正确」一刀切。
|
||||
* 后端文案是现行 HTTP 契约的一部分,这里按其区分并用测试锁定。 */
|
||||
export function humanChangePasswordError(raw: string): string {
|
||||
if (raw.includes('current password incorrect')) return '当前密码不正确'
|
||||
if (raw.includes('session login required')) return '请先用账号密码登录再改密(API 密钥不能修改密码)'
|
||||
if (raw.includes('session expired')) return '会话已过期,请重新登录'
|
||||
if (raw.includes('credentials changed concurrently')) return '登录状态已变化(密码在其他地方被更新),请重新登录'
|
||||
if (raw.includes('invalid or revoked API key')) return '本地残留的 API 密钥已失效,请退出登录后重试'
|
||||
if (raw.includes('429')) return '失败次数过多,请稍后再试'
|
||||
if (raw.includes('401')) return '认证失败,请重新登录'
|
||||
return raw.replace(/^\d{3}:\s*/, '')
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
// 我的账号(设置 → 账号):可选资料编辑 + 忘记密码指引。忘记密码只有一个处置:
|
||||
// 由管理员生成一次性登录密码,用户用它登录后进入强制改密门,设置新密码才放行。
|
||||
// 我的账号与恢复(设置 → 账号):可选资料编辑 + 恢复提案的属主确认视图。
|
||||
// 管理员只能建立提案;确认必须使用提案之前已存在、来源可信的完整密码会话
|
||||
//(backend eligibleRecoveryProof 契约)。确认产出的一次性 grant 只在本次响应
|
||||
// 出现,可就地兑换进受限设密会话(完成前不放行工作台)。
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api, type AccountProfile, type AccountRecord } from '../api'
|
||||
import { api, type AccountProfile, type AccountRecord, type RecoveryProposal } from '../api'
|
||||
import { useStore } from '../store'
|
||||
import { Collapsible, Icon, SettingsGroup } from '@ui'
|
||||
import { recoveryStatusLabel } from '../recovery'
|
||||
|
||||
const fields: { key: keyof AccountProfile; label: string }[] = [
|
||||
{ key: 'name', label: '姓名' }, { key: 'phone', label: '电话' }, { key: 'email', label: '邮箱' },
|
||||
@@ -14,6 +17,8 @@ export function MyAccountSettings() {
|
||||
const me = useStore(s => s.me)
|
||||
const [account, setAccount] = useState<AccountRecord | null>(null)
|
||||
const [draft, setDraft] = useState<AccountProfile | null>(null)
|
||||
const [proposals, setProposals] = useState<RecoveryProposal[] | null>(null)
|
||||
const [grant, setGrant] = useState<{ proposalId: string; value: string } | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
@@ -22,9 +27,9 @@ export function MyAccountSettings() {
|
||||
useEffect(() => {
|
||||
if (!me?.auth) return
|
||||
const seq = ++sequence.current
|
||||
api.meProfile().then(profile => {
|
||||
Promise.all([api.meProfile(), api.listMyRecoveryProposals()]).then(([profile, statuses]) => {
|
||||
if (seq !== sequence.current) return
|
||||
setAccount(profile); setDraft(profile.profile)
|
||||
setAccount(profile); setDraft(profile.profile); setProposals(statuses)
|
||||
}).catch(error => { if (seq === sequence.current) setErr(String(error)) })
|
||||
return () => { sequence.current++ }
|
||||
}, [me?.owner, me?.auth])
|
||||
@@ -39,14 +44,74 @@ export function MyAccountSettings() {
|
||||
setBusy(true); setErr(''); setNotice('')
|
||||
try {
|
||||
const updated = await api.patchMeProfile(account.profileVersion, patch)
|
||||
setAccount(updated); setDraft(updated.profile); setNotice('个人资料已保存')
|
||||
setAccount(updated); setDraft(updated.profile); setNotice('个人资料已保存;资料不会成为恢复身份的证明')
|
||||
} catch (error) { setErr(String(error).includes('409') ? '资料版本已变化,请关闭设置后重新打开' : String(error)) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return <SettingsGroup title="我的账号">
|
||||
const refreshProposal = async (id: string) => {
|
||||
const updated = await api.getMyRecoveryProposal(id)
|
||||
setProposals(current => current?.map(item => item.id === id ? updated : item) ?? [updated])
|
||||
return updated
|
||||
}
|
||||
|
||||
const confirm = async (proposal: RecoveryProposal) => {
|
||||
setBusy(true); setErr(''); setNotice(''); setGrant(null)
|
||||
try {
|
||||
const result = await api.confirmMyRecoveryProposal(proposal.id, proposal.version)
|
||||
setProposals(current => current?.map(item => item.id === proposal.id ? result.proposal : item) ?? [result.proposal])
|
||||
if (!result.grant) {
|
||||
setErr('提案已确认,但一次性明文不可读回。若仍保有原本人会话,可显式重新生成设密能力。')
|
||||
return
|
||||
}
|
||||
setGrant({ proposalId: proposal.id, value: result.grant })
|
||||
setNotice('本人确认成功。设密能力只在此响应可用,请立即兑换并设置新密码。')
|
||||
} catch (error) {
|
||||
const current = await refreshProposal(proposal.id).catch(() => null)
|
||||
setErr(current?.status === 'confirmed'
|
||||
? '确认结果待核验:提案已确认,但明文不能读回。若仍保有原本人会话,可显式重新生成设密能力。'
|
||||
: `确认未完成或结果未知:${String(error)}。请核对当前提案状态。`)
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const rotate = async (proposal: RecoveryProposal) => {
|
||||
setBusy(true); setErr(''); setNotice(''); setGrant(null)
|
||||
try {
|
||||
const result = await api.rotateMyRecoveryGrant(proposal.id, proposal.version, proposal.generation)
|
||||
setProposals(current => current?.map(item => item.id === proposal.id ? result.proposal : item) ?? [result.proposal])
|
||||
if (!result.grant) {
|
||||
setErr('重新生成已提交,但一次性明文不可读回。请核对当前提案状态,再由本人显式操作。')
|
||||
return
|
||||
}
|
||||
setGrant({ proposalId: proposal.id, value: result.grant })
|
||||
setNotice('已重新生成设密能力;旧能力与派生受限会话已失效。')
|
||||
} catch (error) {
|
||||
await refreshProposal(proposal.id).catch(() => null)
|
||||
setErr(`重新生成结果待核验:${String(error)}。请核对提案状态,再由本人显式操作。`)
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const redeem = async () => {
|
||||
if (!grant) return
|
||||
setBusy(true); setErr(''); setNotice('')
|
||||
try {
|
||||
await api.redeemLoginKey(grant.value)
|
||||
location.reload() // 新 HttpOnly 受限会话由应用启动门送到设密页
|
||||
} catch (error) {
|
||||
const session = await api.me().catch(() => null)
|
||||
if (session?.sessionScope === 'set_password_only' && session.passwordChangeMode === 'grant_setup') {
|
||||
location.reload()
|
||||
return
|
||||
}
|
||||
await refreshProposal(grant.proposalId).catch(() => null)
|
||||
setGrant(null)
|
||||
setErr(`兑换结果待核验:${String(error)}。不会自动重复兑换;若仍保有原本人会话,请显式重新生成能力。`)
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return <SettingsGroup title="我的账号与恢复">
|
||||
{account && draft ? <>
|
||||
<p className="ad-hint">登录名 {account.owner} · userId {account.userId}。以下资料均可留空。</p>
|
||||
<p className="ad-hint">登录名 {account.owner} · userId {account.userId}。以下资料均可留空,也不会作为本人恢复证明。</p>
|
||||
<Collapsible id={`my-profile-${account.userId}`} trigger="编辑可选资料">
|
||||
<div className="ad-create">{fields.map(field => <div className="ad-row" key={field.key}>
|
||||
<label className="lg-label" htmlFor={`my-profile-${field.key}`}>{field.label}(可空)</label>
|
||||
@@ -56,7 +121,21 @@ export function MyAccountSettings() {
|
||||
<button className="lg-submit ad-create-btn" disabled={busy} onClick={save}>保存我的资料</button></div>
|
||||
</Collapsible>
|
||||
</> : <p className="ad-hint">使用完整密码会话后可查看和修改个人资料。</p>}
|
||||
<p className="ad-hint">忘记密码:请联系管理员生成一次性登录密码。用它登录后必须立即设置新密码(强制改密),原密码随生成即失效。</p>
|
||||
|
||||
<p className="ad-hint">忘记密码的恢复:由管理员发起提案,本人在此用当前登录会话确认后获得一次性 Key 兑换设密;历史未知来源账号无法在线证明。</p>
|
||||
{(proposals ?? []).map(proposal => <div className="ad-create" key={proposal.id}>
|
||||
<div className="ad-row"><span className="ad-mono">{proposal.id}</span><b>{recoveryStatusLabel[proposal.status]}</b>
|
||||
<span className="ad-dim">到期 {new Date(proposal.expiresAt).toLocaleString()}</span></div>
|
||||
<div className="ad-row">
|
||||
{proposal.status === 'pending' && <button className="ad-mini" disabled={busy} onClick={() => confirm(proposal)}>由本人确认</button>}
|
||||
{proposal.status === 'confirmed' && <button className="ad-mini" disabled={busy} onClick={() => rotate(proposal)}>显式重新生成设密能力</button>}
|
||||
</div>
|
||||
</div>)}
|
||||
{proposals !== null && proposals.length === 0 && <p className="ad-hint">当前没有恢复提案。</p>}
|
||||
{grant && <div className="ad-reset">
|
||||
<p className="ad-hint">设密能力仅保存在当前页面内存,离开后不可读回;不自动复制到剪贴板。</p>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy} onClick={redeem}>立即兑换并设置新密码</button>
|
||||
</div>}
|
||||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
|
||||
</SettingsGroup>
|
||||
|
||||
@@ -24,7 +24,6 @@ export function PreviewPane() {
|
||||
})
|
||||
: undefined
|
||||
const art = active ? artifacts.find(a => a.id === active.id) : undefined
|
||||
const contentUrl=art?api.artifactContentUrl(art.id)+(active?.sourceContext?.expectedHash?`?expectedHash=${encodeURIComponent(active.sourceContext.expectedHash)}`:''):''
|
||||
const textKind = !!art && isTextKind(art)
|
||||
const [content, setContent] = useState<string | null>(null)
|
||||
const [editing, setEditing] = useState(false)
|
||||
@@ -36,12 +35,12 @@ export function PreviewPane() {
|
||||
setContent(null)
|
||||
if (!art || !isTextKind(art)) return
|
||||
let cancelled = false
|
||||
fetch(contentUrl)
|
||||
.then(r => {if(!r.ok)throw new Error('source unavailable');return r.text()})
|
||||
fetch(api.artifactContentUrl(art.id))
|
||||
.then(r => r.text())
|
||||
.then(text => { if (!cancelled) setContent(text) })
|
||||
.catch(() => { if (!cancelled) setContent('(读取失败)') })
|
||||
return () => { cancelled = true }
|
||||
}, [art?.id,contentUrl])
|
||||
}, [art?.id])
|
||||
|
||||
if (!active) return null
|
||||
if (active.id.startsWith('surface:')) {
|
||||
@@ -97,7 +96,6 @@ export function PreviewPane() {
|
||||
await api.saveArtifactContent(art.id, content ?? '')
|
||||
setDirty(false)
|
||||
store.toast('已保存')
|
||||
const conv=store.getState().currentConvId;if(conv)void store.refreshCards(conv)
|
||||
}}>保存</Btn>}
|
||||
</>
|
||||
)}
|
||||
@@ -110,7 +108,6 @@ export function PreviewPane() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{active.sourceContext&&<div className="acard-status-note" role="note"><p>{active.sourceContext.notice}</p>{active.sourceContext.quote&&<blockquote>{active.sourceContext.quote}</blockquote>}</div>}
|
||||
<div className="preview-pane-body">
|
||||
{editing && textKind ? (
|
||||
<textarea className="search-input preview-pane-editor" value={content ?? ''}
|
||||
@@ -121,7 +118,7 @@ export function PreviewPane() {
|
||||
id: art.id,
|
||||
name: art.name,
|
||||
mime: art.mime,
|
||||
contentUrl,
|
||||
contentUrl: api.artifactContentUrl(art.id),
|
||||
downloadUrl: api.artifactDownloadUrl(art.id),
|
||||
}} />
|
||||
) : <div className="empty-note" style={{ padding: 14 }}>这个文件已不在当前项目里</div>}
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
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,
|
||||
api, type ManagedAccountRecord, type PermissionDescriptor, type RoleMember, type RolePermissionBatchResult, type RolePermissionPatchEntry,
|
||||
type RolePermissionPreview, type RoleRecord, type UserRoleAssignmentPreview,
|
||||
} from '../api'
|
||||
import {
|
||||
accountLabel, accountName, assignedToRole, clearSelection, createSelection, isSelected, memberAssignments, pageSelectionState,
|
||||
previewAllowsAssign, previewTransition, refreshStateVersions, sameNameHint, selectPage, selectionCounts, selectionSummary, staleUserIds,
|
||||
toggleOne, type MemberSelection,
|
||||
} from '../roleMembers'
|
||||
import {
|
||||
PROJECT_CREATE_ACTION, PROJECT_CREATE_LINKED_ACTIONS, ROLE_PERMISSION_STATES, categoryActions, createRolePermissionDraft, dirtyRoles,
|
||||
linkedByProjectCreate, permissionCategories, rebaseRoleDraft, rolePatchEntries, stateLabel, stateToValue, updateRolePermission,
|
||||
validateRolePermissionDraft, valueToState, visiblePatch,
|
||||
type RolePermissionDraft, type RolePermissionStateName, type RolePermissionValue,
|
||||
} from '../rolePermissions'
|
||||
import { Collapsible, Icon } from '@ui'
|
||||
import { useStore } from '../store'
|
||||
@@ -10,6 +20,14 @@ import { useStore } from '../store'
|
||||
/** DR9 离开管理区守卫:面板有未保存权限草稿时注册;AdminZone「返回工作台」消费。 */
|
||||
export const roleDraftLeaveGuard: { current: (() => boolean) | null } = { current: null }
|
||||
|
||||
export const NARROW_ROLE_QUERY = '(max-width: 900px)'
|
||||
const LINKED_NOTE = '创建项目时自动具备 · 仅本人创建的项目'
|
||||
|
||||
/** 草稿携带建立基线时的角色快照:其 roleVersion 即批量 patch 的 expectedRoleVersion。 */
|
||||
type RoleDraft = RolePermissionDraft & { role: RoleRecord }
|
||||
type RoleDraftMap = Record<string, RoleDraft>
|
||||
type RolePermissionClient = Pick<typeof api, 'previewRolePermissions' | 'batchPatchRolePermissions' | 'getRole'>
|
||||
|
||||
export function filterRoles(
|
||||
roles: RoleRecord[], query: string, mode: 'exact' | 'fuzzy', status: 'all' | 'enabled' | 'disabled',
|
||||
): RoleRecord[] {
|
||||
@@ -23,11 +41,6 @@ export function filterRoles(
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -36,6 +49,16 @@ export function hasSystemAction(actions: string[] | undefined, action: string):
|
||||
return actions?.includes(action) ?? false
|
||||
}
|
||||
|
||||
/** 窄屏(≤900px)默认单角色聚焦;仍可手动打开矩阵。 */
|
||||
export function initialRoleView(matches?: (query: string) => boolean): 'single' | 'matrix' {
|
||||
return matches?.(NARROW_ROLE_QUERY) ? 'single' : 'matrix'
|
||||
}
|
||||
|
||||
/** 系统动作(project.create)是二态开关;项目动作是三态。 */
|
||||
export function cellKind(item: PermissionDescriptor): 'toggle' | 'state' {
|
||||
return item.scope === 'system' ? 'toggle' : 'state'
|
||||
}
|
||||
|
||||
function roleValues(role: RoleRecord, catalog: PermissionDescriptor[]): Record<string, RolePermissionValue> {
|
||||
const existing = new Map(role.permissions.map(item => [item.actionId, item]))
|
||||
return Object.fromEntries(catalog.filter(item => !item.protected).map(item => {
|
||||
@@ -49,49 +72,315 @@ function roleValue(role: RoleRecord, actionId: string): RolePermissionValue {
|
||||
return { allowed: value?.allowed ?? false, capAllowed: value?.capAllowed ?? false }
|
||||
}
|
||||
|
||||
export function roleCellValue(drafts: RoleDraftMap, role: RoleRecord, actionId: string): RolePermissionValue {
|
||||
return drafts[role.roleId]?.values[actionId] ?? roleValue(role, actionId)
|
||||
}
|
||||
|
||||
// DR9:任何单元格修改都写进该角色自己的草稿(无需先选中);首次修改时以当时的服务端值为基线。
|
||||
export function editRoleDraft(
|
||||
drafts: RoleDraftMap, role: RoleRecord, catalog: PermissionDescriptor[], actionId: string, value: RolePermissionValue,
|
||||
): RoleDraftMap {
|
||||
const current = drafts[role.roleId] ?? { ...createRolePermissionDraft(roleValues(role, catalog)), role }
|
||||
return { ...drafts, [role.roleId]: { ...updateRolePermission(current, actionId, value), role: current.role } }
|
||||
}
|
||||
|
||||
export function roleLeaveMessage(names: string[]): string {
|
||||
return `以下角色有未保存的权限修改:${names.map(name => `「${name}」`).join('、')}。离开将放弃这些修改,确定离开吗?`
|
||||
}
|
||||
|
||||
function previewBlocksSave(previews: RolePermissionPreview[]): boolean {
|
||||
return previews.some(preview => !!preview.error || (preview.conflicts?.length ?? 0) > 0)
|
||||
}
|
||||
|
||||
export async function previewRoleBatch(client: Pick<RolePermissionClient, 'previewRolePermissions'>, entries: RolePermissionPatchEntry[]) {
|
||||
const previews = await client.previewRolePermissions(entries, { mode: 'patch' })
|
||||
return { previews, blocked: previewBlocksSave(previews) }
|
||||
}
|
||||
|
||||
// 原子保存:所有脏角色一次提交;预览存在冲突或错误时拒绝提交。
|
||||
export async function commitRoleBatch(
|
||||
client: Pick<RolePermissionClient, 'batchPatchRolePermissions'>, entries: RolePermissionPatchEntry[],
|
||||
previews: RolePermissionPreview[], idempotencyKey: string,
|
||||
): Promise<RolePermissionBatchResult> {
|
||||
if (!entries.length) throw new Error('没有需要保存的权限修改')
|
||||
if (previewBlocksSave(previews)) throw new Error('预览存在冲突或错误,不能保存')
|
||||
return client.batchPatchRolePermissions(entries, idempotencyKey)
|
||||
}
|
||||
|
||||
// DR9:409 后逐个读取最新角色,只重放各角色相对自身旧基线的本地变化。
|
||||
export async function rebaseRoleDrafts(
|
||||
client: Pick<RolePermissionClient, 'getRole'>, drafts: RoleDraftMap, roleIds: string[], catalog: PermissionDescriptor[],
|
||||
): Promise<{ fresh: RoleRecord[]; drafts: RoleDraftMap }> {
|
||||
const fresh = await Promise.all(roleIds.map(roleId => client.getRole(roleId)))
|
||||
const next = { ...drafts }
|
||||
for (const role of fresh) {
|
||||
const current = drafts[role.roleId]
|
||||
if (current) next[role.roleId] = { ...rebaseRoleDraft(current, roleValues(role, catalog)), role }
|
||||
}
|
||||
return { fresh, drafts: next }
|
||||
}
|
||||
|
||||
function pruneCleanDrafts(drafts: RoleDraftMap, actionIds: string[]): RoleDraftMap {
|
||||
const dirty = new Set(dirtyRoles(drafts, actionIds).map(entry => entry.roleId))
|
||||
if (Object.keys(drafts).every(roleId => dirty.has(roleId))) return drafts
|
||||
return Object.fromEntries(Object.entries(drafts).filter(([roleId]) => dirty.has(roleId)))
|
||||
}
|
||||
|
||||
// 本端资料/状态写入成功且草稿基线即写入前版本时,权限未变,草稿可继承新版本号。
|
||||
function adoptRoleSnapshot(drafts: RoleDraftMap, expectedVersion: number, updated: RoleRecord): RoleDraftMap {
|
||||
const current = drafts[updated.roleId]
|
||||
if (!current || current.role.roleVersion !== expectedVersion) return drafts
|
||||
return { ...drafts, [updated.roleId]: { ...current, role: updated } }
|
||||
}
|
||||
|
||||
function mergeRoles(current: RoleRecord[] | null, fresh: RoleRecord[]): RoleRecord[] {
|
||||
const byId = new Map(fresh.map(role => [role.roleId, role]))
|
||||
const merged = (current ?? []).map(role => byId.get(role.roleId) ?? role)
|
||||
return [...fresh.filter(role => !merged.some(item => item.roleId === role.roleId)), ...merged]
|
||||
}
|
||||
|
||||
export const roleMembersLabel = (role: Pick<RoleRecord, 'name'>) => `查看角色 ${role.name} 的成员`
|
||||
|
||||
export function memberStatusLabel(member: Pick<RoleMember, 'enabled'>): string {
|
||||
return member.enabled ? '有效' : '停用(绑定保留,不授予权限)'
|
||||
}
|
||||
|
||||
const formatAssignedAt = (value: string) => {
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
/** 角色成员子视图:成员分页列表 + 「添加用户到角色」(预览后一次批量事务提交)。 */
|
||||
function RoleMembersView({ role, canAssign, backLabel, onBack, onAssigned }: {
|
||||
role: RoleRecord; canAssign: boolean; backLabel: string; onBack: () => void; onAssigned: () => void
|
||||
}) {
|
||||
const [members, setMembers] = useState<RoleMember[] | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [status, setStatus] = useState<'all' | 'enabled' | 'disabled'>('all')
|
||||
const [cursor, setCursor] = useState('')
|
||||
const [trail, setTrail] = useState<string[]>([])
|
||||
const [nextCursor, setNextCursor] = useState('')
|
||||
const [reload, setReload] = useState(0)
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const [userQuery, setUserQuery] = useState('')
|
||||
const [userCursor, setUserCursor] = useState('')
|
||||
const [userTrail, setUserTrail] = useState<string[]>([])
|
||||
const [nextUserCursor, setNextUserCursor] = useState('')
|
||||
const [candidates, setCandidates] = useState<ManagedAccountRecord[] | null>(null)
|
||||
const [known, setKnown] = useState<Record<string, ManagedAccountRecord>>({})
|
||||
const [selection, setSelection] = useState<MemberSelection>(createSelection)
|
||||
const [preview, setPreview] = useState<UserRoleAssignmentPreview[] | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [notice, setNotice] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
const memberSequence = useRef(0)
|
||||
const userSequence = useRef(0)
|
||||
const previewSequence = useRef(0)
|
||||
const selectableIds = (candidates ?? []).filter(account => !assignedToRole(account, role.roleId)).map(account => account.userId)
|
||||
const pageState = pageSelectionState(selection, selectableIds)
|
||||
const counts = selectionCounts(selection, selectableIds)
|
||||
const ready = !!preview && previewAllowsAssign(preview, selection.ids)
|
||||
const stale = preview ? staleUserIds(preview) : []
|
||||
|
||||
useEffect(() => {
|
||||
const request = ++memberSequence.current
|
||||
const timer = window.setTimeout(() => {
|
||||
void api.listRoleMembers(role.roleId, { query: query.trim(), mode: 'fuzzy', status, cursor, limit: 20 })
|
||||
.then(page => { if (request === memberSequence.current) { setMembers(page.members ?? []); setNextCursor(page.nextCursor ?? '') } })
|
||||
.catch(error => { if (request === memberSequence.current) setErr(String(error)) })
|
||||
}, 180)
|
||||
return () => { window.clearTimeout(timer); memberSequence.current++ }
|
||||
}, [role.roleId, query, status, cursor, reload])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pickerOpen) return
|
||||
const request = ++userSequence.current
|
||||
const timer = window.setTimeout(() => {
|
||||
void api.searchAdminUsers({ query: userQuery.trim(), mode: 'fuzzy', status: 'all', fields: ['owner', 'name', 'userId'], cursor: userCursor, limit: 20 })
|
||||
.then(page => {
|
||||
if (request !== userSequence.current) return
|
||||
setCandidates(page.accounts ?? []); setNextUserCursor(page.nextCursor ?? '')
|
||||
setKnown(current => ({ ...current, ...Object.fromEntries((page.accounts ?? []).map(account => [account.userId, account])) }))
|
||||
})
|
||||
.catch(error => { if (request === userSequence.current) setErr(String(error)) })
|
||||
}, 180)
|
||||
return () => { window.clearTimeout(timer); userSequence.current++ }
|
||||
}, [pickerOpen, userQuery, userCursor, reload])
|
||||
|
||||
const changeSelection = (next: MemberSelection) => {
|
||||
previewSequence.current++
|
||||
setSelection(next); setPreview(null)
|
||||
}
|
||||
|
||||
const requestPreview = async (versions = known) => {
|
||||
const request = ++previewSequence.current
|
||||
setPreview(null); setErr(''); setNotice(''); setBusy(true)
|
||||
try {
|
||||
const assignments = memberAssignments(selection.ids, role.roleId,
|
||||
Object.fromEntries(Object.values(versions).map(account => [account.userId, account.stateVersion])))
|
||||
const previews = await api.previewUserRoleAssignments(assignments)
|
||||
if (request === previewSequence.current) setPreview(previews)
|
||||
} catch (error) { if (request === previewSequence.current) setErr(error instanceof Error ? error.message : String(error)) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const refreshVersions = async () => {
|
||||
if (!preview) return
|
||||
setBusy(true); setErr('')
|
||||
try {
|
||||
const fresh = { ...known, ...await refreshStateVersions(api, staleUserIds(preview)) }
|
||||
setKnown(fresh)
|
||||
await requestPreview(fresh)
|
||||
} catch (error) { setErr(String(error)); setBusy(false) }
|
||||
}
|
||||
|
||||
const confirm = async () => {
|
||||
if (!preview || !previewAllowsAssign(preview, selection.ids)) return
|
||||
setBusy(true); setErr(''); setNotice('')
|
||||
try {
|
||||
const assignments = memberAssignments(selection.ids, role.roleId,
|
||||
Object.fromEntries(Object.values(known).map(account => [account.userId, account.stateVersion])))
|
||||
const result = await api.assignUserRolesBatch(assignments, crypto.randomUUID())
|
||||
changeSelection(clearSelection())
|
||||
setCursor(''); setTrail([]); setUserCursor(''); setUserTrail([]); setReload(value => value + 1)
|
||||
onAssigned()
|
||||
setNotice(`已将 ${result.bindings?.length ?? assignments.length} 人加入角色「${role.name}」`)
|
||||
} catch (error) {
|
||||
setPreview(null)
|
||||
setErr(String(error).includes('409') ? '部分用户状态或角色已变化,整批均未写入;选择已保留,请重新预览后再确认' : `整批均未写入:${String(error)}`)
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return <section className="rp-members" aria-label={`角色「${role.name}」的成员`}>
|
||||
<div className="ad-row">
|
||||
<button className="ad-mini" disabled={busy} onClick={onBack}>{backLabel}</button>
|
||||
<strong className="au-grow">角色「{role.name}」的成员 · {role.bindingCount} 人</strong>
|
||||
</div>
|
||||
{!role.enabled && <p className="rp-disabled-note">角色已停用:现有绑定不再授予权限,也不能向其添加用户</p>}
|
||||
<div className="rp-status">
|
||||
{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>}
|
||||
</div>
|
||||
<div className="ad-row rp-filters">
|
||||
<input className="lg-input" aria-label="搜索角色成员" placeholder="姓名、账号或 ID" value={query} onChange={event => { setQuery(event.target.value); setCursor(''); setTrail([]) }} />
|
||||
<select className="lg-input" aria-label="成员状态" value={status} onChange={event => { setStatus(event.target.value as typeof status); setCursor(''); setTrail([]) }}>
|
||||
<option value="all">全部状态</option><option value="enabled">有效</option><option value="disabled">停用</option>
|
||||
</select>
|
||||
</div>
|
||||
{sameNameHint(members ?? []) && <p className="ad-hint">{sameNameHint(members ?? [])}</p>}
|
||||
<table className="ad-table"><thead><tr><th>姓名与账号</th><th>状态</th><th>分配时间</th></tr></thead>
|
||||
<tbody>{(members ?? []).map(member => <tr key={member.userId}>
|
||||
<td><div className="au-name-cell"><span>{accountName(member)}</span>{member.name.trim() && <span className="ad-dim ad-mono">{member.owner}</span>}</div></td>
|
||||
<td>{memberStatusLabel(member)}</td><td>{formatAssignedAt(member.assignedAt)}</td>
|
||||
</tr>)}
|
||||
{members !== null && members.length === 0 && <tr><td colSpan={3} className="ad-dim">该角色暂无匹配成员</td></tr>}
|
||||
{members === null && <tr><td colSpan={3} className="ad-dim">加载中…</td></tr>}</tbody></table>
|
||||
<div className="ad-row">
|
||||
<button className="ad-mini" disabled={!trail.length} onClick={() => { setCursor(trail[trail.length - 1]); setTrail(trail.slice(0, -1)) }}>上一页成员</button>
|
||||
<button className="ad-mini" disabled={!nextCursor} onClick={() => { setTrail([...trail, cursor]); setCursor(nextCursor) }}>下一页成员</button>
|
||||
</div>
|
||||
{canAssign && role.enabled && <Collapsible open={pickerOpen} onToggle={setPickerOpen} trigger="添加用户到角色">
|
||||
<div className="au-copy">
|
||||
<div className="ad-row">
|
||||
<input className="lg-input" aria-label="搜索要添加的用户" placeholder="搜索用户姓名、账号或 ID" value={userQuery}
|
||||
onChange={event => { setUserQuery(event.target.value); setUserCursor(''); setUserTrail([]) }} />
|
||||
<button className="ad-mini" disabled={!userTrail.length} onClick={() => { setUserCursor(userTrail[userTrail.length - 1]); setUserTrail(userTrail.slice(0, -1)) }}>上一页用户</button>
|
||||
<button className="ad-mini" disabled={!nextUserCursor} onClick={() => { setUserTrail([...userTrail, userCursor]); setUserCursor(nextUserCursor) }}>下一页用户</button>
|
||||
</div>
|
||||
{sameNameHint(candidates ?? []) && <p className="ad-hint">{sameNameHint(candidates ?? [])}</p>}
|
||||
<table className="ad-table"><thead><tr>
|
||||
<th className="au-select-col"><input type="checkbox" aria-label="全选本页可添加用户" checked={pageState === 'all'} disabled={busy || !selectableIds.length}
|
||||
ref={element => { if (element) element.indeterminate = pageState === 'some' }}
|
||||
onChange={event => changeSelection(selectPage(selection, selectableIds, event.target.checked))} /></th>
|
||||
<th>姓名与账号</th><th>当前角色</th><th>状态</th></tr></thead>
|
||||
<tbody>{(candidates ?? []).map(account => {
|
||||
const assigned = assignedToRole(account, role.roleId)
|
||||
return <tr key={account.userId}>
|
||||
<td className="au-select-col"><input type="checkbox" aria-label={assigned ? `${accountLabel(account)} 已分配` : `选择 ${accountLabel(account)}`}
|
||||
checked={!assigned && isSelected(selection, account.userId)} disabled={busy || assigned}
|
||||
onChange={event => changeSelection(toggleOne(selection, account.userId, event.target.checked))} /></td>
|
||||
<td><div className="au-name-cell"><span>{accountName(account)}</span>{account.profile.name.trim() && <span className="ad-dim ad-mono">{account.owner}</span>}</div></td>
|
||||
<td>{assigned ? <span className="au-same-name">已分配</span> : account.role === null ? '未分配' : account.role?.name ?? '—'}</td>
|
||||
<td>{account.enabled ? '有效' : '停用'}</td>
|
||||
</tr>
|
||||
})}
|
||||
{candidates !== null && candidates.length === 0 && <tr><td colSpan={4} className="ad-dim">没有匹配用户</td></tr>}
|
||||
{candidates === null && <tr><td colSpan={4} className="ad-dim">加载中…</td></tr>}</tbody></table>
|
||||
{preview && <ul className="au-preview-list">{preview.map(item => <li key={item.userId}>
|
||||
<span>{accountLabel(item)}</span><span className="ad-dim">{previewTransition(item)}</span>
|
||||
{item.error && <span className="rp-conflict"><Icon name="alert" size={12} />{item.error}</span>}
|
||||
{!item.error && !item.stateMatches && <span className="rp-conflict"><Icon name="alert" size={12} />用户状态已变化</span>}
|
||||
</li>)}</ul>}
|
||||
{preview && !ready && <p className="rp-conflict" role="alert"><Icon name="alert" size={12} />存在停用、版本变化或角色无效的用户,处理后才能确认添加</p>}
|
||||
<div className="ad-row">
|
||||
<span className="ad-hint au-grow" aria-live="polite">{selectionSummary(counts)}</span>
|
||||
<button className="ad-mini" disabled={busy || !counts.count} onClick={() => changeSelection(clearSelection())}>清空选择</button>
|
||||
{stale.length > 0 && <button className="ad-mini" disabled={busy} onClick={() => void refreshVersions()}>刷新 {stale.length} 人的状态并重新预览</button>}
|
||||
<button className="ad-mini" disabled={busy || !counts.count} onClick={() => void requestPreview()}>预览分配</button>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !ready} onClick={() => void confirm()}>确认添加</button>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible>}
|
||||
</section>
|
||||
}
|
||||
|
||||
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 canViewMembers = allowed('system.users.view') && allowed('system.roles.view')
|
||||
const canAssignMembers = canViewMembers && allowed('system.roles.assign')
|
||||
const [membersRole, setMembersRole] = useState<RoleRecord | null>(null)
|
||||
const [roles, setRoles] = useState<RoleRecord[] | null>(null)
|
||||
const [catalog, setCatalog] = useState<PermissionDescriptor[]>([])
|
||||
const [catalogFailed, setCatalogFailed] = useState(false)
|
||||
const [selectedId, setSelectedId] = useState('')
|
||||
const [drafts, setDrafts] = useState<Record<string, RolePermissionDraft>>({})
|
||||
const [drafts, setDrafts] = useState<RoleDraftMap>({})
|
||||
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 [category, setCategory] = useState('')
|
||||
const [actionFilter, setActionFilter] = useState('')
|
||||
const [roleCursor, setRoleCursor] = useState('')
|
||||
const [roleTrail, setRoleTrail] = useState<string[]>([])
|
||||
const [nextRoleCursor, setNextRoleCursor] = useState('')
|
||||
const [page, setPage] = useState(0)
|
||||
const [view, setView] = useState<'single' | 'matrix'>('single')
|
||||
const [view, setView] = useState<'single' | 'matrix'>(() => initialRoleView(
|
||||
typeof window !== 'undefined' && typeof window.matchMedia === 'function' ? media => window.matchMedia(media).matches : undefined))
|
||||
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 [preview, setPreview] = useState<{ previews: RolePermissionPreview[]; confirm: boolean } | null>(null)
|
||||
const requestSequence = useRef(0)
|
||||
const selectedIdRef = useRef('')
|
||||
const savePermsRef = useRef<HTMLButtonElement>(null)
|
||||
const statusRef = useRef<HTMLDivElement>(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 descriptorById = useMemo(() => new Map(catalog.map(item => [item.actionId, item])), [catalog])
|
||||
const categories = useMemo(() => permissionCategories(catalog), [catalog])
|
||||
const activeCategory = categories.includes(category) ? category : categories[0] ?? ''
|
||||
const columns = useMemo(() => categoryActions(catalog, activeCategory), [catalog, activeCategory])
|
||||
const dirtyList = useMemo(() => dirtyRoles(drafts, allEditableIds), [drafts, allEditableIds])
|
||||
const dirtyNames = dirtyList.map(entry => roles?.find(role => role.roleId === entry.roleId)?.name ?? drafts[entry.roleId].role.name)
|
||||
const changeCount = dirtyList.reduce((sum, entry) => sum + entry.changes, 0)
|
||||
const changes = draft ? visiblePatch(draft, allEditableIds) : []
|
||||
const filtered = roles ?? []
|
||||
const effectiveView = canViewPermissions ? view : 'single'
|
||||
const editable = canManagePermissions && !catalogFailed && catalog.length > 0
|
||||
const blocked = !!preview && previewBlocksSave(preview.previews)
|
||||
const labelOf = (actionId: string) => descriptorById.get(actionId)?.label ?? actionId
|
||||
const cellDisabled = (role: RoleRecord) => busy || roleLoading || !editable || !role.enabled
|
||||
|
||||
const load = async (pageCursor = roleCursor, refreshCatalog = false, preferredId = selectedId,
|
||||
filters = { query, mode, status, action: actionFilter }) => {
|
||||
@@ -107,6 +396,7 @@ export function RolePermissionsPanel() {
|
||||
setRoleLoading(false)
|
||||
const descriptors = catalogResult.status === 'fulfilled' ? catalogResult.value : catalog
|
||||
if (catalogResult.status === 'fulfilled') setCatalog(descriptors)
|
||||
setCatalogFailed(catalogResult.status === 'rejected')
|
||||
if (roleResult.status === 'rejected') {
|
||||
setErr(String(roleResult.reason))
|
||||
return
|
||||
@@ -114,34 +404,33 @@ export function RolePermissionsPanel() {
|
||||
const rolePage = roleResult.value
|
||||
setRoles(rolePage.roles ?? [])
|
||||
setNextRoleCursor(rolePage.nextCursor ?? '')
|
||||
setDrafts(prev => pruneCleanDrafts(prev, descriptors.filter(item => !item.protected).map(item => item.actionId)))
|
||||
const nextSelected = rolePage.roles.find(role => role.roleId === preferredId) ?? rolePage.roles[0] ?? null
|
||||
if (nextSelected) selectRole(nextSelected, descriptors, sequence)
|
||||
if (nextSelected) selectRole(nextSelected, sequence)
|
||||
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, 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:离开管理区须显式决定;带未保存草稿时刷新走浏览器原生确认。
|
||||
// DR9:离开管理区须显式决定(覆盖所有脏角色);带未保存草稿时刷新走浏览器原生确认。
|
||||
const dirtyKey = dirtyNames.join('\u0000')
|
||||
useEffect(() => {
|
||||
if (!dirty || !selected) { roleDraftLeaveGuard.current = null; return }
|
||||
const message = `角色「${selected.name}」有未保存的权限修改,离开将放弃这些修改。确定离开吗?`
|
||||
if (!dirtyNames.length) { roleDraftLeaveGuard.current = null; return }
|
||||
const message = roleLeaveMessage(dirtyNames)
|
||||
roleDraftLeaveGuard.current = () => window.confirm(message)
|
||||
return () => { roleDraftLeaveGuard.current = null }
|
||||
}, [dirty, selected?.roleId, selected?.name])
|
||||
}, [dirtyKey])
|
||||
const dirty = dirtyList.length > 0
|
||||
useEffect(() => {
|
||||
if (!dirty) return
|
||||
const handler = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = '' }
|
||||
@@ -155,6 +444,13 @@ export function RolePermissionsPanel() {
|
||||
return () => { window.clearTimeout(timer); requestSequence.current++ }
|
||||
}, [query, mode, status, actionFilter, roleCursor, canViewPermissions])
|
||||
|
||||
// DR10:保存/冲突/失败后焦点稳定——若焦点因按钮短暂 disabled 或保存区收起掉回 body,则归还。
|
||||
const restoreFocus = () => requestAnimationFrame(() => {
|
||||
if (document.activeElement !== document.body) return
|
||||
const target = savePermsRef.current && !savePermsRef.current.disabled ? savePermsRef.current : statusRef.current
|
||||
target?.focus()
|
||||
})
|
||||
|
||||
const create = async () => {
|
||||
if (!newName.trim()) return
|
||||
requestSequence.current++; setRoleLoading(false)
|
||||
@@ -164,7 +460,7 @@ export function RolePermissionsPanel() {
|
||||
setNewName(''); setNewDescription('')
|
||||
setQuery(''); setStatus('all'); setActionFilter(''); setRoleCursor(''); setRoleTrail([])
|
||||
setRoles(current => [role, ...(current ?? []).filter(item => item.roleId !== role.roleId)])
|
||||
selectRole(role, catalog, ++requestSequence.current)
|
||||
selectRole(role, ++requestSequence.current)
|
||||
setNotice(`已创建角色 ${role.name}`)
|
||||
await load('', true, role.roleId, { query: '', mode, status: 'all', action: '' })
|
||||
setNotice(`已创建角色 ${role.name}`)
|
||||
@@ -178,15 +474,15 @@ export function RolePermissionsPanel() {
|
||||
setRoleLoading(false)
|
||||
setBusy(true); setErr(''); setNotice('')
|
||||
try {
|
||||
await api.patchRole(selected.roleId, selected.roleVersion, { name: name.trim(), description: description.trim() })
|
||||
const updated = await api.patchRole(selected.roleId, selected.roleVersion, { name: name.trim(), description: description.trim() })
|
||||
setDrafts(prev => adoptRoleSnapshot(prev, selected.roleVersion, updated))
|
||||
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 ?? [])])
|
||||
setRoles(current => mergeRoles(current, [fresh]))
|
||||
setErr('角色资料版本冲突;已读取最新版本并保留本地名称与描述,请核对后重试')
|
||||
} catch (refreshError) { setErr(String(refreshError)) }
|
||||
} else setErr(String(error))
|
||||
@@ -201,105 +497,190 @@ export function RolePermissionsPanel() {
|
||||
setRoleLoading(false)
|
||||
setBusy(true); setErr(''); setNotice('')
|
||||
try {
|
||||
await api.patchRole(selected.roleId, selected.roleVersion, { enabled: !selected.enabled })
|
||||
const updated = await api.patchRole(selected.roleId, selected.roleVersion, { enabled: !selected.enabled })
|
||||
setDrafts(prev => adoptRoleSnapshot(prev, selected.roleVersion, updated))
|
||||
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 ?? [])])
|
||||
setRoles(current => mergeRoles(current, [fresh]))
|
||||
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 editCell = (role: RoleRecord, actionId: string, value: RolePermissionValue) => {
|
||||
setDrafts(prev => editRoleDraft(prev, role, catalog, actionId, value))
|
||||
setPreview(null)
|
||||
}
|
||||
|
||||
const savePermissions = async () => {
|
||||
if (!selected || !draft) return
|
||||
const roleId = selected.roleId
|
||||
const patchEntries = () => rolePatchEntries(drafts,
|
||||
Object.fromEntries(Object.entries(drafts).map(([roleId, item]) => [roleId, item.role.roleVersion])), allEditableIds)
|
||||
|
||||
// 「查看变化」与「保存」都先取服务端权威预览;confirm=true 时保存按钮变为「确认保存」。
|
||||
const requestPreview = async (confirm: boolean) => {
|
||||
for (const entry of dirtyList) {
|
||||
const invalid = validateRolePermissionDraft(drafts[entry.roleId])
|
||||
if (invalid) { setErr(`角色「${drafts[entry.roleId].role.name}」的 ${labelOf(invalid.actionId)} 默认权限不能超过角色上限`); return }
|
||||
}
|
||||
const entries = patchEntries()
|
||||
if (!entries.length) return
|
||||
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('角色权限已保存')
|
||||
const result = await previewRoleBatch(api, entries)
|
||||
if (requestSequence.current !== operationSequence) return
|
||||
setPreview({ previews: result.previews, confirm })
|
||||
} catch (error) { setErr(String(error)) } finally { setBusy(false); restoreFocus() }
|
||||
}
|
||||
|
||||
const confirmSave = async () => {
|
||||
if (!preview) return
|
||||
const entries = patchEntries()
|
||||
const snapshot = drafts
|
||||
const operationSequence = ++requestSequence.current
|
||||
setRoleLoading(false)
|
||||
setBusy(true); setErr(''); setNotice('')
|
||||
try {
|
||||
const result = await commitRoleBatch(api, entries, preview.previews, crypto.randomUUID())
|
||||
const saved = new Set(entries.map(entry => entry.roleId))
|
||||
setDrafts(current => Object.fromEntries(Object.entries(current).filter(([roleId]) => !saved.has(roleId))))
|
||||
setPreview(null)
|
||||
await load(roleCursor, true, selectedId)
|
||||
setNotice(`已保存 ${result.receipts.length} 个角色的权限设置`)
|
||||
} catch (error) {
|
||||
if (String(error).includes('409')) {
|
||||
try { await rebasePermissionConflict(roleId, draft, operationSequence) } catch (refreshError) { setErr(String(refreshError)) }
|
||||
try {
|
||||
const rebased = await rebaseRoleDrafts(api, snapshot, entries.map(entry => entry.roleId), catalog)
|
||||
if (requestSequence.current !== operationSequence) return
|
||||
setRoles(current => mergeRoles(current, rebased.fresh))
|
||||
setDrafts(current => ({ ...current, ...Object.fromEntries(entries.map(entry => [entry.roleId, rebased.drafts[entry.roleId]])) }))
|
||||
setPreview(null)
|
||||
setErr(`权限版本冲突;已基于最新版本为 ${entries.length} 个角色重放本地草稿,请核对后再次保存`)
|
||||
} 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()
|
||||
})
|
||||
}
|
||||
} finally { setBusy(false); restoreFocus() }
|
||||
}
|
||||
|
||||
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 cancelAll = () => {
|
||||
if (!window.confirm(`确定放弃 ${dirtyNames.map(name => `「${name}」`).join('、')} 的全部未保存权限修改吗?`)) return
|
||||
setDrafts({}); setPreview(null); setErr(''); setNotice('已取消全部未保存的权限修改')
|
||||
}
|
||||
|
||||
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 openFocus = (role: RoleRecord) => { selectRole(role); setView('single') }
|
||||
|
||||
const valueLabel = (actionId: string, value: RolePermissionValue | undefined) => descriptorById.get(actionId)?.scope === 'system'
|
||||
? (value?.allowed ? '允许' : '不允许') : stateLabel(valueToState(value))
|
||||
|
||||
const cell = (role: RoleRecord, item: PermissionDescriptor) => {
|
||||
const value = roleCellValue(drafts, role, item.actionId)
|
||||
const label = `${role.name} ${item.label}`
|
||||
const disabled = cellDisabled(role)
|
||||
const roleDraft = drafts[role.roleId]
|
||||
const modified = !!roleDraft && visiblePatch(roleDraft, [item.actionId]).length > 0
|
||||
const linked = linkedByProjectCreate({ [PROJECT_CREATE_ACTION]: roleCellValue(drafts, role, PROJECT_CREATE_ACTION) }, item.actionId)
|
||||
return <div className="rp-cell">
|
||||
{cellKind(item) === 'toggle'
|
||||
? <select className="rp-state" aria-label={label} value={value.allowed ? 'on' : 'off'} disabled={disabled}
|
||||
onChange={event => editCell(role, item.actionId, stateToValue(event.target.value === 'on' ? 'default' : 'denied'))}>
|
||||
<option value="off">不允许</option><option value="on">允许</option>
|
||||
</select>
|
||||
: <select className="rp-state" aria-label={label} value={valueToState(value)} disabled={disabled}
|
||||
onChange={event => editCell(role, item.actionId, stateToValue(event.target.value as RolePermissionStateName))}>
|
||||
{ROLE_PERMISSION_STATES.map(option => <option key={option.state} value={option.state}>{option.label}</option>)}
|
||||
</select>}
|
||||
{modified && <span className="rp-cell-flag">已修改</span>}
|
||||
{linked && <span className="rp-linked"><Icon name="sparkles" size={12} />{LINKED_NOTE}</span>}
|
||||
</div>
|
||||
}
|
||||
|
||||
const matrix = () => <div className="rp-matrix-wrap">
|
||||
const matrix = () => <div className="rp-matrix-wrap" role="region" aria-label={`角色矩阵 · ${activeCategory}`} tabIndex={0}>
|
||||
<table className="ad-table rp-matrix">
|
||||
<thead><tr><th>角色</th>{pageItems.map(item => <th key={item.actionId}>{item.label}</th>)}</tr></thead>
|
||||
<thead><tr><th scope="col">角色</th>{columns.map(item => <th scope="col" key={item.actionId} title={item.description}>{item.label}</th>)}</tr></thead>
|
||||
<tbody>{filtered.map(role => <tr key={role.roleId} className={role.roleId === selectedId ? 'rp-selected' : ''}>
|
||||
<td><button className="ad-mini" disabled={busy || roleLoading} onClick={() => selectRole(role)}>{role.name}</button><span className="ad-dim"> · {role.bindingCount} 人</span></td>
|
||||
{pageItems.map(item => {
|
||||
const value = role.roleId === selectedId && draft ? draft.values[item.actionId] : roleValue(role, item.actionId)
|
||||
return <td key={item.actionId}>
|
||||
{item.protected ? <span className="ad-dim">系统授权</span> : <>
|
||||
<label className="ad-check"><input type="checkbox" aria-label={`${role.name} ${item.label} 默认允许`} checked={value?.allowed ?? false}
|
||||
disabled={busy || roleLoading || !canManagePermissions || role.roleId !== selectedId} onChange={event => editValue(item.actionId, 'allowed', event.target.checked)} />默认</label>
|
||||
<label className="ad-check"><input type="checkbox" aria-label={`${role.name} ${item.label} 角色上限`} checked={value?.capAllowed ?? false}
|
||||
disabled={busy || roleLoading || !canManagePermissions || role.roleId !== selectedId} onChange={event => editValue(item.actionId, 'capAllowed', event.target.checked)} />上限</label>
|
||||
</>}
|
||||
</td>
|
||||
})}
|
||||
<th scope="row"><div className="rp-role-cell">
|
||||
<button className="rp-role-name" disabled={busy || roleLoading} onClick={() => openFocus(role)}>{role.name}</button>
|
||||
{role.description && <span className="rp-role-desc" title={role.description}>{role.description}</span>}
|
||||
<span className="ad-dim">{canViewMembers
|
||||
? <button className="rp-members-link" aria-label={roleMembersLabel(role)} disabled={busy} onClick={() => setMembersRole(role)}>{role.bindingCount} 人</button>
|
||||
: `${role.bindingCount} 人`} · {role.enabled ? '启用' : '已停用'}{dirtyList.some(entry => entry.roleId === role.roleId) ? ' · 未保存' : ''}</span>
|
||||
{!role.enabled && <span className="rp-disabled-note">停用角色不可编辑权限,请先启用</span>}
|
||||
</div></th>
|
||||
{columns.map(item => <td key={item.actionId}>{cell(role, item)}</td>)}
|
||||
</tr>)}</tbody>
|
||||
</table>
|
||||
{filtered.length === 0 && !roleLoading && <p className="ad-hint rp-empty">没有符合条件的角色</p>}
|
||||
</div>
|
||||
|
||||
const focus = () => <div className="rp-single">
|
||||
<aside className="rp-role-list">
|
||||
{filtered.map(role => <button key={role.roleId} disabled={busy || roleLoading} className={`az-nav-item ${role.roleId === selectedId ? 'on' : ''}`} onClick={() => selectRole(role)}>
|
||||
<span>{role.name}{dirtyList.some(entry => entry.roleId === role.roleId) ? ' · 未保存' : ''}</span><span className="ad-dim">{role.bindingCount} 人</span>
|
||||
</button>)}
|
||||
</aside>
|
||||
<section className="rp-editor">
|
||||
{selected ? <>
|
||||
<div className="ad-row">
|
||||
<input className="lg-input" aria-label="角色名称" value={name} disabled={!canManageRoles || busy || roleLoading} onChange={event => setName(event.target.value)} />
|
||||
<input className="lg-input" aria-label="角色描述" value={description} disabled={!canManageRoles || busy || roleLoading} onChange={event => setDescription(event.target.value)} />
|
||||
{canManageRoles && <button className="ad-mini" disabled={busy || roleLoading} onClick={saveMetadata}>保存资料</button>}
|
||||
{canManageRoles && (selected.enabled
|
||||
? <button className="ad-mini" disabled={busy || roleLoading} onClick={() => setConfirmDisable(true)}>停用角色</button>
|
||||
: <button className="ad-mini" disabled={busy || roleLoading} onClick={setEnabled}>启用角色</button>)}
|
||||
{canViewMembers && <button className="ad-mini" aria-label={roleMembersLabel(selected)} disabled={busy} onClick={() => setMembersRole(selected)}>成员 {selected.bindingCount} 人</button>}
|
||||
</div>
|
||||
{confirmDisable && selected.enabled && <div className="rp-disable-confirm" role="alertdialog" aria-label={`确认停用角色 ${selected.name}`}>
|
||||
<span>停用角色「{selected.name}」?该角色现有 {selected.bindingCount} 个用户绑定,停用后这些绑定将不再授予权限。</span>
|
||||
<button className="ad-mini" disabled={busy || roleLoading} onClick={() => { setConfirmDisable(false); void setEnabled() }}>确认停用</button>
|
||||
<button className="ad-mini" disabled={busy} onClick={() => setConfirmDisable(false)}>取消</button>
|
||||
</div>}
|
||||
{canViewPermissions ? <>
|
||||
{!selected.enabled && <p className="rp-disabled-note">角色已停用,启用后才能编辑权限</p>}
|
||||
<table className="ad-table rp-focus-table"><thead><tr><th>权限 · {activeCategory}</th><th>状态</th><th>说明</th></tr></thead>
|
||||
<tbody>{columns.map(item => <tr key={item.actionId}>
|
||||
<td>{item.label}</td><td>{cell(selected, item)}</td><td className="ad-dim">{item.description}</td>
|
||||
</tr>)}</tbody></table>
|
||||
<Collapsible id={`role-tech-${selected.roleId}`} trigger="技术详情">
|
||||
<ul className="rp-diff">
|
||||
<li>角色 ID:<span className="ad-mono">{selected.roleId}</span> · 版本 {selected.roleVersion}</li>
|
||||
{columns.map(item => <li key={item.actionId}>{item.label}:<span className="ad-mono">{item.actionId}</span></li>)}
|
||||
</ul>
|
||||
</Collapsible>
|
||||
<Collapsible id={`role-diff-${selected.roleId}`} trigger={`变更预览(${changes.length})`}>
|
||||
{changes.length === 0 || !draft ? <p className="ad-hint">没有权限变化</p> : <ul className="rp-diff">{changes.map(change =>
|
||||
<li key={change.actionId}>{labelOf(change.actionId)}:{valueLabel(change.actionId, draft.baseline[change.actionId])} → {valueLabel(change.actionId, change)}</li>)}</ul>}
|
||||
</Collapsible>
|
||||
</> : <p className="ad-hint">当前账号没有查看角色权限目录的系统权限</p>}
|
||||
</> : <p className="ad-hint">{roleLoading ? '加载中…' : '请选择角色'}</p>}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
const previewPanel = (current: NonNullable<typeof preview>) => <section className="rp-preview" aria-label="保存前预览">
|
||||
<div className="rp-preview-head">
|
||||
<strong>{current.confirm ? '请确认以下变化后保存' : '待保存的变化'}</strong>
|
||||
<button className="ad-mini" disabled={busy} onClick={() => setPreview(null)}>收起</button>
|
||||
</div>
|
||||
{current.previews.map(item => <div key={item.roleId} className="rp-preview-role">
|
||||
<div className="rp-preview-title">{item.roleName}
|
||||
{!item.versionMatches && <span className="rp-warn-text"> · 角色已被他人修改,保存时将提示冲突并基于最新版本重放</span>}</div>
|
||||
{item.error && <p className="rp-conflict"><Icon name="alert" size={12} />错误{item.errorActionId ? ` · ${labelOf(item.errorActionId)}` : ''}:{item.error}</p>}
|
||||
{(item.changes ?? []).length > 0
|
||||
? <ul className="rp-diff">{item.changes.map(change => <li key={change.actionId}>{change.label}:{change.before} → {change.after}</li>)}</ul>
|
||||
: <p className="ad-hint">没有状态变化</p>}
|
||||
{(item.linkedExpansion ?? []).length > 0 && <p className="rp-linked"><Icon name="sparkles" size={12} />
|
||||
系统将自动补齐:{item.linkedExpansion.map(labelOf).join('、')}{item.linkedExpansionState ? `(${item.linkedExpansionState})` : ''}</p>}
|
||||
{(item.keptLinkedActions ?? []).length > 0 && <p className="ad-hint">关闭创建项目后保留:{item.keptLinkedActions.map(labelOf).join('、')}</p>}
|
||||
{item.migrationNote && <p className="ad-hint">{item.migrationNote}</p>}
|
||||
{(item.conflicts ?? []).map(conflict => <p key={conflict.actionId} className="rp-conflict">
|
||||
<Icon name="alert" size={12} />冲突 · {labelOf(conflict.actionId)}:{conflict.reason}</p>)}
|
||||
</div>)}
|
||||
{blocked && <p className="rp-conflict" role="alert"><Icon name="alert" size={12} />存在冲突或错误,调整后才能保存</p>}
|
||||
</section>
|
||||
|
||||
return <div className="rp-panel">
|
||||
{canManageRoles && <div className="ad-create">
|
||||
<div className="ad-row">
|
||||
@@ -317,9 +698,6 @@ export function RolePermissionsPanel() {
|
||||
<select className="lg-input" aria-label="角色状态" value={status} disabled={busy} onChange={event => { setStatus(event.target.value as typeof status); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }}>
|
||||
<option value="all">全部状态</option><option value="enabled">启用</option><option value="disabled">停用</option>
|
||||
</select>
|
||||
<select className="lg-input" aria-label="权限分组" value={group} onChange={event => { setGroup(event.target.value); setPage(0) }}>
|
||||
<option value="">全部权限</option>{groups.map(value => <option key={value}>{value}</option>)}
|
||||
</select>
|
||||
{canViewPermissions && <select className="lg-input" aria-label="按权限过滤角色" value={actionFilter} disabled={busy}
|
||||
onChange={event => { setActionFilter(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }}>
|
||||
<option value="">全部角色权限</option>{catalog.filter(item => !item.protected).map(item => <option key={item.actionId} value={item.actionId}>{item.label}</option>)}
|
||||
@@ -327,60 +705,35 @@ export function RolePermissionsPanel() {
|
||||
</div>
|
||||
</Collapsible>
|
||||
<div className="ad-row rp-view-toggle">
|
||||
<button className={`ad-mini ${view === 'single' ? 'on' : ''}`} onClick={() => setView('single')}>单角色配置</button>
|
||||
{canViewPermissions && <button className={`ad-mini ${view === 'matrix' ? 'on' : ''}`} onClick={() => setView('matrix')}>角色矩阵</button>}
|
||||
<button className="ad-mini" disabled={page === 0} onClick={() => setPage(value => value - 1)}>上一组</button>
|
||||
<button className="ad-mini" disabled={(page + 1) * 6 >= catalog.filter(item => !group || item.group === group).length} onClick={() => setPage(value => value + 1)}>下一组</button>
|
||||
{canViewPermissions && categories.length > 0 && <div className="rp-category-nav" role="group" aria-label="权限分类">
|
||||
{categories.map(value => <button key={value} className={`ad-mini ${value === activeCategory ? 'on' : ''}`} aria-pressed={value === activeCategory}
|
||||
onClick={() => setCategory(value)}>{value}</button>)}
|
||||
</div>}
|
||||
{canViewPermissions && <button className={`ad-mini ${effectiveView === 'matrix' ? 'on' : ''}`} aria-pressed={effectiveView === 'matrix'} onClick={() => { setMembersRole(null); setView('matrix') }}>角色矩阵</button>}
|
||||
<button className={`ad-mini ${effectiveView === 'single' ? 'on' : ''}`} aria-pressed={effectiveView === 'single'} onClick={() => { setMembersRole(null); setView('single') }}>单角色配置</button>
|
||||
<button className="ad-mini" disabled={busy || roleLoading || !roleTrail.length} onClick={() => { setRoleCursor(roleTrail[roleTrail.length - 1]); setRoleTrail(roleTrail.slice(0, -1)) }}>上一页角色</button>
|
||||
<button className="ad-mini" disabled={busy || roleLoading || !nextRoleCursor} onClick={() => { setRoleTrail([...roleTrail, roleCursor]); setRoleCursor(nextRoleCursor) }}>下一页角色</button>
|
||||
{view === 'matrix' && canManagePermissions && <>
|
||||
<button className="ad-mini" disabled={busy || roleLoading || !editablePageItems.length} onClick={() => editVisible('allowed', true)}>当前页默认全选</button>
|
||||
<button className="ad-mini" disabled={busy || roleLoading || !editablePageItems.length} onClick={() => editVisible('allowed', false)}>当前页默认清除</button>
|
||||
<button className="ad-mini" disabled={busy || roleLoading || !editablePageItems.length} onClick={() => editVisible('capAllowed', true)}>当前页上限全开</button>
|
||||
<button className="ad-mini" disabled={busy || roleLoading || !editablePageItems.length} onClick={() => editVisible('capAllowed', false)}>当前页上限关闭</button>
|
||||
</>}
|
||||
</div>
|
||||
{notice && <div className="ad-notice" role="status"><Icon name="check" size={14} />{notice}</div>}
|
||||
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
|
||||
{view === 'matrix' ? matrix() : <div className="rp-single">
|
||||
<aside className="rp-role-list">
|
||||
{(filtered).map(role => <button key={role.roleId} disabled={busy || roleLoading} className={`az-nav-item ${role.roleId === selectedId ? 'on' : ''}`} onClick={() => selectRole(role)}>
|
||||
<span>{role.name}</span><span className="ad-dim">{role.bindingCount} 人</span>
|
||||
</button>)}
|
||||
</aside>
|
||||
<section className="rp-editor">
|
||||
{selected && draft ? <>
|
||||
<div className="ad-row">
|
||||
<input className="lg-input" aria-label="角色名称" value={name} disabled={!canManageRoles || busy || roleLoading} onChange={event => setName(event.target.value)} />
|
||||
<input className="lg-input" aria-label="角色描述" value={description} disabled={!canManageRoles || busy || roleLoading} onChange={event => setDescription(event.target.value)} />
|
||||
{canManageRoles && <button className="ad-mini" disabled={busy || roleLoading} onClick={saveMetadata}>保存资料</button>}
|
||||
{canManageRoles && (selected.enabled
|
||||
? <button className="ad-mini" disabled={busy || roleLoading} onClick={() => setConfirmDisable(true)}>停用角色</button>
|
||||
: <button className="ad-mini" disabled={busy || roleLoading} onClick={setEnabled}>启用角色</button>)}
|
||||
</div>
|
||||
{confirmDisable && selected.enabled && <div className="rp-disable-confirm" role="alertdialog" aria-label={`确认停用角色 ${selected.name}`}>
|
||||
<span>停用角色「{selected.name}」?该角色现有 {selected.bindingCount} 个用户绑定,停用后这些绑定将不再授予权限。</span>
|
||||
<button className="ad-mini" disabled={busy || roleLoading} onClick={() => { setConfirmDisable(false); void setEnabled() }}>确认停用</button>
|
||||
<button className="ad-mini" disabled={busy} onClick={() => setConfirmDisable(false)}>取消</button>
|
||||
</div>}
|
||||
{canViewPermissions ? <><table className="ad-table"><thead><tr><th>权限</th><th>默认允许</th><th>角色上限</th><th>说明</th></tr></thead>
|
||||
<tbody>{pageItems.map(item => {
|
||||
const value = draft.values[item.actionId] ?? { allowed: false, capAllowed: false }
|
||||
return <tr key={item.actionId}><td>{item.label}<div className="ad-dim ad-mono">{item.actionId}</div></td>
|
||||
<td><input type="checkbox" aria-label={`${item.label} 默认允许`} checked={value.allowed} disabled={busy || roleLoading || !canManagePermissions || item.protected}
|
||||
onChange={event => editValue(item.actionId, 'allowed', event.target.checked)} /></td>
|
||||
<td><input type="checkbox" aria-label={`${item.label} 角色上限`} checked={value.capAllowed} disabled={busy || roleLoading || !canManagePermissions || item.protected}
|
||||
onChange={event => editValue(item.actionId, 'capAllowed', event.target.checked)} /></td>
|
||||
<td className="ad-dim">{item.protected ? '受保护系统授权,不能由普通角色授予' : item.description}</td></tr>
|
||||
})}</tbody></table>
|
||||
<Collapsible id={`role-diff-${selected.roleId}`} trigger={`变更预览(${changes.length})`}>
|
||||
{changes.length === 0 ? <p className="ad-hint">没有权限变化</p> : <ul className="rp-diff">{changes.map(change =>
|
||||
<li key={change.actionId}><span className="ad-mono">{change.actionId}</span>:默认 {change.allowed ? '允许' : '拒绝'},上限 {change.capAllowed ? '允许' : '拒绝'}</li>)}</ul>}
|
||||
</Collapsible>
|
||||
{canManagePermissions && <button ref={savePermsRef} className="lg-submit ad-create-btn" disabled={busy || roleLoading || changes.length === 0 || editablePageItems.length === 0} onClick={savePermissions}>保存权限</button>}
|
||||
</> : <p className="ad-hint">当前账号没有查看角色权限目录的系统权限</p>}
|
||||
</> : <p className="ad-hint">请选择角色</p>}
|
||||
</section>
|
||||
{canViewPermissions && <Collapsible id="role-linked-help" trigger="权限关联说明">
|
||||
<p className="ad-hint">开启「{labelOf(PROJECT_CREATE_ACTION)}」的角色,在本人创建的项目中自动具备{PROJECT_CREATE_LINKED_ACTIONS.map(id => `「${labelOf(id)}」`).join('、')}。
|
||||
这些资格仅对本人创建的项目生效,其他项目仍按项目授权判断。</p>
|
||||
</Collapsible>}
|
||||
<div ref={statusRef} tabIndex={-1} className="rp-status">
|
||||
{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>}
|
||||
</div>
|
||||
{membersRole && canViewMembers
|
||||
? <RoleMembersView key={membersRole.roleId} role={roles?.find(role => role.roleId === membersRole.roleId) ?? membersRole} canAssign={canAssignMembers}
|
||||
backLabel={effectiveView === 'matrix' ? '返回角色矩阵' : '返回单角色配置'} onBack={() => setMembersRole(null)} onAssigned={() => void load()} />
|
||||
: roles === null && roleLoading ? <div className="empty-note">加载中…</div> : effectiveView === 'matrix' ? matrix() : focus()}
|
||||
{preview && previewPanel(preview)}
|
||||
{canManagePermissions && dirty && <div className="rp-savebar" role="region" aria-label="未保存的权限修改">
|
||||
<span className="rp-savebar-summary">已修改 {dirtyList.length} 个角色、{changeCount} 项设置</span>
|
||||
<button className="ad-mini" disabled={busy || roleLoading} onClick={() => void requestPreview(false)}>查看变化</button>
|
||||
<button className="ad-mini" disabled={busy} onClick={cancelAll}>取消全部</button>
|
||||
{preview?.confirm
|
||||
? <button ref={savePermsRef} className="lg-submit ad-create-btn" disabled={busy || roleLoading || blocked} onClick={() => void confirmSave()}>确认保存</button>
|
||||
: <button ref={savePermsRef} className="ad-mini" disabled={busy || roleLoading || !editable} onClick={() => void requestPreview(true)}>保存</button>}
|
||||
</div>}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import { WorkExecutionRecord } from './WorkExecutionRecord'
|
||||
// 工件解析/打开注入:渲染器纯展示,doc-ref 词汇经此桥接本仓状态(D29)。
|
||||
// 打开语义走 requestPreview 唯一入口(第三栏「文件」入口)。
|
||||
export const docBridge: DocBridge = {
|
||||
onOpenTaskSource: (cardId, source) => store.openTaskSource(cardId, source),
|
||||
resolveDoc: id => store.getState().artifacts.find(a => a.id === id),
|
||||
onOpenDoc: id => {
|
||||
const art = store.getState().artifacts.find(a => a.id === id)
|
||||
|
||||
@@ -11,33 +11,28 @@ import { Btn, Collapsible, Icon, Picker } from '@ui'
|
||||
import {
|
||||
memberAddPayload, memberRemovePayload, memberSetRolePayload,
|
||||
permCheckPayload, describeSource,
|
||||
} from '../rdmManagement'
|
||||
import { dutyConditionNote, linkedCapabilitySummary } from '../permissionExplanations'
|
||||
|
||||
// ---- 项目设置:成员(P7:可搜索人员选择器 + 专业角色/项目身份两列 + 显式身份调整)----
|
||||
|
||||
import {
|
||||
type MemberMatchMode, type PersonPickItem, type RdmAccountPage, type RdmMemberPage, type RdmProfRole,
|
||||
type SearchFieldsChoice,
|
||||
MATCH_MODE_LABELS, SEARCH_FIELDS, accountCandidatePager, accountSearchKey, appendMemberPage, memberListParams,
|
||||
personLabel, roleOptionsPayload,
|
||||
} from '../rdmManagement'
|
||||
|
||||
// ---- 项目设置:成员(P7:可搜索人员选择器 + 专业角色/项目身份两列 + 显式身份调整)----
|
||||
|
||||
interface MemberRow {
|
||||
owner: string; name?: string; role: string; addedBy: string; addedAt: string
|
||||
profRole?: { roleId: string; name: string } | null
|
||||
}
|
||||
|
||||
export function RdmMembersSection({
|
||||
project,
|
||||
initialCanManage,
|
||||
initialRows,
|
||||
}: {
|
||||
project: Project
|
||||
initialCanManage?: boolean
|
||||
initialRows?: MemberRow[]
|
||||
}) {
|
||||
export function RdmMembersSection({ project }: { project: Project }) {
|
||||
const projectId = project.id
|
||||
const writeError = projectWorkError(project)
|
||||
const cmd = <T,>(id: string, params: Record<string, unknown> = {}) => api.rdmProjectCommand<T>(project, id, params)
|
||||
const [canManage, setCanManage] = useState(initialCanManage ?? false)
|
||||
const [rows, setRows] = useState<MemberRow[] | null>(initialRows ?? null)
|
||||
const [rows, setRows] = useState<MemberRow[] | null>(null)
|
||||
const [memberNext, setMemberNext] = useState('')
|
||||
const [memberTotal, setMemberTotal] = useState<number | null>(null)
|
||||
const [directoryOk, setDirectoryOk] = useState(true)
|
||||
@@ -63,23 +58,15 @@ export function RdmMembersSection({
|
||||
const memberGen = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
setCanManage(false)
|
||||
setPicked([]); setPickedItems([]); knownPicks.current.clear()
|
||||
setCandRoleId(''); setCandMatch('fuzzy'); setCandFields('both'); setMemberQuery(''); setMemberRoleId('')
|
||||
setRoleOptions(null)
|
||||
}, [project])
|
||||
|
||||
useEffect(() => {
|
||||
if (!canManage) {
|
||||
setRoleOptions(null)
|
||||
return
|
||||
}
|
||||
let live = true
|
||||
cmd<{ roles?: RdmProfRole[] }>('rdm.role.options', roleOptionsPayload(projectId))
|
||||
.then(out => { if (live) setRoleOptions(out.roles ?? []) })
|
||||
.catch(() => { if (live) setRoleOptions(null) })
|
||||
return () => { live = false }
|
||||
}, [project, canManage])
|
||||
}, [project])
|
||||
|
||||
const candOptions = useMemo(() => ({ mode: candMatch, searchFields: SEARCH_FIELDS[candFields].fields, roleId: candRoleId }),
|
||||
[candMatch, candFields, candRoleId])
|
||||
@@ -107,9 +94,6 @@ export function RdmMembersSection({
|
||||
const out = await cmd<RdmMemberPage>('rdm.project.member.list',
|
||||
memberListParams(projectId, { query: memberQuery, roleId: memberRoleId, cursor }))
|
||||
if (gen !== memberGen.current) return
|
||||
if (!cursor) {
|
||||
setCanManage(Boolean(out.canManage))
|
||||
}
|
||||
setRows(prev => appendMemberPage(cursor ? prev : null, out.members))
|
||||
setMemberNext(out.next || '')
|
||||
setMemberTotal(typeof out.total === 'number' ? out.total : null)
|
||||
@@ -159,48 +143,44 @@ export function RdmMembersSection({
|
||||
return (
|
||||
<div>
|
||||
{writeError && <p className="ad-hint">{writeError}</p>}
|
||||
{canManage ? (
|
||||
<div className="ad-create">
|
||||
<div className="ad-row">
|
||||
{roleFilter(candRoleId, setCandRoleId, '候选专业角色筛选')}
|
||||
<select className="lg-input" aria-label="匹配方式" value={candMatch}
|
||||
onChange={e => setCandMatch(e.target.value as MemberMatchMode)}>
|
||||
{(Object.keys(MATCH_MODE_LABELS) as MemberMatchMode[]).map(m =>
|
||||
<option key={m} value={m}>{MATCH_MODE_LABELS[m]}</option>)}
|
||||
</select>
|
||||
<select className="lg-input" aria-label="检索字段" value={candFields}
|
||||
onChange={e => setCandFields(e.target.value as SearchFieldsChoice)}>
|
||||
{(Object.keys(SEARCH_FIELDS) as SearchFieldsChoice[]).map(f =>
|
||||
<option key={f} value={f}>{SEARCH_FIELDS[f].label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="ad-row">
|
||||
<Picker
|
||||
aria-label="选择人员"
|
||||
selected={picked}
|
||||
onSelect={onPick}
|
||||
selectedItems={pickedItems}
|
||||
fetchPage={fetchPage}
|
||||
reloadKey={accountSearchKey(projectId, candOptions)}
|
||||
disabled={pickerDisabled}
|
||||
placeholder={candMatch === 'fuzzy' ? '输入至少两个字符搜索账号或姓名' : '输入完整账号或姓名精确搜索'}
|
||||
emptyText="输入后显示匹配账号"
|
||||
/>
|
||||
<select className="lg-input" value={role} onChange={e => setRole(e.target.value as 'member' | 'owner')}>
|
||||
<option value="member">项目成员</option>
|
||||
<option value="owner">项目负责人</option>
|
||||
</select>
|
||||
<button className="lg-submit ad-create-btn" disabled={pickerDisabled || !pickedValid}
|
||||
onClick={() => pickedItem && run(`已添加 ${personLabel({ owner: pickedItem.owner, name: pickedItem.title })}`, async () => {
|
||||
await cmd('rdm.project.member.add', memberAddPayload(projectId, pickedItem.owner, role, pickedItem.id))
|
||||
setPicked([]); setPickedItems([])
|
||||
})}>添加成员</button>
|
||||
</div>
|
||||
<p className="ad-hint">候选来自系统账号目录(输入后检索,姓名为空时显示账号);无效账号与已加入项目的账号显示为不可选——调整身份请在成员表中操作。最后一位负责人不可移除/降级。</p>
|
||||
<div className="ad-create">
|
||||
<div className="ad-row">
|
||||
{roleFilter(candRoleId, setCandRoleId, '候选专业角色筛选')}
|
||||
<select className="lg-input" aria-label="匹配方式" value={candMatch}
|
||||
onChange={e => setCandMatch(e.target.value as MemberMatchMode)}>
|
||||
{(Object.keys(MATCH_MODE_LABELS) as MemberMatchMode[]).map(m =>
|
||||
<option key={m} value={m}>{MATCH_MODE_LABELS[m]}</option>)}
|
||||
</select>
|
||||
<select className="lg-input" aria-label="检索字段" value={candFields}
|
||||
onChange={e => setCandFields(e.target.value as SearchFieldsChoice)}>
|
||||
{(Object.keys(SEARCH_FIELDS) as SearchFieldsChoice[]).map(f =>
|
||||
<option key={f} value={f}>{SEARCH_FIELDS[f].label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<p className="ad-hint">仅项目创建者可管理成员;你可以查看成员列表。</p>
|
||||
)}
|
||||
<div className="ad-row">
|
||||
<Picker
|
||||
aria-label="选择人员"
|
||||
selected={picked}
|
||||
onSelect={onPick}
|
||||
selectedItems={pickedItems}
|
||||
fetchPage={fetchPage}
|
||||
reloadKey={accountSearchKey(projectId, candOptions)}
|
||||
disabled={pickerDisabled}
|
||||
placeholder={candMatch === 'fuzzy' ? '输入至少两个字符搜索账号或姓名' : '输入完整账号或姓名精确搜索'}
|
||||
emptyText="输入后显示匹配账号"
|
||||
/>
|
||||
<select className="lg-input" value={role} onChange={e => setRole(e.target.value as 'member' | 'owner')}>
|
||||
<option value="member">项目成员</option>
|
||||
<option value="owner">项目负责人</option>
|
||||
</select>
|
||||
<button className="lg-submit ad-create-btn" disabled={pickerDisabled || !pickedValid}
|
||||
onClick={() => pickedItem && run(`已添加 ${personLabel({ owner: pickedItem.owner, name: pickedItem.title })}`, async () => {
|
||||
await cmd('rdm.project.member.add', memberAddPayload(projectId, pickedItem.owner, role, pickedItem.id))
|
||||
setPicked([]); setPickedItems([])
|
||||
})}>添加成员</button>
|
||||
</div>
|
||||
<p className="ad-hint">候选来自系统账号目录(输入后检索,姓名为空时显示账号);无效账号与已加入项目的账号显示为不可选——调整身份请在成员表中操作。最后一位负责人不可移除/降级。</p>
|
||||
</div>
|
||||
<div className="ad-row">
|
||||
<input className="lg-input" aria-label="搜索成员" placeholder="搜索成员账号或姓名" value={memberQuery}
|
||||
onChange={e => setMemberQuery(e.target.value)} />
|
||||
@@ -209,16 +189,7 @@ export function RdmMembersSection({
|
||||
</div>
|
||||
{!directoryOk && <p className="ad-hint">账号目录暂不可用:成员姓名可能缺失,仅按账号显示。</p>}
|
||||
<table className="ad-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>成员</th>
|
||||
<th>专业角色</th>
|
||||
<th>项目身份</th>
|
||||
<th>添加人</th>
|
||||
<th>添加时间</th>
|
||||
{canManage && <th></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<thead><tr><th>成员</th><th>专业角色</th><th>项目身份</th><th>添加人</th><th>添加时间</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{(rows ?? []).map(m => (
|
||||
<tr key={m.owner}>
|
||||
@@ -227,23 +198,21 @@ export function RdmMembersSection({
|
||||
<td>{m.role === 'owner' ? <span className="ad-chip ad-chip-admin">项目负责人</span> : <span className="ad-chip">项目成员</span>}</td>
|
||||
<td className="ad-dim">{m.addedBy || '—'}</td>
|
||||
<td className="ad-dim">{m.addedAt ? new Date(m.addedAt).toLocaleString() : '—'}</td>
|
||||
{canManage && (
|
||||
<td>
|
||||
<button className="ad-mini" disabled={busy || !!writeError}
|
||||
title={m.role === 'owner' ? '降为项目成员(setRole)' : '升为项目负责人(setRole)'}
|
||||
onClick={() => run(`已调整 ${m.owner} 的项目身份`, () =>
|
||||
cmd('rdm.project.member.set-role', memberSetRolePayload(projectId, m.owner, m.role === 'owner' ? 'member' : 'owner')))}>
|
||||
{m.role === 'owner' ? '降为成员' : '升为负责人'}
|
||||
</button>{' '}
|
||||
<button className="ad-mini" disabled={busy || !!writeError}
|
||||
onClick={() => run(`已移除 ${m.owner}`,
|
||||
() => cmd('rdm.project.member.remove', memberRemovePayload(projectId, m.owner)))}>移除</button>
|
||||
</td>
|
||||
)}
|
||||
<td>
|
||||
<button className="ad-mini" disabled={busy || !!writeError}
|
||||
title={m.role === 'owner' ? '降为项目成员(setRole)' : '升为项目负责人(setRole)'}
|
||||
onClick={() => run(`已调整 ${m.owner} 的项目身份`, () =>
|
||||
cmd('rdm.project.member.set-role', memberSetRolePayload(projectId, m.owner, m.role === 'owner' ? 'member' : 'owner')))}>
|
||||
{m.role === 'owner' ? '降为成员' : '升为负责人'}
|
||||
</button>{' '}
|
||||
<button className="ad-mini" disabled={busy || !!writeError}
|
||||
onClick={() => run(`已移除 ${m.owner}`,
|
||||
() => cmd('rdm.project.member.remove', memberRemovePayload(projectId, m.owner)))}>移除</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows !== null && rows.length === 0 && <tr><td colSpan={canManage ? 6 : 5} className="ad-dim">{filtering ? '没有匹配的成员' : '暂无成员'}</td></tr>}
|
||||
{rows === null && <tr><td colSpan={canManage ? 6 : 5} className="ad-dim">{err ? '成员清单未加载(见下方提示)' : '加载中…'}</td></tr>}
|
||||
{rows !== null && rows.length === 0 && <tr><td colSpan={6} className="ad-dim">{filtering ? '没有匹配的成员' : '暂无成员'}</td></tr>}
|
||||
{rows === null && <tr><td colSpan={6} className="ad-dim">{err ? '成员清单未加载(见下方提示)' : '加载中…'}</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
{memberNext && rows !== null && (
|
||||
@@ -301,7 +270,10 @@ function ActionDetailTable({ actions, onOverride, canManage }: {
|
||||
<td title={`内部标识 ${a.action}`}>{actionTitle(a)}</td>
|
||||
<td>{a.allowed ? <span className="ad-chip ad-chip-admin">允许</span> : <span className="ad-chip ad-chip-warn">拒绝</span>}</td>
|
||||
<td className="ad-dim">{describeSource(a.source)}</td>
|
||||
<td className="ad-dim">{a.reason || '—'}</td>
|
||||
<td className="ad-dim">
|
||||
{a.reason || '—'}
|
||||
{dutyConditionNote(a.action) && <span className="ad-dim ad-cond-note">{dutyConditionNote(a.action)}</span>}
|
||||
</td>
|
||||
{canManage ? (
|
||||
<td>
|
||||
<select className="lg-input" value={overrideOf(a)} style={{ minWidth: 120 }}
|
||||
@@ -392,6 +364,9 @@ export function RdmPermMatrixSection({ project }: { project: Project }) {
|
||||
}
|
||||
|
||||
const canManage = !writeError && !!matrix && matrix.role !== undefined
|
||||
const linked = me?.linkedCapabilities
|
||||
const showLinked = !!linked && !!matrix && matrix.member === me?.owner
|
||||
&& (linked.projectCreate || (linked.actions ?? []).length > 0)
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -417,6 +392,11 @@ export function RdmPermMatrixSection({ project }: { project: Project }) {
|
||||
|
||||
{view === 'member' && matrix && (
|
||||
<>
|
||||
{showLinked && (
|
||||
<Collapsible trigger="创建项目与本人项目管理资格">
|
||||
<p className="ad-hint">{linkedCapabilitySummary(linked)}</p>
|
||||
</Collapsible>
|
||||
)}
|
||||
<div className="ad-row">
|
||||
<span>成员 <b className="ad-mono">{matrix.member}</b> · 项目身份 <b>{matrix.role === 'owner' ? '项目负责人' : matrix.role === 'member' ? '项目成员' : matrix.role}</b>
|
||||
{matrix.profRole && <> · 专业角色 <b>{matrix.profRole.name}</b></>}
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
// 系统管理分区面板(重设计方案 阶段②):普通用户界面不再暴露的系统能力
|
||||
// 全部收进这里,由 AdminZone 以分区注册表呈现。数据入口与原弹窗完全一致
|
||||
//(api.* 不变),只换呈现位置;插件面板/声明工作区经 UIRenderer 内联渲染。
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api, type RecoveryProposal } from '../api'
|
||||
import { store, useStore } from '../store'
|
||||
import type { AgentProfile } from '../contracts'
|
||||
import { Btn, EmptyState, Icon, SettingRow, SettingsGroup, UIRenderer, type DocBridge } from '@ui'
|
||||
import { SettingInput, flatten, unflatten } from './SettingsModal'
|
||||
import type { SettingDesc } from '../contracts'
|
||||
import { docBridge } from './Workbench'
|
||||
import { accountActionPolicy, canAppointAdmins } from '../accountAuthority'
|
||||
import { CreateAdminOption, IdentityChip } from './AccountUsersPanel'
|
||||
import { GeneratedCredentialPanel, oneTimePasswordCredential, type GeneratedCredential } from './oneTimePasswordUi'
|
||||
import {
|
||||
recoveryAttemptKey, recoveryProposalErrorMessage, recoveryProposalHint, recoveryProposalNotice, recoveryProposalRejected,
|
||||
recoveryStatusLabel, type RecoveryAttempt,
|
||||
} from '../recovery'
|
||||
|
||||
// ---- 用户管理:页面建号可设置/生成初始密码;忘记密码的唯一处置是
|
||||
// 管理员生成一次性登录密码,用户登录后必须立即改密。 ----
|
||||
interface AdminUserRow { userId: string; owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number }
|
||||
// ---- 用户管理:页面建号可设置/生成初始密码,管理员重置生成临时密码;
|
||||
// 保留恢复提案与 Key 管理作为既有非登录页恢复流程。 ----
|
||||
interface AdminUserRow { userId: string; owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number }
|
||||
|
||||
export function UsersPanel() {
|
||||
const me = useStore(s => s.me)
|
||||
@@ -25,37 +26,112 @@ export function UsersPanel() {
|
||||
const [owner, setOwner] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [initialPassword, setInitialPassword] = useState('')
|
||||
const [createAsAdmin, setCreateAsAdmin] = useState(false)
|
||||
const [generatedCredential, setGeneratedCredential] = useState<GeneratedCredential | null>(null)
|
||||
const [generatedCredential, setGeneratedCredential] = useState<{ owner: string; password: string; kind: 'initial' | 'temporary' } | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
// 恢复提案面板:propFor 打开指定用户的提案视图(创建 / 状态 / 撤销)。
|
||||
const [propFor, setPropFor] = useState<AdminUserRow | null>(null)
|
||||
const [proposals, setProposals] = useState<RecoveryProposal[] | null>(null)
|
||||
|
||||
// 提案视图代次:切换/关闭目标推进代次,迟到响应不得覆盖新选中的账号。
|
||||
const proposalSeq = useRef(0)
|
||||
// 结果未确定的提案请求:显式重试沿用同一 idempotencyKey,不重复创建。
|
||||
const recoveryAttempt = useRef<RecoveryAttempt | null>(null)
|
||||
|
||||
const refresh = async () => {
|
||||
try { setUsers(await api.listAdminUsers()) } catch (e) { setErr(String(e)) }
|
||||
}
|
||||
const refreshProposals = async (userId: string) => {
|
||||
const seq = proposalSeq.current
|
||||
try {
|
||||
const statuses = await api.listAdminRecoveryProposals(userId)
|
||||
if (seq === proposalSeq.current) setProposals(statuses)
|
||||
} catch (e) { if (seq === proposalSeq.current) setErr(String(e)) }
|
||||
}
|
||||
useEffect(() => { refresh() }, [])
|
||||
|
||||
const create = async () => {
|
||||
setErr(''); setNotice('')
|
||||
setBusy(true)
|
||||
try {
|
||||
const out = await api.createAdminUser(owner.trim(), name.trim(), initialPassword.trim() || undefined, canAppointAdmins(me) && createAsAdmin)
|
||||
const out = await api.createAdminUser(owner.trim(), name.trim(), initialPassword.trim() || undefined)
|
||||
setGeneratedCredential(out.initialPassword ? { owner: out.owner, password: out.initialPassword, kind: 'initial' } : null)
|
||||
setNotice(out.initialPassword ? `已创建 ${out.owner},请将下方一次性初始密码交付给用户。首次登录后必须改密。` : `已创建 ${out.owner}。用户首次登录后必须改密。`)
|
||||
setOwner(''); setName(''); setInitialPassword(''); setCreateAsAdmin(false)
|
||||
setOwner(''); setName(''); setInitialPassword('')
|
||||
await refresh()
|
||||
} catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const issueOneTimePassword = async (u: AdminUserRow) => {
|
||||
const resetPassword = async (u: AdminUserRow) => {
|
||||
setErr(''); setNotice(''); setGeneratedCredential(null); setBusy(true)
|
||||
try {
|
||||
const out = await api.adminIssueOneTimePassword(u.owner)
|
||||
setGeneratedCredential(oneTimePasswordCredential(out))
|
||||
setNotice(`已为 ${u.owner} 生成一次性登录密码。用户使用后必须立即设置新密码。`)
|
||||
const out = await api.adminResetUserPassword(u.owner)
|
||||
setGeneratedCredential({ owner: u.owner, password: out.temporaryPassword, kind: 'temporary' })
|
||||
setNotice(`已为 ${u.owner} 生成临时密码。该用户登录后必须立即修改密码。`)
|
||||
await refresh()
|
||||
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const openProposals = async (u: AdminUserRow) => {
|
||||
proposalSeq.current++
|
||||
setErr(''); setNotice(''); setPropFor(u); setProposals(null)
|
||||
await refreshProposals(u.userId)
|
||||
}
|
||||
const closeProposals = () => { proposalSeq.current++; setPropFor(null); setProposals(null) }
|
||||
|
||||
const propose = async () => {
|
||||
if (!propFor) return
|
||||
const target = propFor
|
||||
const seq = proposalSeq.current
|
||||
const stillSelected = () => seq === proposalSeq.current
|
||||
setErr(''); setNotice(''); setBusy(true)
|
||||
const idempotencyKey = recoveryAttemptKey(recoveryAttempt.current, target.userId, target.stateVersion, () => crypto.randomUUID())
|
||||
recoveryAttempt.current = { userId: target.userId, stateVersion: target.stateVersion, key: idempotencyKey }
|
||||
try {
|
||||
const created = await api.createAdminRecoveryProposal(target.userId, target.stateVersion, idempotencyKey)
|
||||
recoveryAttempt.current = null
|
||||
const statuses = await api.listAdminRecoveryProposals(target.userId).catch(() => null)
|
||||
if (!stillSelected()) return
|
||||
if (statuses) setProposals(statuses)
|
||||
setNotice(recoveryProposalNotice(target.owner, (statuses?.find(p => p.id === created.id) ?? created).status))
|
||||
} catch (e) {
|
||||
const message = String(e)
|
||||
if (message.includes('409')) {
|
||||
recoveryAttempt.current = null
|
||||
await resetPassword(target)
|
||||
return
|
||||
}
|
||||
if (!stillSelected()) return
|
||||
if (recoveryProposalRejected(message)) {
|
||||
recoveryAttempt.current = null
|
||||
setErr(recoveryProposalErrorMessage(message))
|
||||
return
|
||||
}
|
||||
// 响应丢失或服务端异常时提案可能已提交:先重读元数据,再允许沿用同一 key 显式重试。
|
||||
try {
|
||||
const statuses = await api.listAdminRecoveryProposals(target.userId)
|
||||
if (!stillSelected()) return
|
||||
setProposals(statuses)
|
||||
if (statuses.length > 0) {
|
||||
setNotice(`提案响应未收到;状态已刷新。最近记录:${recoveryStatusLabel[statuses[0].status]}。请核对下方记录;再次提交会沿用同一请求标识。`)
|
||||
} else {
|
||||
setErr('提案结果未确定;状态已刷新。再次提交会沿用同一请求标识。')
|
||||
}
|
||||
} catch {
|
||||
if (stillSelected()) setErr('提案结果未知且状态暂时无法读取;请稍后核对,不会自动重复创建。')
|
||||
}
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const revokeProposal = async (p: RecoveryProposal) => {
|
||||
if (!propFor) return
|
||||
setErr(''); setNotice(''); setBusy(true)
|
||||
try {
|
||||
await api.adminRevokeRecoveryProposal(propFor.userId, p.id, p.version)
|
||||
await refreshProposals(propFor.userId)
|
||||
setNotice('提案已撤销')
|
||||
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{me?.isAdmin && (
|
||||
@@ -66,34 +142,65 @@ export function UsersPanel() {
|
||||
<input className="lg-input" type="password" autoComplete="new-password" placeholder="初始密码(留空随机生成)" value={initialPassword} onChange={e => setInitialPassword(e.target.value)} />
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !owner.trim()} onClick={create}>创建账号</button>
|
||||
</div>
|
||||
<CreateAdminOption me={me} checked={createAsAdmin} disabled={busy} onChange={setCreateAsAdmin} />
|
||||
<p className="ad-hint">可填写管理员指定的初始密码;留空则由服务端生成一次性随机密码。用户首次登录后必须改密。</p>
|
||||
</div>
|
||||
)}
|
||||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{generatedCredential && <GeneratedCredentialPanel credential={generatedCredential} onClose={() => setGeneratedCredential(null)} />}
|
||||
{generatedCredential && <div className="ad-reset">
|
||||
<p className="ad-hint">{generatedCredential.kind === 'initial' ? '初始密码' : '临时密码'}仅显示一次,请立即安全交付给 {generatedCredential.owner}。</p>
|
||||
<input className="lg-input ad-mono" readOnly value={generatedCredential.password} aria-label="一次性密码" />
|
||||
<button type="button" className="ad-mini" onClick={() => setGeneratedCredential(null)}>关闭</button>
|
||||
</div>}
|
||||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||
{me?.isAdmin && <p className="ad-hint">用户忘记密码时,点击该用户行的「生成一次性登录密码」:用户登录后必须立即设置新密码。</p>}
|
||||
<table className="ad-table">
|
||||
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>密码设置于</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{(users ?? []).map(u => {
|
||||
const policy = accountActionPolicy(me, u)
|
||||
return <tr key={u.owner}>
|
||||
<td className="ad-user-cell"><span className="ad-mono">{u.owner}</span></td>
|
||||
<td>{u.name || '—'}</td>
|
||||
<td>{u.isSuperAdmin ? <IdentityChip account={u} /> : u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
|
||||
<td>{u.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
|
||||
<td className="ad-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
|
||||
<td>{me?.isAdmin && (policy.resetPassword
|
||||
? <button className="ad-mini" disabled={busy} onClick={() => issueOneTimePassword(u)}>生成一次性登录密码</button>
|
||||
: <span className="ad-dim">{policy.protectedReason || '受保护账号不可生成一次性登录密码'}</span>
|
||||
)}</td>
|
||||
</tr>
|
||||
})}
|
||||
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
{propFor ? (
|
||||
<div className="ad-reset">
|
||||
<div className="ad-row">
|
||||
<span><b>{propFor.owner}</b> 的恢复提案(属主确认制:管理员提案,本人确认后获一次性 Key)</span>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy} onClick={propose}>发起恢复提案</button>
|
||||
<button type="button" className="ad-mini" onClick={closeProposals}>关闭</button>
|
||||
</div>
|
||||
<p className="ad-hint">前提:该用户当前在任一设备保持完整密码登录(会话证明)。已锁死且无会话的账号无法在线恢复,请走服务器本地处置。</p>
|
||||
{proposals !== null && proposals.length > 0 && (
|
||||
<table className="ad-table">
|
||||
<thead><tr><th>提案</th><th>状态</th><th>到期时间</th><th>发起人</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{proposals.map(p => (
|
||||
<tr key={p.id}>
|
||||
<td className="ad-mono">{p.id}</td>
|
||||
<td>{recoveryStatusLabel[p.status]}{recoveryProposalHint(p.status) && <span className="ad-dim"> · {recoveryProposalHint(p.status)}</span>}</td>
|
||||
<td className="ad-dim">{new Date(p.expiresAt).toLocaleString()}</td>
|
||||
<td className="ad-dim">{p.createdBy}</td>
|
||||
<td>{(p.status === 'pending' || p.status === 'confirmed') && (
|
||||
<button className="ad-mini" disabled={busy} onClick={() => revokeProposal(p)}>撤销</button>
|
||||
)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{proposals !== null && proposals.length === 0 && <p className="ad-hint">该用户当前没有恢复提案。</p>}
|
||||
</div>
|
||||
) : (
|
||||
<table className="ad-table">
|
||||
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>密码设置于</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{(users ?? []).map(u => (
|
||||
<tr key={u.owner}>
|
||||
<td className="ad-user-cell"><span className="ad-mono">{u.owner}</span></td>
|
||||
<td>{u.name || '—'}</td>
|
||||
<td>{u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
|
||||
<td>{u.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
|
||||
<td className="ad-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
|
||||
<td>{me?.isAdmin && <span className="ad-row">
|
||||
<button className="ad-mini" disabled={busy} onClick={() => resetPassword(u)}>重置临时密码</button>
|
||||
<button className="ad-mini" onClick={() => openProposals(u)}>恢复提案</button>
|
||||
</span>}</td>
|
||||
</tr>
|
||||
))}
|
||||
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
/** 登录错误文案:把 HTTP 状态码翻成人话。 */
|
||||
export function humanLoginError(raw: string): string {
|
||||
if (raw.includes('401')) return '用户名或密码不正确'
|
||||
if (raw.includes('429')) return '失败次数过多,请稍后再试'
|
||||
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
|
||||
return raw.replace(/^\d{3}:\s*/, '')
|
||||
}
|
||||
|
||||
/** 改密门错误文案:改密的 401 有多种真实原因(会话过期/并发失效/退避),
|
||||
* 不能沿用登录的「用户名或密码不正确」一刀切。
|
||||
* 后端文案是现行 HTTP 契约的一部分,这里按其区分并用测试锁定。 */
|
||||
export function humanChangePasswordError(raw: string): string {
|
||||
if (raw.includes('current password incorrect')) return '当前密码不正确'
|
||||
if (raw.includes('session login required')) return '请先用账号密码登录再改密(API 密钥不能修改密码)'
|
||||
if (raw.includes('session expired')) return '会话已过期,请重新登录'
|
||||
if (raw.includes('credentials changed concurrently')) return '登录状态已变化(密码在其他地方被更新),请重新登录'
|
||||
if (raw.includes('invalid or revoked API key')) return '本地残留的 API 密钥已失效,请退出登录后重试'
|
||||
if (raw.includes('429')) return '失败次数过多,请稍后再试'
|
||||
if (raw.includes('401')) return '认证失败,请重新登录'
|
||||
return raw.replace(/^\d{3}:\s*/, '')
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { AdminIssuedOneTimePassword } from '../api'
|
||||
|
||||
export interface GeneratedCredential {
|
||||
owner: string
|
||||
password: string
|
||||
kind: 'initial' | 'one-time'
|
||||
}
|
||||
|
||||
export function oneTimePasswordCredential(out: AdminIssuedOneTimePassword): GeneratedCredential {
|
||||
return { owner: out.owner, password: out.oneTimePassword, kind: 'one-time' }
|
||||
}
|
||||
|
||||
export function GeneratedCredentialPanel({ credential, onClose }: { credential: GeneratedCredential; onClose: () => void }) {
|
||||
return <div className="ad-reset">
|
||||
<p className="ad-hint">{credential.kind === 'initial'
|
||||
? `初始密码仅显示一次,请立即安全交付给 ${credential.owner}。`
|
||||
: `一次性登录密码仅显示一次,请立即安全交付给 ${credential.owner};使用后必须强制改密。`}</p>
|
||||
<input className="lg-input ad-mono" readOnly value={credential.password} aria-label="一次性密码" />
|
||||
<button type="button" className="ad-mini" onClick={onClose}>关闭</button>
|
||||
</div>
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { api, type PasswordChangeMode } from '../api'
|
||||
|
||||
export function requiresCurrentPassword(mode: PasswordChangeMode | null | undefined): boolean {
|
||||
return mode === 'current_required'
|
||||
}
|
||||
|
||||
export function isForcedPasswordMode(mode: PasswordChangeMode | null | undefined): mode is PasswordChangeMode {
|
||||
return mode === 'grant_setup' || mode === 'current_required' || mode === 'one_time_password'
|
||||
}
|
||||
|
||||
export function ForcedPasswordModeSection({ mode, current, onCurrentChange }: { mode: PasswordChangeMode | null; current: string; onCurrentChange: (value: string) => void }) {
|
||||
if (mode === 'current_required') {
|
||||
return <>
|
||||
<label className="lg-label" htmlFor="forced-current">当前密码</label>
|
||||
<input id="forced-current" className="lg-input" type="password" value={current}
|
||||
onChange={e => onCurrentChange(e.target.value)} autoComplete="current-password" />
|
||||
</>
|
||||
}
|
||||
if (mode === 'grant_setup' || mode === 'one_time_password') {
|
||||
return <p className="lg-hint">当前为受限设密会话,此次无需输入当前密码。</p>
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function submitForcedPassword(mode: PasswordChangeMode, current: string, next: string) {
|
||||
await api.changePassword(requiresCurrentPassword(mode) ? current : '', next)
|
||||
}
|
||||
+1
-27
@@ -150,7 +150,6 @@ export interface ActivityCard {
|
||||
id: ID; projectId: ID; conversationId?: ID;
|
||||
sourceRunId?: ID; anchorMessageId?: ID;
|
||||
kind: CardKind; title: string; status: CardStatus; tone?: string;
|
||||
task?: TaskSnapshot;
|
||||
body?: UINode[]; actions?: { id: string; label: string; style?: 'primary' | 'default' | 'danger'; command?: string }[];
|
||||
pinned?: boolean; createdAt: string; updatedAt: string; work?: WorkCard;
|
||||
}
|
||||
@@ -352,7 +351,7 @@ export interface PluginPageDecl {
|
||||
|
||||
export interface ParsedDocument {
|
||||
meta: {
|
||||
filename: string; mime?: string; parser: string; confidence?: number; origin?: DocumentOrigin;
|
||||
filename: string; mime?: string; parser: string; confidence?: number;
|
||||
crossCheck?: { with: string; diffBlocks: number; verdict: 'agree' | 'differ' };
|
||||
parsedAt: string;
|
||||
};
|
||||
@@ -415,28 +414,3 @@ export interface SurfaceMeta {
|
||||
conversationId?: ID;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Persistent work projection; host-owned identities and revisions.
|
||||
export interface DocumentOrigin { artifactId: ID; contentHash: string; parserRevision: string }
|
||||
export interface TaskPhase { id: string; label: string; observedAt?: string }
|
||||
export interface TaskSourceRef { id: string; artifactId: ID; contentHash: string; parserRevision: string; blockIndex: number; label: string; quote?: string; availability?: string }
|
||||
export interface TaskResultRef { artifactId?: ID; surfaceId?: ID; version?: string; contentHash?: string; availability: string; name?: string }
|
||||
export interface TaskFinding { id: string; text: string; impact?: string; kind: string; status: string; revision: number; sources: TaskSourceRef[]; assumptions?: string; corrects?: string }
|
||||
export interface TaskSnapshot {
|
||||
inputs?: TaskSourceRef[]
|
||||
limitations?: string[]
|
||||
schemaVersion: number; taskId: ID; attemptId: ID; cardId: ID; projectId: ID; conversationId: ID;
|
||||
sourceMessageId?: ID; parentRunId?: ID; runId?: ID; ownerInstanceId: string; executionEpoch?: number;
|
||||
revision: number; sourceSeq: number; streamScope: string; inputRevision?: string; title: string; goal?: string;
|
||||
executionState: 'queued'|'running'|'awaiting_user'|'completed'|'failed'|'interrupted'; outcomeCompleteness: 'none'|'partial'|'complete'; deliveryState: 'pending'|'available'|'reported'; phase: TaskPhase;
|
||||
focus?: string; coverage?: string; remaining?: string; nextStep?: string; conclusion?: string;
|
||||
findings?: TaskFinding[]; actions?: UIAction[]; resultRefs?: TaskResultRef[]; lastActivityAt?: string; lastMeaningfulUpdateAt?: string;
|
||||
checkpointKeys?: Record<string,string>; checkpointCount?: number; checkpointSeq?: number;
|
||||
}
|
||||
export interface TaskSourceQuote { sourceId: string; blockIndex: number; quote: string }
|
||||
interface WorkEvidencePremise { role: "total" | "phase" | "dependent" | "direct_relation"; sourceIndex: number; value?: number }
|
||||
interface WorkFindingEvidence { mode: "direct_relation" | "difference" | "unresolved_relation"; premises: WorkEvidencePremise[]; result?: number }
|
||||
export interface WorkFindingCandidate { findingKey: string; text: string; impact?: string; kind: string; assumptions?: string; corrects?: string; sources: TaskSourceQuote[]; evidence?: WorkFindingEvidence }
|
||||
export interface WorkCheckpointCandidate { checkpointId: string; phaseId: string; focus?: string; remaining?: string; nextStep?: string; findings: WorkFindingCandidate[] }
|
||||
|
||||
export interface UIAction { id: string; label: string; style?: string; command?: string }
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// 项目有效权限说明的纯文案层:只消费服务端字段(/api/me linkedCapabilities)与固定权限
|
||||
// 语义,不按角色/成员关系推算任何有效权限。未知动作 id / 来源回退原文。
|
||||
import type { LinkedCapabilities } from './api'
|
||||
|
||||
/** 联动动作中文名(与后端权限 catalog 一致)。 */
|
||||
export const LINKED_ACTION_LABELS: Record<string, string> = {
|
||||
'project.settings': '管理项目设置',
|
||||
'project.members.manage': '管理项目成员',
|
||||
'project.permissions.manage': '管理项目权限',
|
||||
'document.duties.assign': '分配文档职责',
|
||||
}
|
||||
|
||||
const PROJECT_CREATE_SOURCE_LABELS: Record<string, string> = {
|
||||
grant: '管理员授予',
|
||||
'admin-grant': '管理员授予',
|
||||
direct: '管理员授予',
|
||||
role: '角色',
|
||||
admin: '系统管理员',
|
||||
}
|
||||
|
||||
const actionLabels = (ids: string[]) => ids.map(id => LINKED_ACTION_LABELS[id] ?? id).join('、')
|
||||
|
||||
/** 创建项目与本人项目管理资格说明;字段缺失(旧服务)返回空串。 */
|
||||
export function linkedCapabilitySummary(linked: LinkedCapabilities | undefined): string {
|
||||
if (!linked) return ''
|
||||
if (!linked.projectCreate) return '未具备创建项目资格'
|
||||
const source = linked.source ? `(来源:${PROJECT_CREATE_SOURCE_LABELS[linked.source] ?? linked.source})` : ''
|
||||
const parts = [`已具备创建项目资格${source}`]
|
||||
const actions = linked.actions ?? []
|
||||
if (actions.length > 0) {
|
||||
parts.push(`创建本人项目后自动具备:${actionLabels(actions)}`)
|
||||
parts.push('这些资格仅对本人创建的项目生效,其他项目按项目授权判断')
|
||||
}
|
||||
const conflicts = linked.conflicts ?? []
|
||||
if (conflicts.length > 0) parts.push(`存在冲突:${actionLabels(conflicts)} 被明确禁止`)
|
||||
return parts.join(';')
|
||||
}
|
||||
|
||||
const DUTY_CONDITION_NOTES: Record<string, string> = {
|
||||
'document.author': '仍需被分配该文档的编制职责,且文档状态符合要求',
|
||||
'document.review': '仍需被分配该文档的审核职责,且文档状态符合要求',
|
||||
'document.approve': '仍需被分配该文档的批准职责,且文档状态符合要求',
|
||||
}
|
||||
|
||||
/** 文档编制/审核/批准动作的固定条件说明(权限语义,非对具体文档的判定)。 */
|
||||
export function dutyConditionNote(actionId: string): string {
|
||||
return DUTY_CONDITION_NOTES[actionId] ?? ''
|
||||
}
|
||||
+1
-2
@@ -3,14 +3,13 @@
|
||||
export interface PreviewTab {
|
||||
id: string
|
||||
name: string
|
||||
sourceContext?: {notice:string;quote:string;expectedHash?:string}
|
||||
}
|
||||
|
||||
export function openPreviewTab(tabs: PreviewTab[], tab: PreviewTab): { tabs: PreviewTab[]; activeId: string } {
|
||||
const i = tabs.findIndex(t => t.id === tab.id)
|
||||
if (i >= 0) {
|
||||
const next = tabs.slice()
|
||||
next[i] = { ...next[i], name: tab.name || next[i].name,sourceContext:tab.sourceContext }
|
||||
next[i] = { ...next[i], name: tab.name || next[i].name }
|
||||
return { tabs: next, activeId: tab.id }
|
||||
}
|
||||
return { tabs: [...tabs, tab], activeId: tab.id }
|
||||
|
||||
@@ -212,7 +212,6 @@ export interface RdmMemberRow {
|
||||
}
|
||||
export interface RdmMemberPage {
|
||||
members: RdmMemberRow[]; next?: string; total?: number; accountDirectoryAvailable?: boolean
|
||||
canManage?: boolean
|
||||
}
|
||||
/** 与 @ui PickItem 结构兼容(纯逻辑层不依赖组件库);id 为稳定 userId,owner 为提交用账号。 */
|
||||
export interface PersonPickItem {
|
||||
@@ -354,6 +353,8 @@ export function describeSource(source: string): string {
|
||||
case 'override': return '项目内覆盖'
|
||||
case 'relation-default': return '项目关系默认'
|
||||
case 'non-member': return '非项目成员'
|
||||
case 'creator': return '本人项目创建者自动具备(仅本人创建的项目)'
|
||||
case 'role': return '职位角色'
|
||||
default: return source
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
recoveryAttemptKey, recoveryProposalErrorMessage, recoveryProposalHint, recoveryProposalNotice, recoveryProposalRejected, recoveryStatusLabel,
|
||||
} from './recovery'
|
||||
import { humanChangePasswordError, humanRedeemError, requiresCurrentPassword } from './components/LoginPage'
|
||||
|
||||
// 账户链路文案与模式判定(2026-10-14 current_required 加固后前后端契约对齐的
|
||||
// 防漂移锁):改密门必须区分 grant_setup / current_required,恢复提案 409 的
|
||||
// 两种语义必须分开表达。
|
||||
|
||||
describe('requiresCurrentPassword', () => {
|
||||
it('current_required 必须要当前密码,grant_setup 免当前密码', () => {
|
||||
expect(requiresCurrentPassword('current_required')).toBe(true)
|
||||
expect(requiresCurrentPassword('grant_setup')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('humanChangePasswordError', () => {
|
||||
it('后端 current password incorrect 映射为当前密码不正确', () => {
|
||||
expect(humanChangePasswordError('401: current password incorrect')).toBe('当前密码不正确')
|
||||
})
|
||||
})
|
||||
|
||||
describe('humanRedeemError', () => {
|
||||
it('401/409/429 语义分离', () => {
|
||||
expect(humanRedeemError('409: login key already used')).toContain('已被使用')
|
||||
expect(humanRedeemError('401: invalid or expired login key')).toContain('无效或已过期')
|
||||
expect(humanRedeemError('429: too many failed attempts')).toContain('稍后再试')
|
||||
})
|
||||
})
|
||||
|
||||
describe('recoveryStatusLabel', () => {
|
||||
it('覆盖全部五种提案状态', () => {
|
||||
for (const status of ['pending', 'confirmed', 'completed', 'revoked', 'expired'] as const) {
|
||||
expect(recoveryStatusLabel[status]).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('recoveryProposalHint', () => {
|
||||
it('pending/confirmed 给出下一步,终态不给', () => {
|
||||
expect(recoveryProposalHint('pending')).toContain('本人确认')
|
||||
expect(recoveryProposalHint('confirmed')).toContain('新密码')
|
||||
expect(recoveryProposalHint('completed')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('recoveryProposalNotice', () => {
|
||||
it('按服务端当前状态给出结果:待确认、已确认与终态(重复响应只回状态)', () => {
|
||||
expect(recoveryProposalNotice('alice', 'pending')).toContain('本人确认')
|
||||
expect(recoveryProposalNotice('alice', 'pending')).toContain('管理员不会收到设密能力')
|
||||
expect(recoveryProposalNotice('alice', 'confirmed')).toContain('已由本人确认')
|
||||
expect(recoveryProposalNotice('alice', 'confirmed')).toContain('管理员不会收到设密能力')
|
||||
for (const status of ['completed', 'revoked', 'expired'] as const) {
|
||||
const notice = recoveryProposalNotice('alice', status)
|
||||
expect(notice).toContain(recoveryStatusLabel[status])
|
||||
expect(notice).toContain('核对下方记录')
|
||||
expect(notice).not.toContain('请该用户')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('recoveryAttemptKey', () => {
|
||||
const fresh = () => 'new-key'
|
||||
it('同一账号同一状态版本的重试沿用未确定结果的 key', () => {
|
||||
expect(recoveryAttemptKey({ userId: 'usr_1', stateVersion: 3, key: 'old-key' }, 'usr_1', 3, fresh)).toBe('old-key')
|
||||
})
|
||||
it('无未确定请求、换账号或状态版本变化时生成新 key', () => {
|
||||
expect(recoveryAttemptKey(null, 'usr_1', 3, fresh)).toBe('new-key')
|
||||
expect(recoveryAttemptKey({ userId: 'usr_2', stateVersion: 3, key: 'old-key' }, 'usr_1', 3, fresh)).toBe('new-key')
|
||||
expect(recoveryAttemptKey({ userId: 'usr_1', stateVersion: 2, key: 'old-key' }, 'usr_1', 3, fresh)).toBe('new-key')
|
||||
})
|
||||
})
|
||||
|
||||
describe('recoveryProposalRejected', () => {
|
||||
it('4xx 为明确拒绝;网络失败与 5xx 结果未确定,需要先重读状态', () => {
|
||||
expect(recoveryProposalRejected('Error: 403: admin required')).toBe(true)
|
||||
expect(recoveryProposalRejected('Error: 404: not found')).toBe(true)
|
||||
expect(recoveryProposalRejected('Error: 502: bad gateway')).toBe(false)
|
||||
expect(recoveryProposalRejected('TypeError: Failed to fetch')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('recoveryProposalErrorMessage', () => {
|
||||
it('OWNER_PROOF_UNAVAILABLE 与活动提案冲突分开表达', () => {
|
||||
expect(recoveryProposalErrorMessage('409: OWNER_PROOF_UNAVAILABLE')).toContain('会话证明')
|
||||
expect(recoveryProposalErrorMessage('409: store: conflict')).toContain('已有进行中的恢复提案')
|
||||
})
|
||||
it('未识别错误剥状态码原样透出', () => {
|
||||
expect(recoveryProposalErrorMessage('500: boom')).toBe('boom')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
// 恢复提案展示与错误文案(纯函数):管理端 UsersPanel 与本人 MyAccountSettings
|
||||
// 共用,vitest 锁定语义防漂移。后端 409 两种含义按响应体区分:
|
||||
// OWNER_PROOF_UNAVAILABLE=无合格会话证明;其余 conflict=已有活动提案。
|
||||
import type { RecoveryProposal } from './api'
|
||||
|
||||
export const recoveryStatusLabel: Record<RecoveryProposal['status'], string> = {
|
||||
pending: '待本人确认',
|
||||
confirmed: '已确认,待兑换设密',
|
||||
completed: '已完成',
|
||||
revoked: '已撤销',
|
||||
expired: '已过期',
|
||||
}
|
||||
|
||||
/** 管理端视角的下一步指引:提案成立后动作在属主侧(设置 → 账号)。 */
|
||||
export function recoveryProposalHint(status: RecoveryProposal['status']): string {
|
||||
if (status === 'pending') return '等待该用户在「设置 → 账号」由本人确认;确认后一次性 Key 只在其会话内出现'
|
||||
if (status === 'confirmed') return '该用户已确认;请提醒其尽快在受限会话中完成新密码设置,到期未用需重新生成'
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 建立提案后的结果提示:以服务端返回或重读的当前状态为准。同一 idempotencyKey
|
||||
* 的重复响应只回状态(可能已是终态),管理员任何时候都拿不到设密能力。 */
|
||||
export function recoveryProposalNotice(owner: string, status: RecoveryProposal['status']): string {
|
||||
if (status === 'pending') return `已为 ${owner} 建立恢复提案:请该用户在「设置 → 账号」由本人确认;确认后一次性 Key 只在其会话内出现,管理员不会收到设密能力`
|
||||
if (status === 'confirmed') return `${owner} 的恢复提案已由本人确认。只有目标本人可以取得设密能力;管理员不会收到设密能力`
|
||||
return `${owner} 的恢复提案${recoveryStatusLabel[status]}。如需再次恢复,请核对下方记录`
|
||||
}
|
||||
|
||||
export interface RecoveryAttempt { userId: string; stateVersion: number; key: string }
|
||||
|
||||
/** 提案请求标识:同一账号、同一状态版本的显式重试沿用上次未确定结果的 key,
|
||||
* 让响应丢失后的重试落到同一提案而不是重复创建;账号或状态版本变化才换新 key。 */
|
||||
export function recoveryAttemptKey(previous: RecoveryAttempt | null, userId: string, stateVersion: number, fresh: () => string): string {
|
||||
return previous?.userId === userId && previous.stateVersion === stateVersion ? previous.key : fresh()
|
||||
}
|
||||
|
||||
/** 4xx 是服务端明确拒绝(提案未创建);网络失败或 5xx 时提案可能已提交,结果未确定。 */
|
||||
export function recoveryProposalRejected(raw: string): boolean {
|
||||
return /\b4\d\d:/.test(raw)
|
||||
}
|
||||
|
||||
export function recoveryProposalErrorMessage(raw: string): string {
|
||||
if (raw.includes('OWNER_PROOF_UNAVAILABLE')) {
|
||||
return '无法发起:该账号没有可用的合格会话证明(需该用户当前在任一设备保持完整密码登录)。历史来源或已锁死账号请走服务器本地处置'
|
||||
}
|
||||
if (raw.includes('409')) return '该用户已有进行中的恢复提案;请先撤销旧提案或等它完成/过期'
|
||||
if (raw.includes('403')) return '需要管理员身份'
|
||||
if (raw.includes('404')) return '用户不存在或已被移除'
|
||||
return raw.replace(/^\d{3}:\s*/, '')
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import type {
|
||||
AccountRoleSummary, PermissionDescriptor, RoleCopyAssignRequest, RolePermissionChangeInput, RoleRecord,
|
||||
UserRoleAssignmentInput, UserRoleAssignmentPreview,
|
||||
} from './api'
|
||||
import type { RolePermissionValue } from './rolePermissions'
|
||||
|
||||
/** 显式选择集合:只含逐个或整页勾选过的 userId;筛选与翻页既不清空也不扩大它。 */
|
||||
export interface MemberSelection { readonly ids: readonly string[] }
|
||||
|
||||
export const createSelection = (ids: readonly string[] = []): MemberSelection => ({ ids: [...new Set(ids)] })
|
||||
|
||||
export const clearSelection = (): MemberSelection => createSelection()
|
||||
|
||||
export const isSelected = (selection: MemberSelection, userId: string) => selection.ids.includes(userId)
|
||||
|
||||
export function toggleOne(selection: MemberSelection, userId: string, checked = !isSelected(selection, userId)): MemberSelection {
|
||||
if (checked) return isSelected(selection, userId) ? selection : { ids: [...selection.ids, userId] }
|
||||
return isSelected(selection, userId) ? { ids: selection.ids.filter(id => id !== userId) } : selection
|
||||
}
|
||||
|
||||
/** 表头全选只作用于当前页可选行;其他页已选的人保持不变。 */
|
||||
export function selectPage(selection: MemberSelection, pageIds: readonly string[], checked: boolean): MemberSelection {
|
||||
if (checked) return createSelection([...selection.ids, ...pageIds])
|
||||
const page = new Set(pageIds)
|
||||
return { ids: selection.ids.filter(id => !page.has(id)) }
|
||||
}
|
||||
|
||||
export function pageSelectionState(selection: MemberSelection, pageIds: readonly string[]): 'none' | 'some' | 'all' {
|
||||
const picked = pageIds.filter(id => isSelected(selection, id)).length
|
||||
if (!pageIds.length || picked === 0) return 'none'
|
||||
return picked === pageIds.length ? 'all' : 'some'
|
||||
}
|
||||
|
||||
export function selectionCounts(selection: MemberSelection, pageIds: readonly string[]): { count: number; beyondPage: number } {
|
||||
const page = new Set(pageIds)
|
||||
return { count: selection.ids.length, beyondPage: selection.ids.filter(id => !page.has(id)).length }
|
||||
}
|
||||
|
||||
export function selectionSummary(counts: { count: number; beyondPage: number }): string {
|
||||
return counts.beyondPage > 0 ? `已选 ${counts.count} 人(包含其他页的 ${counts.beyondPage} 人)` : `已选 ${counts.count} 人`
|
||||
}
|
||||
|
||||
type NamedAccount = { userId: string; owner: string; name?: string; profile?: { name: string } }
|
||||
|
||||
const rawName = (account: NamedAccount) => (account.profile?.name ?? account.name ?? '').trim()
|
||||
|
||||
/** 姓名缺失时以账号(登录名)展示;它只是显示文本,提交永远用 userId。 */
|
||||
export const accountName = (account: NamedAccount) => rawName(account) || account.owner
|
||||
|
||||
export const accountLabel = (account: NamedAccount) => rawName(account) ? `${rawName(account)}(${account.owner})` : account.owner
|
||||
|
||||
export function duplicateNames(accounts: readonly NamedAccount[]): Set<string> {
|
||||
const owners = new Map<string, Set<string>>()
|
||||
for (const account of accounts) {
|
||||
const name = rawName(account)
|
||||
if (!name) continue
|
||||
owners.set(name, (owners.get(name) ?? new Set()).add(account.userId))
|
||||
}
|
||||
return new Set([...owners].filter(([, ids]) => ids.size > 1).map(([name]) => name))
|
||||
}
|
||||
|
||||
export function sameNameHint(accounts: readonly NamedAccount[]): string {
|
||||
const duplicates = duplicateNames(accounts)
|
||||
if (!duplicates.size) return ''
|
||||
const groups = [...duplicates].map(name => `「${name}」:${accounts.filter(account => rawName(account) === name).map(account => account.owner).join('、')}`)
|
||||
return `存在同名用户${groups.join(';')},请按账号辨识`
|
||||
}
|
||||
|
||||
/** undefined = 无角色查看权(服务端不返回投影);null = 未分配。 */
|
||||
export function roleColumnLabel(role: AccountRoleSummary | null | undefined): string {
|
||||
if (role === undefined) return '—'
|
||||
if (role === null) return '未分配'
|
||||
return role.enabled ? role.name : `${role.name}(已停用)`
|
||||
}
|
||||
|
||||
export const assignedToRole = (account: { role?: AccountRoleSummary | null }, roleId: string) => !!roleId && account.role?.roleId === roleId
|
||||
|
||||
export function memberAssignments(
|
||||
selected: readonly string[], roleId: string, stateVersions: Readonly<Record<string, number>>,
|
||||
): UserRoleAssignmentInput[] {
|
||||
if (!roleId) throw new Error('请先选择目标角色')
|
||||
return [...new Set(selected)].map(userId => {
|
||||
const expectedStateVersion = stateVersions[userId]
|
||||
if (!expectedStateVersion) throw new Error('缺少用户状态版本,请刷新列表后重试')
|
||||
return { userId, roleId, expectedStateVersion }
|
||||
})
|
||||
}
|
||||
|
||||
/** 预览显示版本不一致时,只读重取这些用户的详情以获得最新 stateVersion(不涉及任何写接口)。 */
|
||||
export async function refreshStateVersions<T extends { userId: string; stateVersion: number }>(
|
||||
client: { adminGetUser: (userId: string) => Promise<T> }, userIds: readonly string[],
|
||||
): Promise<Record<string, T>> {
|
||||
const fresh = await Promise.all([...new Set(userIds)].map(userId => client.adminGetUser(userId)))
|
||||
return Object.fromEntries(fresh.map(account => [account.userId, account]))
|
||||
}
|
||||
|
||||
export const staleUserIds = (previews: readonly UserRoleAssignmentPreview[]) =>
|
||||
previews.filter(preview => !preview.stateMatches).map(preview => preview.userId)
|
||||
|
||||
export const previewTransition = (preview: Pick<UserRoleAssignmentPreview, 'currentRoleName' | 'targetRoleName'>) =>
|
||||
`${preview.currentRoleName || '未分配'} → ${preview.targetRoleName}`
|
||||
|
||||
/** 只有每个提交对象都有预览、且全部无错误且状态版本一致时才允许确认。 */
|
||||
export function previewAllowsAssign(previews: readonly UserRoleAssignmentPreview[], userIds: readonly string[]): boolean {
|
||||
if (!userIds.length) return false
|
||||
const byUser = new Map(previews.map(preview => [preview.userId, preview]))
|
||||
return userIds.every(userId => {
|
||||
const preview = byUser.get(userId)
|
||||
return !!preview && !preview.error && preview.stateMatches
|
||||
})
|
||||
}
|
||||
|
||||
/** 用户角色投影(GET users/{id}/role)不含 permissions;只有完整角色记录才能作为复制起点。 */
|
||||
export function roleSourceValues(role: RoleRecord, catalog: readonly PermissionDescriptor[]): Record<string, RolePermissionValue> {
|
||||
if (!Array.isArray(role.permissions)) return {}
|
||||
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 }]
|
||||
}))
|
||||
}
|
||||
|
||||
export function copyAssignRequest(input: {
|
||||
idempotencyKey: string; source: RoleRecord; name: string; description: string; catalog: readonly PermissionDescriptor[]
|
||||
values: Readonly<Record<string, RolePermissionValue>>; users: readonly { userId: string; stateVersion: number }[]
|
||||
}): RoleCopyAssignRequest {
|
||||
const name = input.name.trim()
|
||||
if (!name) throw new Error('请为新角色填写名称')
|
||||
if (!input.users.length) throw new Error('请至少选择一位用户')
|
||||
const baseline = roleSourceValues(input.source, input.catalog)
|
||||
const overrides: RolePermissionChangeInput[] = []
|
||||
for (const [actionId, before] of Object.entries(baseline)) {
|
||||
const after = input.values[actionId]
|
||||
if (after && (after.allowed !== before.allowed || after.capAllowed !== before.capAllowed)) overrides.push({ actionId, ...after })
|
||||
}
|
||||
return {
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
source: { roleId: input.source.roleId, expectedRoleVersion: input.source.roleVersion },
|
||||
name, description: input.description.trim(),
|
||||
...(overrides.length ? { overrides } : {}),
|
||||
assignments: input.users.map(user => ({ userId: user.userId, expectedStateVersion: user.stateVersion })),
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,58 @@
|
||||
import type { PermissionDescriptor, RolePermissionPatchEntry } from './api'
|
||||
|
||||
export type RolePermissionValue = { allowed: boolean; capAllowed: boolean }
|
||||
type RolePermissionChange = { actionId: string; allowed: boolean; capAllowed: boolean }
|
||||
export type RolePermissionStateName = 'denied' | 'project' | 'default'
|
||||
|
||||
export const ROLE_PERMISSION_STATES: { state: RolePermissionStateName; label: string }[] = [
|
||||
{ state: 'denied', label: '不允许' },
|
||||
{ state: 'project', label: '按项目授权' },
|
||||
{ state: 'default', label: '默认允许' },
|
||||
]
|
||||
|
||||
const ROLE_PERMISSION_CATEGORIES = ['项目与协作', '文档工作', '项目内容'] as const
|
||||
|
||||
export const PROJECT_CREATE_ACTION = 'project.create'
|
||||
/** 开启 project.create 时随之具备的本人创建项目管理资格(与后端 catalog 同序)。 */
|
||||
export const PROJECT_CREATE_LINKED_ACTIONS = [
|
||||
'project.settings', 'project.members.manage', 'project.permissions.manage', 'document.duties.assign',
|
||||
] as const
|
||||
|
||||
export function stateToValue(state: RolePermissionStateName): RolePermissionValue {
|
||||
if (state === 'default') return { allowed: true, capAllowed: true }
|
||||
if (state === 'project') return { allowed: false, capAllowed: true }
|
||||
return { allowed: false, capAllowed: false }
|
||||
}
|
||||
|
||||
export function valueToState(value: RolePermissionValue | undefined): RolePermissionStateName {
|
||||
if (!value?.capAllowed) return 'denied'
|
||||
return value.allowed ? 'default' : 'project'
|
||||
}
|
||||
|
||||
export function stateLabel(state: RolePermissionStateName): string {
|
||||
return ROLE_PERMISSION_STATES.find(item => item.state === state)?.label ?? state
|
||||
}
|
||||
|
||||
/** 受保护系统授权不进矩阵;缺分类的非保护系统动作(如 project.create)归入「项目与协作」。 */
|
||||
function permissionCategory(item: PermissionDescriptor): string {
|
||||
if (item.protected) return ''
|
||||
return item.category || (item.scope === 'system' ? ROLE_PERMISSION_CATEGORIES[0] : item.group)
|
||||
}
|
||||
|
||||
export function permissionCategories(catalog: PermissionDescriptor[]): string[] {
|
||||
const present = [...new Set(catalog.map(permissionCategory).filter(Boolean))]
|
||||
const fixed: string[] = ROLE_PERMISSION_CATEGORIES.filter(category => present.includes(category))
|
||||
return [...fixed, ...present.filter(category => !fixed.includes(category))]
|
||||
}
|
||||
|
||||
export function categoryActions(catalog: PermissionDescriptor[], category: string): PermissionDescriptor[] {
|
||||
if (!category) return []
|
||||
return catalog.filter(item => permissionCategory(item) === category)
|
||||
}
|
||||
|
||||
export function linkedByProjectCreate(values: Record<string, RolePermissionValue>, actionId: string): boolean {
|
||||
return (PROJECT_CREATE_LINKED_ACTIONS as readonly string[]).includes(actionId) && !!values[PROJECT_CREATE_ACTION]?.allowed
|
||||
}
|
||||
|
||||
export interface RolePermissionDraft {
|
||||
baseline: Record<string, RolePermissionValue>
|
||||
@@ -68,3 +121,22 @@ export function validateRolePermissionDraft(draft: RolePermissionDraft): { actio
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function dirtyRoles(
|
||||
drafts: Record<string, RolePermissionDraft>, actionIds?: string[],
|
||||
): { roleId: string; changes: number }[] {
|
||||
return Object.entries(drafts)
|
||||
.map(([roleId, draft]) => ({ roleId, changes: visiblePatch(draft, actionIds ?? Object.keys(draft.values)).length }))
|
||||
.filter(entry => entry.changes > 0)
|
||||
}
|
||||
|
||||
// One entry per dirty role; each carries only that role's changes from its own baseline.
|
||||
export function rolePatchEntries(
|
||||
drafts: Record<string, RolePermissionDraft>, versions: Record<string, number>, actionIds?: string[],
|
||||
): RolePermissionPatchEntry[] {
|
||||
return dirtyRoles(drafts, actionIds).map(({ roleId }) => ({
|
||||
roleId,
|
||||
expectedRoleVersion: versions[roleId],
|
||||
changes: visiblePatch(drafts[roleId], actionIds ?? Object.keys(drafts[roleId].values)),
|
||||
}))
|
||||
}
|
||||
|
||||
+16
-77
@@ -1,7 +1,7 @@
|
||||
// 应用状态:服务端权威(事件 + Query 对账),前端 store 是投影。
|
||||
// useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { type AdminZoneSection, api, connectEvents } from './api'
|
||||
import { type AdminZoneSection, type LinkedCapabilities, api, connectEvents } from './api'
|
||||
import { auth } from './auth'
|
||||
import { projectActionError, projectWorkError } from './projectmeta'
|
||||
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyArtifactUpdated, applyCheck, applyFinalizing, type LiveItem } from './live'
|
||||
@@ -23,7 +23,6 @@ export function isFontSizeChoice(value: number): value is FontSizeChoice {
|
||||
}
|
||||
import type {
|
||||
ActivityCard,
|
||||
TaskSourceRef,
|
||||
AgentProfile,
|
||||
Artifact,
|
||||
Conversation,
|
||||
@@ -91,7 +90,10 @@ export interface UIState {
|
||||
projectSettingsId: string | null
|
||||
}
|
||||
|
||||
export interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; isSuperAdmin?: boolean; mustChangePassword?: boolean; systemActions?: string[] }
|
||||
export interface Me {
|
||||
owner: string; name: string; auth: boolean; isAdmin?: boolean; mustChangePassword?: boolean; systemActions?: string[]
|
||||
linkedCapabilities?: LinkedCapabilities
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
contextObs: Record<string, ContextObs>
|
||||
@@ -233,8 +235,7 @@ function syncFilesEntry(hasTabs: boolean) {
|
||||
|
||||
function setUI(patch: Partial<UIState>) {
|
||||
const ui = { ...state.ui, ...patch }
|
||||
const navigation=(['rightTab','filesView','rightCollapsed','activeWorkspace'] as const).some(key=>key in patch&&patch[key]!==state.ui[key])
|
||||
state = { ...state, ui,previewOpenIntent:state.previewOpenIntent+(navigation?1:0) }
|
||||
state = { ...state, ui }
|
||||
try { localStorage.setItem('as.ui', JSON.stringify(ui)) } catch { /* ignore */ }
|
||||
applyTheme(ui.theme)
|
||||
applyFontSize(ui.fontSize)
|
||||
@@ -300,9 +301,6 @@ let projectsRequest = 0
|
||||
let tasksRequest = 0
|
||||
let meRequest = 0
|
||||
let streamSeq = 0
|
||||
let streamHasWatermark = false
|
||||
let gapReconnectPending = false
|
||||
let streamPending = new Map<number, StreamEvent>()
|
||||
let detailRequest = 0
|
||||
let detailSeq = 0
|
||||
let detailVersion = 0
|
||||
@@ -358,24 +356,12 @@ function scheduleTerminalReconcile() {
|
||||
|
||||
// Snapshot queries may overlap events. Only rows changed after the request began
|
||||
// override that snapshot; a newer request/context invalidates the old query entirely.
|
||||
export function mergeTaskSnapshot<T extends {taskId:string;attemptId:string;revision:number}>(current:T,incoming:T):T {
|
||||
if(current.taskId!==incoming.taskId||current.attemptId!==incoming.attemptId||!Number.isSafeInteger(incoming.revision)||incoming.revision<=current.revision)return current
|
||||
return incoming
|
||||
}
|
||||
function mergeTaskCard(current:ActivityCard|undefined,incoming:ActivityCard):ActivityCard {
|
||||
if(!current?.task||!incoming.task||current.task.schemaVersion!==1||incoming.task.schemaVersion!==1)return incoming
|
||||
const a=current.task,b=incoming.task
|
||||
if(a.taskId!==b.taskId||a.attemptId!==b.attemptId||a.projectId!==b.projectId||a.conversationId!==b.conversationId||a.ownerInstanceId!==b.ownerInstanceId||(a.executionEpoch??0)>(b.executionEpoch??0)||a.revision>b.revision)return current
|
||||
if(['completed','failed','interrupted'].includes(a.executionState)&&!['completed','failed','interrupted'].includes(b.executionState))return current
|
||||
if(a.revision!==b.revision&&mergeTaskSnapshot(a,b)===a)return current
|
||||
return incoming
|
||||
}
|
||||
function mergeCardSnapshot(snapshot: ActivityCard[], current: ActivityCard[], versions: Record<string, number>, started: number,
|
||||
patches: Record<string, Pick<ActivityCard, 'status' | 'tone'>>) {
|
||||
const fresh = current.filter(c => (versions[c.id] ?? 0) > started)
|
||||
const freshIds = new Set(fresh.map(c => c.id))
|
||||
const combined=fresh.map(c=>{const row=snapshot.find(r=>r.id===c.id);return row?.task&&c.task&&row.task.revision>c.task.revision?mergeTaskCard(c,row):c})
|
||||
return [...combined, ...snapshot.filter(c => !freshIds.has(c.id)).map(row => {const c=mergeTaskCard(current.find(old=>old.id===row.id),row);return (versions[c.id] ?? 0) > started && patches[c.id] ? { ...c, ...patches[c.id], tone: patches[c.id].tone ?? c.tone } : c})]
|
||||
return [...fresh, ...snapshot.filter(c => !freshIds.has(c.id)).map(c =>
|
||||
(versions[c.id] ?? 0) > started && patches[c.id] ? { ...c, ...patches[c.id], tone: patches[c.id].tone ?? c.tone } : c)]
|
||||
}
|
||||
|
||||
function hitlSource(card: ActivityCard): string | undefined {
|
||||
@@ -547,7 +533,7 @@ export const store = {
|
||||
projectSeq = 0
|
||||
projectCardsRequest++
|
||||
projectCardVersion = 0; projectCardVersions = {}; projectCardPatches = {}
|
||||
conversationEpoch++; streamSeq = 0; streamHasWatermark=false;streamPending.clear();gapReconnectPending=false; cardVersion = 0; cardVersions = {}; cardPatches = {}
|
||||
conversationEpoch++; streamSeq = 0; cardVersion = 0; cardVersions = {}; cardPatches = {}
|
||||
detailSeq = 0; detailVersion = 0; runObservations = {}; messageObservations = {}; hitlObservations = {}
|
||||
set({ currentConvId: null, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
|
||||
cards: [], projectCards: [], workRecord: null, workReference: null, plan: null, todos: null, surfaces: [], projectSurfaces: [],
|
||||
@@ -638,7 +624,7 @@ export const store = {
|
||||
async openConversation(convId: string) {
|
||||
if (sse) { sse.close(); sse = null }
|
||||
const epoch = ++conversationEpoch
|
||||
streamSeq = 0; streamHasWatermark=false;streamPending.clear();gapReconnectPending=false; cardVersion = 0; cardVersions = {}; cardPatches = {}
|
||||
streamSeq = 0; cardVersion = 0; cardVersions = {}; cardPatches = {}
|
||||
detailSeq = 0; detailVersion = 0; runObservations = {}; messageObservations = {}; hitlObservations = {}
|
||||
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], workRecord: null, workReference: null, surfaces: [], projectSurfaces: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false })
|
||||
setUI({ previewExpanded: false })
|
||||
@@ -736,21 +722,15 @@ export const store = {
|
||||
streamSeq = Math.max(streamSeq, since ?? state.lastSeq)
|
||||
sse?.close()
|
||||
sse = connectEvents(convId, since ?? state.lastSeq, {
|
||||
onOpen: () => { if (state.currentConvId === convId && conversationEpoch === epoch) {set({ connected: true });void store.refreshCards(convId)} },
|
||||
onOpen: () => { if (state.currentConvId === convId && conversationEpoch === epoch) set({ connected: true }) },
|
||||
onError: () => { if (state.currentConvId === convId && conversationEpoch === epoch) set({ connected: false }) },
|
||||
onEvent: e => { if (state.currentConvId === convId && conversationEpoch === epoch) store.applyEvent(e) },
|
||||
})
|
||||
},
|
||||
|
||||
applyEvent(e: StreamEvent) {
|
||||
if (!state.currentConvId || e.conversationId !== state.currentConvId || !Number.isSafeInteger(e.seq) || e.seq <= streamSeq) return
|
||||
if(streamHasWatermark&&e.seq>streamSeq+1){
|
||||
if(streamPending.size<1000)streamPending.set(e.seq,e)
|
||||
if(!gapReconnectPending){gapReconnectPending=true;const conv=state.currentConvId,epoch=conversationEpoch;queueMicrotask(()=>{gapReconnectPending=false;if(conv===state.currentConvId&&epoch===conversationEpoch)store.connectLive(streamSeq)})}
|
||||
return
|
||||
}
|
||||
if (!state.currentConvId || e.conversationId !== state.currentConvId || !Number.isFinite(e.seq) || e.seq <= streamSeq) return
|
||||
streamSeq = e.seq
|
||||
if(streamPending.has(e.seq+1)){const pending=streamPending.get(e.seq+1)!;streamPending.delete(e.seq+1);queueMicrotask(()=>store.applyEvent(pending))}
|
||||
const previousSeq = state.lastSeq
|
||||
// Running-turn replay reconstructs transient output, but cannot regress the
|
||||
// persistent card/plan/status snapshot already returned by the detail query.
|
||||
@@ -901,7 +881,6 @@ export const store = {
|
||||
}
|
||||
case 'artifact.created':
|
||||
case 'artifact.updated': {
|
||||
if(e.type==='artifact.updated'&&state.currentConvId)void store.refreshCards(state.currentConvId)
|
||||
const p = e.payload as { artifact?: { id: string; name: string; kind?: string } }
|
||||
if (p?.artifact) {
|
||||
const ref = { artifactId: p.artifact.id, name: p.artifact.name, kind: p.artifact.kind }
|
||||
@@ -1098,7 +1077,6 @@ export const store = {
|
||||
if (card.conversationId && card.conversationId !== state.currentConvId) return
|
||||
cardVersions[card.id] = ++cardVersion
|
||||
delete cardPatches[card.id]
|
||||
card=mergeTaskCard(state.cards.find(c=>c.id===card.id),card)
|
||||
const i = state.cards.findIndex(c => c.id === card.id)
|
||||
const cards = i >= 0 ? state.cards.map(c => c.id === card.id ? card : c) : [card, ...state.cards]
|
||||
set({ cards })
|
||||
@@ -1110,10 +1088,8 @@ export const store = {
|
||||
const request = ++cardsRequest
|
||||
const started = cardVersion
|
||||
try {
|
||||
const out = await api.listCardsSnapshot(convId)
|
||||
const snapshot=out.cards??[]
|
||||
const snapshot = await api.listCards(convId)
|
||||
if (state.currentConvId !== convId || conversationEpoch !== epoch || request !== cardsRequest) return
|
||||
if(Number.isSafeInteger(out.replaySeq)&&out.replaySeq!>=0)streamHasWatermark=true
|
||||
const cards = mergeCardSnapshot(snapshot, state.cards, cardVersions, started, cardPatches)
|
||||
set({ cards })
|
||||
settleCardFeedback(cards)
|
||||
@@ -1315,8 +1291,7 @@ export const store = {
|
||||
applyProjectEvent(e: StreamEvent) {
|
||||
if (!Number.isFinite(e.seq) || e.seq <= projectSeq) return
|
||||
projectSeq = e.seq
|
||||
const upsert = (incoming: ActivityCard) => {
|
||||
const card=mergeTaskCard(state.projectCards.find(c=>c.id===incoming.id),incoming)
|
||||
const upsert = (card: ActivityCard) => {
|
||||
if (card.projectId !== state.currentProjectId) return
|
||||
projectCardVersions[card.id] = ++projectCardVersion
|
||||
const list = store.getState().projectCards
|
||||
@@ -1429,41 +1404,6 @@ export const store = {
|
||||
* 工件预览唯一入口:在第三栏「文件」入口打开或激活标签(多文件多标签)。
|
||||
* 打开第一张自动切到预览子视图;关掉最后一张回到文件列表,并收回展开态。
|
||||
*/
|
||||
async openTaskSource(cardId: string, source: TaskSourceRef) {
|
||||
const conversationId = state.currentConvId
|
||||
const projectId = state.currentProjectId
|
||||
const epoch = conversationEpoch
|
||||
const intent = state.previewOpenIntent + 1
|
||||
set({ previewOpenIntent: intent })
|
||||
const isCurrent = () => conversationId === state.currentConvId && projectId === state.currentProjectId
|
||||
&& epoch === conversationEpoch && intent === state.previewOpenIntent
|
||||
try {
|
||||
const out = await api.taskSource(cardId, source.id)
|
||||
if (!isCurrent()) return
|
||||
if (!out.artifact) {
|
||||
store.toast('来源当前不可用,正在核实工作记录')
|
||||
if (conversationId) void store.refreshCards(conversationId)
|
||||
return
|
||||
}
|
||||
store.requestTaskSourcePreview(out.artifact, {
|
||||
notice: out.versionState === 'changed'
|
||||
? '当前文件已更新;下方引用来自任务处理时的版本,不能作为当前文件的结论'
|
||||
: '任务处理时引用的来源版本;下方是本项发现引用的片段',
|
||||
quote: out.source.quote ?? '',
|
||||
expectedHash: out.versionState === 'current' ? out.source.contentHash : undefined,
|
||||
})
|
||||
if (out.versionState === 'changed' && conversationId) void store.refreshCards(conversationId)
|
||||
} catch {
|
||||
if (!isCurrent()) return
|
||||
store.toast('来源当前无法读取,正在核实工作记录')
|
||||
if (conversationId) void store.refreshCards(conversationId)
|
||||
}
|
||||
},
|
||||
requestTaskSourcePreview(a:Artifact,sourceContext:{notice:string;quote:string;expectedHash?:string}){
|
||||
store.requestPreview(a)
|
||||
const conv=previewTabsKey(state.currentConvId)
|
||||
set({previewTabs:{...state.previewTabs,[conv]:(state.previewTabs[conv]??[]).map(tab=>tab.id===a.id?{...tab,sourceContext}:tab)}})
|
||||
},
|
||||
requestPreview(a: Artifact) {
|
||||
const conv = previewTabsKey(state.currentConvId)
|
||||
const opened = openPreviewTab(state.previewTabs[conv] ?? [], { id: a.id, name: a.name })
|
||||
@@ -1515,7 +1455,7 @@ export const store = {
|
||||
togglePreviewExpanded() { setUI({ previewExpanded: !state.ui.previewExpanded }) },
|
||||
activatePreview(id: string) {
|
||||
const conv = previewTabsKey(state.currentConvId)
|
||||
set({ previewActive: { ...state.previewActive, [conv]: id },previewOpenIntent:state.previewOpenIntent+1 })
|
||||
set({ previewActive: { ...state.previewActive, [conv]: id } })
|
||||
},
|
||||
closePreview(id: string) {
|
||||
const conv = previewTabsKey(state.currentConvId)
|
||||
@@ -1523,7 +1463,6 @@ export const store = {
|
||||
set({
|
||||
previewTabs: { ...state.previewTabs, [conv]: next.tabs },
|
||||
previewActive: { ...state.previewActive, [conv]: next.activeId },
|
||||
previewOpenIntent:state.previewOpenIntent+1,
|
||||
})
|
||||
if (next.tabs.length === 0) setUI({ previewExpanded: false })
|
||||
syncFilesEntry(next.tabs.length > 0)
|
||||
@@ -1580,5 +1519,5 @@ export function mergeStartedRun(runs: Run[], run: Run): Run[] {
|
||||
export function eventTime(e: { ts?: string }, now = Date.now()): number {
|
||||
if (!e.ts) return now
|
||||
const t = Date.parse(e.ts)
|
||||
return Number.isNaN(t) || t <= 0 ? now : t
|
||||
return Number.isNaN(t) ? now : t
|
||||
}
|
||||
|
||||
+59
-9
@@ -519,31 +519,81 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.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-view-toggle { justify-content: flex-end; flex-wrap: wrap; }
|
||||
.rp-view-toggle .ad-mini.on { background: var(--bg-active); color: var(--text); border-color: var(--accent); }
|
||||
.rp-category-nav { display: flex; gap: 6px; flex-wrap: wrap; margin-right: auto; }
|
||||
.rp-status:focus { outline: none; }
|
||||
.rp-status:empty { display: none; }
|
||||
.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-matrix-wrap { overflow: auto; max-height: 70vh; border: 1px solid var(--border-soft); border-radius: var(--radius-lg); }
|
||||
.rp-matrix-wrap:focus-visible { outline: none; box-shadow: var(--ring); }
|
||||
.rp-matrix { min-width: 760px; border-collapse: separate; border-spacing: 0; }
|
||||
.rp-matrix thead th { position: sticky; top: 0; z-index: 2; background: var(--bg-panel); vertical-align: bottom; }
|
||||
.rp-matrix th:first-child { position: sticky; left: 0; z-index: 1; background: var(--bg-panel); }
|
||||
.rp-matrix thead th:first-child { z-index: 3; }
|
||||
.rp-matrix tbody th { font-weight: normal; color: var(--text); border-bottom: 1px solid var(--border-soft); vertical-align: top; }
|
||||
.rp-matrix td { vertical-align: top; }
|
||||
.rp-matrix tr.rp-selected td, .rp-matrix tr.rp-selected th { background: var(--bg-active); }
|
||||
.rp-role-cell { display: flex; flex-direction: column; gap: 2px; min-width: 180px; max-width: 240px; }
|
||||
.rp-role-name { align-self: flex-start; border: none; background: none; padding: 0; color: var(--accent-text); font-size: var(--fs-ui-sm); font-weight: 600; cursor: pointer; text-align: left; }
|
||||
.rp-role-name:hover:not(:disabled) { text-decoration: underline; }
|
||||
.rp-role-name:disabled { color: var(--text-dim); cursor: default; }
|
||||
.rp-role-desc { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); }
|
||||
.rp-disabled-note { margin: 0; font-size: var(--fs-ui-xs); color: var(--warn-text); }
|
||||
.rp-empty { padding: 12px; }
|
||||
.rp-cell { display: flex; flex-direction: column; gap: 4px; min-width: 120px; }
|
||||
.rp-state { height: 30px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-raised); color: var(--text); font-size: var(--fs-ui-sm); padding: 0 6px; }
|
||||
.rp-state:focus-visible { outline: none; border-color: var(--accent); box-shadow: var(--ring); }
|
||||
.rp-state:disabled { opacity: .55; cursor: default; }
|
||||
.rp-cell-flag { font-size: var(--fs-ui-xs); color: var(--warn-text); }
|
||||
.rp-linked { display: flex; gap: 4px; align-items: flex-start; margin: 0; font-size: var(--fs-ui-xs); color: var(--accent-text); }
|
||||
.rp-linked svg { flex: none; margin-top: 1px; }
|
||||
.rp-focus-table td { vertical-align: top; }
|
||||
.rp-diff { margin: 0; padding-left: 20px; color: var(--text-dim); font-size: var(--fs-ui-sm); }
|
||||
.rp-preview { display: flex; flex-direction: column; gap: 10px; padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--bg-raised); font-size: var(--fs-ui-sm); }
|
||||
.rp-preview-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.rp-preview-role { display: flex; flex-direction: column; gap: 4px; padding-top: 8px; border-top: 1px solid var(--border-soft); }
|
||||
.rp-preview-title { font-weight: 600; color: var(--text); }
|
||||
.rp-warn-text { font-weight: normal; color: var(--warn-text); }
|
||||
.rp-conflict { display: flex; gap: 4px; align-items: flex-start; margin: 0; color: var(--err-text); font-size: var(--fs-ui-sm); }
|
||||
.rp-conflict svg { flex: none; margin-top: 2px; }
|
||||
.rp-savebar { position: sticky; bottom: 0; z-index: 4; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--bg-panel); box-shadow: var(--shadow-md); }
|
||||
.rp-savebar-summary { flex: 1; min-width: 160px; font-size: var(--fs-ui-sm); color: var(--text); }
|
||||
.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-members { display: flex; flex-direction: column; gap: 10px; }
|
||||
.rp-members-link { border: none; background: none; padding: 0; color: var(--accent-text); font-size: var(--fs-ui-sm); cursor: pointer; text-decoration: underline; }
|
||||
.rp-members-link:disabled { color: var(--text-dim); cursor: default; text-decoration: none; }
|
||||
.rp-members-link:focus-visible { outline: none; border-radius: var(--radius-xs); box-shadow: var(--ring); }
|
||||
.au-select-col { width: 32px; }
|
||||
.au-select-col input:focus-visible { outline: none; box-shadow: var(--ring); }
|
||||
.au-name-cell { display: flex; flex-direction: column; gap: 2px; }
|
||||
.au-same-name { align-self: flex-start; font-size: var(--fs-ui-xs); color: var(--warn-text); background: var(--warn-soft); border-radius: var(--radius-xs); padding: 1px 6px; }
|
||||
.au-grow { flex: 1; min-width: 0; }
|
||||
.au-batch .lg-input { height: 34px; }
|
||||
.au-preview-list { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 6px; }
|
||||
.au-preview-list li { display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; padding-top: 6px; border-top: 1px solid var(--border-soft); }
|
||||
.au-copy { display: flex; flex-direction: column; gap: 10px; }
|
||||
.au-copy-group { margin: 0; padding: 8px 10px; border: 1px solid var(--border-soft); border-radius: var(--radius); display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 6px 12px; }
|
||||
.au-copy-group legend { padding: 0 4px; font-size: var(--fs-ui-sm); font-weight: 600; color: var(--text-dim); }
|
||||
.au-copy-row { display: flex; gap: 8px; align-items: center; font-size: var(--fs-ui-sm); color: var(--text); }
|
||||
.au-copy-row > span:first-child { flex: 1; min-width: 0; }
|
||||
@media (max-width: 900px) {
|
||||
.rp-single { grid-template-columns: 1fr; }
|
||||
.rp-role-list { max-height: 220px; overflow-y: auto; }
|
||||
.rp-matrix-wrap { max-height: 60vh; }
|
||||
.rp-savebar .ad-mini, .rp-savebar .lg-submit { flex: 1; justify-content: center; }
|
||||
}
|
||||
.ad-mono { font-family: var(--mono); font-size: var(--fs-ui-sm); }
|
||||
.ad-dim { color: var(--text-dim); }
|
||||
.ad-cond-note { display: block; margin-top: 2px; font-size: var(--fs-ui-xs); }
|
||||
.ad-chip { font-size: var(--fs-ui-xs); border-radius: var(--radius-xs); padding: 2px 7px; font-weight: 700; letter-spacing: .5px; background: var(--bg-hover); color: var(--text-dim); }
|
||||
.ad-chip-admin { background: var(--accent-soft); color: var(--accent-text); }
|
||||
.ad-chip-super { background: var(--accent); color: var(--text-on-accent); }
|
||||
.ad-chip-warn { background: var(--warn-soft); color: var(--warn-text); }
|
||||
.ad-reset { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; border: 1px solid color-mix(in srgb, var(--warn) 40%, transparent); background: var(--warn-soft); border-radius: var(--radius-lg); padding: 12px; font-size: var(--fs-ui-sm); }
|
||||
.ad-reset .lg-input { flex: 1; min-width: 160px; height: 32px; }
|
||||
|
||||
@@ -1,10 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { credentialLabel } from '../src/components/AccountUsersPanel'
|
||||
import { accountLabel, accountName, duplicateNames, roleColumnLabel, sameNameHint } from '../src/roleMembers'
|
||||
|
||||
const account = (userId: string, owner: string, name: string) => ({ userId, owner, profile: { name } })
|
||||
|
||||
describe('account directory credential state', () => {
|
||||
it('shows whether password is ready without legacy initialization labels', () => {
|
||||
expect(credentialLabel({ initializationState: 'pending', passwordSet: false })).toBe('待设置密码')
|
||||
it('shows pending trusted-owner initialization before password state', () => {
|
||||
expect(credentialLabel({ initializationState: 'pending', passwordSet: false })).toBe('待可信 owner 初始化')
|
||||
expect(credentialLabel({ initializationState: 'initialized', passwordSet: true })).toBe('已设置密码')
|
||||
expect(credentialLabel({ initializationState: 'legacy', passwordSet: false })).toBe('待设置密码')
|
||||
expect(credentialLabel({ initializationState: 'legacy', passwordSet: false })).toBe('未设置密码')
|
||||
})
|
||||
})
|
||||
|
||||
describe('account directory names and roles', () => {
|
||||
it('falls back to the login account when the name is missing', () => {
|
||||
expect(accountName(account('u1', 'zhang.san', ''))).toBe('zhang.san')
|
||||
expect(accountName(account('u1', 'zhang.san', ' '))).toBe('zhang.san')
|
||||
expect(accountName(account('u1', 'zhang.san', '张三'))).toBe('张三')
|
||||
expect(accountLabel(account('u1', 'zhang.san', '张三'))).toBe('张三(zhang.san)')
|
||||
expect(accountLabel(account('u1', 'zhang.san', ''))).toBe('zhang.san')
|
||||
expect(accountName({ userId: 'u2', owner: 'li.si', name: '李四' })).toBe('李四')
|
||||
})
|
||||
|
||||
it('asks to tell same-name users apart by account', () => {
|
||||
const accounts = [account('u1', 'zhang.san', '张三'), account('u2', 'zhang.san2', '张三'), account('u3', 'li.si', '李四'), account('u4', 'anon', '')]
|
||||
expect([...duplicateNames(accounts)]).toEqual(['张三'])
|
||||
expect(sameNameHint(accounts)).toBe('存在同名用户「张三」:zhang.san、zhang.san2,请按账号辨识')
|
||||
expect(sameNameHint(accounts.slice(2))).toBe('')
|
||||
})
|
||||
|
||||
it('labels the current role projection', () => {
|
||||
expect(roleColumnLabel(undefined)).toBe('—')
|
||||
expect(roleColumnLabel(null)).toBe('未分配')
|
||||
expect(roleColumnLabel({ roleId: 'r1', name: '软件工程师', enabled: true })).toBe('软件工程师')
|
||||
expect(roleColumnLabel({ roleId: 'r1', name: '软件工程师', enabled: false })).toBe('软件工程师(已停用)')
|
||||
})
|
||||
})
|
||||
|
||||
+13
-53
@@ -1,17 +1,7 @@
|
||||
// 登录/改密门错误文案:登录与改密的 401 语义不同,映射分开锁定
|
||||
// (后端错误串是现行 HTTP 契约的一部分,这里逐字对拍防漂移)。
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { api } from '../src/api'
|
||||
import { auth } from '../src/auth'
|
||||
import {
|
||||
ForcedPasswordModeSection,
|
||||
isForcedPasswordMode,
|
||||
requiresCurrentPassword,
|
||||
submitForcedPassword,
|
||||
} from '../src/components/passwordChangeMode'
|
||||
import { humanChangePasswordError, humanLoginError } from '../src/components/loginErrors'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { humanChangePasswordError, humanLoginError, humanRedeemError } from '../src/components/LoginPage'
|
||||
|
||||
describe('humanLoginError(登录)', () => {
|
||||
it('401 统一为用户名或密码不正确', () => {
|
||||
@@ -41,49 +31,19 @@ describe('humanChangePasswordError(改密门)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('humanRedeemError(可信激活与本人恢复能力兑换)', () => {
|
||||
it('409 已使用 / 401 无效过期 / 429 退避各有文案', () => {
|
||||
expect(humanRedeemError('409: login key already used')).toBe('该能力已被使用过;请由本人核对提案状态,或联系可信 owner 续办初始化')
|
||||
expect(humanRedeemError('401: invalid or expired login key')).toBe('能力无效或已过期;请核对本人提案状态或联系可信 owner 续办初始化')
|
||||
expect(humanRedeemError('429: too many failed attempts; retry later')).toBe('尝试次数过多,请稍后再试')
|
||||
})
|
||||
it('未知错误剥状态码前缀透传', () => {
|
||||
expect(humanRedeemError('400: bad')).toBe('bad')
|
||||
})
|
||||
})
|
||||
|
||||
describe('humanChangePasswordError(登录恢复新增文案)', () => {
|
||||
it('并发凭据变化给出重新登录指引', () => {
|
||||
expect(humanChangePasswordError('401: credentials changed concurrently; retry login')).toBe('登录状态已变化(密码在其他地方被更新),请重新登录')
|
||||
})
|
||||
})
|
||||
|
||||
describe('one_time_password 强制改密流程', () => {
|
||||
afterEach(() => {
|
||||
auth.clear()
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('识别 one_time_password 模式且无需当前密码字段', () => {
|
||||
expect(isForcedPasswordMode('one_time_password')).toBe(true)
|
||||
expect(requiresCurrentPassword('one_time_password')).toBe(false)
|
||||
const html = renderToStaticMarkup(createElement(ForcedPasswordModeSection, {
|
||||
mode: 'one_time_password',
|
||||
current: '',
|
||||
onCurrentChange: () => undefined,
|
||||
}))
|
||||
expect(html).not.toContain('forced-current')
|
||||
expect(html).toContain('无需输入当前密码')
|
||||
})
|
||||
|
||||
it('提交 one_time_password 改密时走 /api/auth/password', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
owner: 'alice',
|
||||
name: 'Alice',
|
||||
isAdmin: false,
|
||||
mustChangePassword: false,
|
||||
reloginRequired: true,
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } }))
|
||||
vi.stubGlobal('fetch', fetchSpy)
|
||||
const changeSpy = vi.spyOn(api, 'changePassword')
|
||||
|
||||
await submitForcedPassword('one_time_password', 'should-be-ignored', 'new-password-123')
|
||||
|
||||
expect(changeSpy).toHaveBeenCalledWith('', 'new-password-123')
|
||||
expect(fetchSpy).toHaveBeenCalledWith('/api/auth/password', expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ current: '', next: 'new-password-123' }),
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { api } from '../src/api'
|
||||
import { auth } from '../src/auth'
|
||||
import { GeneratedCredentialPanel, oneTimePasswordCredential } from '../src/components/oneTimePasswordUi'
|
||||
|
||||
describe('管理员一次性密码发放', () => {
|
||||
afterEach(() => {
|
||||
auth.clear()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('调用精确 one-time-password 端点并使用 POST', async () => {
|
||||
const response = { owner: 'alice', id: 'otp_123', oneTimePassword: 'otp-0001', mustChangePassword: true }
|
||||
const fetchSpy = vi.fn().mockResolvedValue(new Response(JSON.stringify(response), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}))
|
||||
vi.stubGlobal('fetch', fetchSpy)
|
||||
|
||||
const out = await api.adminIssueOneTimePassword('alice')
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
||||
expect(fetchSpy).toHaveBeenCalledWith('/api/admin/users/alice/one-time-password', { method: 'POST' })
|
||||
expect(out).toEqual(response)
|
||||
})
|
||||
|
||||
it('展示后端返回的 oneTimePassword', () => {
|
||||
const credential = oneTimePasswordCredential({ owner: 'alice', id: 'otp_123', oneTimePassword: 'otp-0001', mustChangePassword: true })
|
||||
const html = renderToStaticMarkup(createElement(GeneratedCredentialPanel, {
|
||||
credential,
|
||||
onClose: () => undefined,
|
||||
}))
|
||||
expect(html).toContain('otp-0001')
|
||||
expect(html).toContain('一次性登录密码仅显示一次')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { LINKED_ACTION_LABELS, dutyConditionNote, linkedCapabilitySummary } from '../src/permissionExplanations'
|
||||
import { describeSource } from '../src/rdmManagement'
|
||||
|
||||
const LINKED = ['project.settings', 'project.members.manage', 'project.permissions.manage', 'document.duties.assign']
|
||||
|
||||
describe('linked capability summary', () => {
|
||||
it('returns empty when the server omits the field (older backend)', () => {
|
||||
expect(linkedCapabilitySummary(undefined)).toBe('')
|
||||
})
|
||||
|
||||
it('explains held project-create eligibility and the self-created-project scope', () => {
|
||||
expect(linkedCapabilitySummary({ projectCreate: true, source: 'grant', actions: LINKED })).toBe(
|
||||
'已具备创建项目资格(来源:管理员授予);创建本人项目后自动具备:管理项目设置、管理项目成员、管理项目权限、分配文档职责;'
|
||||
+ '这些资格仅对本人创建的项目生效,其他项目按项目授权判断')
|
||||
})
|
||||
|
||||
it('maps source variants and falls back to the raw source', () => {
|
||||
expect(linkedCapabilitySummary({ projectCreate: true, source: 'role', actions: [] })).toBe('已具备创建项目资格(来源:角色)')
|
||||
expect(linkedCapabilitySummary({ projectCreate: true, source: 'admin', actions: [] })).toBe('已具备创建项目资格(来源:系统管理员)')
|
||||
expect(linkedCapabilitySummary({ projectCreate: true, source: 'admin-grant', actions: [] })).toContain('来源:管理员授予')
|
||||
expect(linkedCapabilitySummary({ projectCreate: true, source: 'future', actions: [] })).toContain('来源:future')
|
||||
expect(linkedCapabilitySummary({ projectCreate: true, actions: [] })).toBe('已具备创建项目资格')
|
||||
})
|
||||
|
||||
it('appends explicit conflicts with Chinese labels', () => {
|
||||
const text = linkedCapabilitySummary({
|
||||
projectCreate: true, source: 'role', actions: ['project.settings'], conflicts: ['project.members.manage', 'document.duties.assign'],
|
||||
})
|
||||
expect(text).toContain('创建本人项目后自动具备:管理项目设置')
|
||||
expect(text.endsWith('存在冲突:管理项目成员、分配文档职责 被明确禁止')).toBe(true)
|
||||
})
|
||||
|
||||
it('states missing eligibility without listing linked actions', () => {
|
||||
expect(linkedCapabilitySummary({ projectCreate: false, actions: [] })).toBe('未具备创建项目资格')
|
||||
expect(linkedCapabilitySummary({ projectCreate: false, actions: ['project.settings'], conflicts: ['project.settings'] })).toBe('未具备创建项目资格')
|
||||
})
|
||||
|
||||
it('labels every linked action and keeps unknown ids verbatim', () => {
|
||||
expect(LINKED.map(id => LINKED_ACTION_LABELS[id])).toEqual(['管理项目设置', '管理项目成员', '管理项目权限', '分配文档职责'])
|
||||
expect(linkedCapabilitySummary({ projectCreate: true, actions: ['x.y'] })).toContain('自动具备:x.y')
|
||||
})
|
||||
})
|
||||
|
||||
describe('duty condition note', () => {
|
||||
it('explains the duty and state conditions for document author/review/approve', () => {
|
||||
expect(dutyConditionNote('document.author')).toBe('仍需被分配该文档的编制职责,且文档状态符合要求')
|
||||
expect(dutyConditionNote('document.review')).toBe('仍需被分配该文档的审核职责,且文档状态符合要求')
|
||||
expect(dutyConditionNote('document.approve')).toBe('仍需被分配该文档的批准职责,且文档状态符合要求')
|
||||
})
|
||||
|
||||
it('is empty for every other action', () => {
|
||||
for (const id of ['document.read', 'project.settings', 'project.create', '']) expect(dutyConditionNote(id)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('describeSource future sources', () => {
|
||||
it('maps creator and role sources, keeping the raw fallback', () => {
|
||||
expect(describeSource('creator')).toBe('本人项目创建者自动具备(仅本人创建的项目)')
|
||||
expect(describeSource('role')).toBe('职位角色')
|
||||
expect(describeSource('weird')).toBe('weird')
|
||||
})
|
||||
})
|
||||
@@ -1,243 +0,0 @@
|
||||
// RDM 项目设置:成员页门控测试
|
||||
// 当 canManage=false 时:添加成员入口与行操作按钮(移除/升降级)不出现,不调用 rdm.role.options;
|
||||
// 当 canManage=true 时:添加成员与行操作按钮出现,请求 rdm.role.options。
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createElement, act } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { api } from '../src/api'
|
||||
import { RdmMembersSection } from '../src/components/adminRdmSections'
|
||||
import type { Project } from '../src/contracts'
|
||||
import type { RdmMemberRow } from '../src/rdmManagement'
|
||||
|
||||
const project: Project = {
|
||||
id: 'prj_test',
|
||||
name: '测试项目',
|
||||
settings: {
|
||||
'rdm.projectId': 'rdm_prj_test',
|
||||
'rdm.state': 'ready',
|
||||
},
|
||||
createdAt: '2026-10-01T00:00:00Z',
|
||||
updatedAt: '2026-10-01T00:00:00Z',
|
||||
}
|
||||
|
||||
const mockRows: RdmMemberRow[] = [
|
||||
{ owner: 'alice', name: 'Alice', role: 'owner', addedBy: 'admin', addedAt: '2026-10-01T10:00:00Z' },
|
||||
{ owner: 'bob', name: 'Bob', role: 'member', addedBy: 'alice', addedAt: '2026-10-02T10:00:00Z' },
|
||||
]
|
||||
|
||||
describe('RdmMembersSection 静态组件门控呈现', () => {
|
||||
it('canManage=false 时:添加成员与移除按钮不呈现,显示只读提示,动作列头不渲染', () => {
|
||||
const html = renderToStaticMarkup(createElement(RdmMembersSection, {
|
||||
project,
|
||||
initialCanManage: false,
|
||||
initialRows: mockRows,
|
||||
}))
|
||||
expect(html).toContain('仅项目创建者可管理成员;你可以查看成员列表。')
|
||||
expect(html).not.toContain('添加成员')
|
||||
expect(html).not.toContain('移除')
|
||||
expect(html).not.toContain('降为成员')
|
||||
expect(html).not.toContain('升为负责人')
|
||||
// 列表基础内容、搜索框、成员信息仍可见
|
||||
expect(html).toContain('alice')
|
||||
expect(html).toContain('bob')
|
||||
expect(html).toContain('搜索成员')
|
||||
// 表头只有 5 列(无末尾操作列头 <th></th>)
|
||||
const thMatches = html.match(/<th\b/g) || []
|
||||
expect(thMatches).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('canManage=true 时:添加成员与移除按钮呈现,不显示只读提示,动作列头渲染', () => {
|
||||
const html = renderToStaticMarkup(createElement(RdmMembersSection, {
|
||||
project,
|
||||
initialCanManage: true,
|
||||
initialRows: mockRows,
|
||||
}))
|
||||
expect(html).not.toContain('仅项目创建者可管理成员')
|
||||
expect(html).toContain('添加成员')
|
||||
expect(html).toContain('移除')
|
||||
expect(html).toContain('降为成员')
|
||||
expect(html).toContain('升为负责人')
|
||||
// 表头共 6 列(包含末尾操作列头 <th></th>)
|
||||
const thMatches = html.match(/<th\b/g) || []
|
||||
expect(thMatches).toHaveLength(6)
|
||||
})
|
||||
})
|
||||
|
||||
describe('RdmMembersSection 动态加载与命令门控交互', () => {
|
||||
let commandsCalled: { id: string; params?: Record<string, unknown> }[] = []
|
||||
let mockEnv: { container: any; cleanup: () => void }
|
||||
|
||||
function setupMockDom() {
|
||||
const origDoc = globalThis.document
|
||||
const origWindow = globalThis.window
|
||||
const origAct = (globalThis as any).IS_REACT_ACT_ENVIRONMENT
|
||||
|
||||
;(globalThis as any).HTMLIFrameElement = class {}
|
||||
;(globalThis as any).Element = class {}
|
||||
;(globalThis as any).HTMLElement = class extends (globalThis as any).Element {}
|
||||
|
||||
function createMockElement(tag: string): any {
|
||||
const el: any = {
|
||||
nodeType: 1,
|
||||
tagName: tag.toUpperCase(),
|
||||
children: [],
|
||||
style: {},
|
||||
ownerDocument: doc,
|
||||
get options() {
|
||||
return el.children.filter((c: any) => c.tagName === 'OPTION')
|
||||
},
|
||||
get length() {
|
||||
return el.children.filter((c: any) => c.tagName === 'OPTION').length
|
||||
},
|
||||
setAttribute(k: string, v: string) { el[k] = v },
|
||||
removeAttribute(k: string) { delete el[k] },
|
||||
getAttribute(k: string) { return el[k] },
|
||||
appendChild(c: any) {
|
||||
el.children.push(c)
|
||||
c.parentNode = el
|
||||
if (c.tagName === 'OPTION') {
|
||||
const idx = el.children.filter((x: any) => x.tagName === 'OPTION').indexOf(c)
|
||||
el[idx] = c
|
||||
}
|
||||
return c
|
||||
},
|
||||
removeChild(c: any) {
|
||||
const i = el.children.indexOf(c)
|
||||
if (i >= 0) el.children.splice(i, 1)
|
||||
delete c.parentNode
|
||||
},
|
||||
insertBefore(newNode: any, refNode: any) {
|
||||
const i = el.children.indexOf(refNode)
|
||||
if (i >= 0) el.children.splice(i, 0, newNode)
|
||||
else el.children.push(newNode)
|
||||
newNode.parentNode = el
|
||||
return newNode
|
||||
},
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
}
|
||||
return el
|
||||
}
|
||||
|
||||
const doc: any = {
|
||||
nodeType: 9,
|
||||
createElement(tag: string) { return createMockElement(tag) },
|
||||
createElementNS(_ns: string, tag: string) { return createMockElement(tag) },
|
||||
createTextNode(text: string) {
|
||||
return { nodeType: 3, nodeValue: text, ownerDocument: doc }
|
||||
},
|
||||
createComment(text: string) {
|
||||
return { nodeType: 8, nodeValue: text, ownerDocument: doc }
|
||||
},
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
defaultView: globalThis,
|
||||
}
|
||||
doc.ownerDocument = doc
|
||||
globalThis.document = doc
|
||||
globalThis.window = globalThis as any
|
||||
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
return {
|
||||
container: createMockElement('div'),
|
||||
cleanup() {
|
||||
globalThis.document = origDoc
|
||||
globalThis.window = origWindow
|
||||
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = origAct
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function serializeDom(el: any): string {
|
||||
if (!el) return ''
|
||||
if (el.nodeType === 3) return el.nodeValue || ''
|
||||
if (el.nodeType === 8) return ''
|
||||
let out = `<${el.tagName.toLowerCase()}`
|
||||
if (el.className) out += ` class="${el.className}"`
|
||||
out += '>'
|
||||
if (el.textContent && el.children.length === 0) {
|
||||
out += el.textContent
|
||||
} else {
|
||||
for (const c of el.children) out += serializeDom(c)
|
||||
}
|
||||
out += `</${el.tagName.toLowerCase()}>`
|
||||
return out
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
commandsCalled = []
|
||||
mockEnv = setupMockDom()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mockEnv.cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('当 rdm.project.member.list 返回 canManage=false 时:不请求 rdm.role.options,添加成员与移除按钮缺席', async () => {
|
||||
vi.spyOn(api, 'rdmProjectCommand').mockImplementation(async (_p, id, params) => {
|
||||
commandsCalled.push({ id, params })
|
||||
if (id === 'rdm.project.member.list') {
|
||||
return { members: mockRows, canManage: false, total: 2 } as any
|
||||
}
|
||||
if (id === 'rdm.role.options') {
|
||||
return { roles: [{ roleId: 'r1', name: '架构师' }] } as any
|
||||
}
|
||||
return {} as any
|
||||
})
|
||||
|
||||
const root = createRoot(mockEnv.container)
|
||||
await act(async () => {
|
||||
root.render(createElement(RdmMembersSection, { project }))
|
||||
})
|
||||
|
||||
// 等待 useEffect 及微任务完成
|
||||
await act(async () => {
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
})
|
||||
|
||||
const html = serializeDom(mockEnv.container)
|
||||
// 证明请求了成员列表,但未请求 rdm.role.options
|
||||
expect(commandsCalled.some(c => c.id === 'rdm.project.member.list')).toBe(true)
|
||||
expect(commandsCalled.some(c => c.id === 'rdm.role.options')).toBe(false)
|
||||
|
||||
// 证明添加成员和移除按钮缺席,显示只读提示
|
||||
expect(html).not.toContain('添加成员')
|
||||
expect(html).not.toContain('移除')
|
||||
expect(html).toContain('仅项目创建者可管理成员;你可以查看成员列表。')
|
||||
})
|
||||
|
||||
it('当 rdm.project.member.list 返回 canManage=true 时:触发 rdm.role.options 请求,呈现添加成员与移除按钮', async () => {
|
||||
vi.spyOn(api, 'rdmProjectCommand').mockImplementation(async (_p, id, params) => {
|
||||
commandsCalled.push({ id, params })
|
||||
if (id === 'rdm.project.member.list') {
|
||||
return { members: mockRows, canManage: true, total: 2 } as any
|
||||
}
|
||||
if (id === 'rdm.role.options') {
|
||||
return { roles: [{ roleId: 'r1', name: '架构师' }] } as any
|
||||
}
|
||||
return {} as any
|
||||
})
|
||||
|
||||
const root = createRoot(mockEnv.container)
|
||||
await act(async () => {
|
||||
root.render(createElement(RdmMembersSection, { project }))
|
||||
})
|
||||
|
||||
// 等待 useEffect 及微任务完成
|
||||
await act(async () => {
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
})
|
||||
|
||||
const html = serializeDom(mockEnv.container)
|
||||
// 证明请求了成员列表,且在 canManage=true 后请求了 rdm.role.options
|
||||
expect(commandsCalled.some(c => c.id === 'rdm.project.member.list')).toBe(true)
|
||||
expect(commandsCalled.some(c => c.id === 'rdm.role.options')).toBe(true)
|
||||
|
||||
// 证明添加成员和移除按钮存在
|
||||
expect(html).toContain('添加成员')
|
||||
expect(html).toContain('移除')
|
||||
expect(html).not.toContain('仅项目创建者可管理成员')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { PermissionDescriptor, RoleRecord, UserRoleAssignmentPreview } from '../src/api'
|
||||
import {
|
||||
assignedToRole, clearSelection, copyAssignRequest, createSelection, memberAssignments, pageSelectionState, previewAllowsAssign,
|
||||
previewTransition, refreshStateVersions, roleSourceValues, selectPage, selectionCounts, selectionSummary, staleUserIds, toggleOne,
|
||||
} from '../src/roleMembers'
|
||||
import { stateToValue } from '../src/rolePermissions'
|
||||
|
||||
const catalog: PermissionDescriptor[] = [
|
||||
{ actionId: 'system.roles.view', label: '查看角色', description: '', group: '角色管理', scope: 'system', protected: true },
|
||||
{ actionId: 'project.create', label: '创建项目', description: '', group: '项目入口', category: '项目与协作', scope: 'system', protected: false },
|
||||
{ actionId: 'document.read', label: '读取文档', description: '', group: '文档动作', category: '文档工作', scope: 'project', protected: false },
|
||||
{ actionId: 'document.author', label: '编制文档', description: '', group: '文档动作', category: '文档工作', scope: 'project', protected: false },
|
||||
]
|
||||
const source: RoleRecord = {
|
||||
roleId: 'role_engineer', name: '软件工程师', description: '研发', enabled: true, roleVersion: 7, bindingCount: 3,
|
||||
permissions: [{ actionId: 'document.read', allowed: true, capAllowed: true, permissionVersion: 1 }],
|
||||
}
|
||||
const preview = (userId: string, patch: Partial<UserRoleAssignmentPreview> = {}): UserRoleAssignmentPreview => ({
|
||||
userId, owner: userId, name: '', currentRoleId: '', currentRoleName: '', targetRoleId: 'role_engineer', targetRoleName: '软件工程师',
|
||||
stateMatches: true, ...patch,
|
||||
})
|
||||
|
||||
describe('cross-page member selection', () => {
|
||||
it('accumulates explicit picks across pages and counts the ones beyond the current page', () => {
|
||||
let selection = toggleOne(createSelection(), 'u1')
|
||||
selection = toggleOne(selection, 'u2')
|
||||
const pageTwo = ['u3', 'u4']
|
||||
selection = selectPage(selection, pageTwo, true)
|
||||
expect(selection.ids).toEqual(['u1', 'u2', 'u3', 'u4'])
|
||||
expect(selectionCounts(selection, pageTwo)).toEqual({ count: 4, beyondPage: 2 })
|
||||
expect(selectionSummary(selectionCounts(selection, pageTwo))).toBe('已选 4 人(包含其他页的 2 人)')
|
||||
expect(selectionSummary(selectionCounts(selection, ['u1', 'u2', 'u3', 'u4']))).toBe('已选 4 人')
|
||||
})
|
||||
|
||||
it('page select-all only touches the current page and reports a partial page', () => {
|
||||
const selection = selectPage(createSelection(['u9']), ['u1', 'u2'], true)
|
||||
expect(pageSelectionState(selection, ['u1', 'u2'])).toBe('all')
|
||||
const unpicked = selectPage(selection, ['u1', 'u2'], false)
|
||||
expect(unpicked.ids).toEqual(['u9'])
|
||||
expect(pageSelectionState(toggleOne(selection, 'u2', false), ['u1', 'u2'])).toBe('some')
|
||||
expect(pageSelectionState(unpicked, [])).toBe('none')
|
||||
})
|
||||
|
||||
it('keeps the selection when filters change and never widens it to unselected matches', () => {
|
||||
const selection = toggleOne(createSelection(), 'u1')
|
||||
const filteredPage = ['u5', 'u6']
|
||||
expect(selectionCounts(selection, filteredPage)).toEqual({ count: 1, beyondPage: 1 })
|
||||
expect(selection.ids).toEqual(['u1'])
|
||||
expect(toggleOne(selection, 'u1', true)).toBe(selection)
|
||||
expect(toggleOne(selection, 'u7', false)).toBe(selection)
|
||||
})
|
||||
|
||||
it('clears to an empty selection', () => {
|
||||
expect(clearSelection().ids).toEqual([])
|
||||
expect(selectionCounts(clearSelection(), ['u1'])).toEqual({ count: 0, beyondPage: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('member assignment entries', () => {
|
||||
it('submits stable userId/roleId pairs with each user\'s own state version, once per user', () => {
|
||||
expect(memberAssignments(['u1', 'u2', 'u1'], 'role_engineer', { u1: 3, u2: 8 })).toEqual([
|
||||
{ userId: 'u1', roleId: 'role_engineer', expectedStateVersion: 3 },
|
||||
{ userId: 'u2', roleId: 'role_engineer', expectedStateVersion: 8 },
|
||||
])
|
||||
})
|
||||
|
||||
it('refuses to build entries without a target role or a known state version', () => {
|
||||
expect(() => memberAssignments(['u1'], '', { u1: 3 })).toThrow('目标角色')
|
||||
expect(() => memberAssignments(['u1', 'u2'], 'role_engineer', { u1: 3 })).toThrow('状态版本')
|
||||
})
|
||||
|
||||
it('allows confirmation only when every submitted user previews cleanly', () => {
|
||||
expect(previewAllowsAssign([preview('u1'), preview('u2')], ['u1', 'u2'])).toBe(true)
|
||||
expect(previewAllowsAssign([preview('u1')], ['u1', 'u2'])).toBe(false)
|
||||
expect(previewAllowsAssign([preview('u1', { error: '用户已停用' })], ['u1'])).toBe(false)
|
||||
expect(previewAllowsAssign([preview('u1', { stateMatches: false })], ['u1'])).toBe(false)
|
||||
expect(previewAllowsAssign([], [])).toBe(false)
|
||||
expect(staleUserIds([preview('u1', { stateMatches: false }), preview('u2')])).toEqual(['u1'])
|
||||
expect(previewTransition(preview('u1'))).toBe('未分配 → 软件工程师')
|
||||
expect(previewTransition(preview('u1', { currentRoleName: '审核工程师' }))).toBe('审核工程师 → 软件工程师')
|
||||
})
|
||||
|
||||
it('re-reads only stale users through the read-only detail endpoint', async () => {
|
||||
const adminGetUser = vi.fn(async (userId: string) => ({ userId, stateVersion: 12 }))
|
||||
expect(await refreshStateVersions({ adminGetUser }, ['u1', 'u1'])).toEqual({ u1: { userId: 'u1', stateVersion: 12 } })
|
||||
expect(adminGetUser).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('marks directory results already bound to the role', () => {
|
||||
expect(assignedToRole({ role: { roleId: 'role_engineer', name: '软件工程师', enabled: true } }, 'role_engineer')).toBe(true)
|
||||
expect(assignedToRole({ role: null }, 'role_engineer')).toBe(false)
|
||||
expect(assignedToRole({}, 'role_engineer')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('copy role and assign request', () => {
|
||||
const base = {
|
||||
idempotencyKey: 'key-1', source, description: ' 独立维护 ', catalog,
|
||||
users: [{ userId: 'u1', stateVersion: 4 }],
|
||||
}
|
||||
|
||||
it('requires an explicit non-empty new role name', () => {
|
||||
expect(() => copyAssignRequest({ ...base, name: ' ', values: {} })).toThrow('名称')
|
||||
expect(() => copyAssignRequest({ ...base, name: '新角色', values: {}, users: [] })).toThrow('用户')
|
||||
})
|
||||
|
||||
it('sends only actions whose value differs from the source role', () => {
|
||||
const request = copyAssignRequest({ ...base, name: ' 软件工程师(外包) ', values: {
|
||||
'document.read': stateToValue('default'),
|
||||
'document.author': stateToValue('project'),
|
||||
'project.create': stateToValue('denied'),
|
||||
} })
|
||||
expect(request).toEqual({
|
||||
idempotencyKey: 'key-1',
|
||||
source: { roleId: 'role_engineer', expectedRoleVersion: 7 },
|
||||
name: '软件工程师(外包)', description: '独立维护',
|
||||
overrides: [{ actionId: 'document.author', allowed: false, capAllowed: true }],
|
||||
assignments: [{ userId: 'u1', expectedStateVersion: 4 }],
|
||||
})
|
||||
})
|
||||
|
||||
it('omits overrides entirely when nothing was adjusted', () => {
|
||||
const request = copyAssignRequest({ ...base, name: '副本', values: { 'document.read': stateToValue('default') } })
|
||||
expect(request).not.toHaveProperty('overrides')
|
||||
expect(request.assignments[0]).not.toHaveProperty('roleId')
|
||||
})
|
||||
|
||||
it('treats a role projection without permissions as an empty baseline instead of throwing', () => {
|
||||
expect(() => roleSourceValues({ permissions: undefined } as any, catalog)).not.toThrow()
|
||||
expect(roleSourceValues({ permissions: undefined } as any, catalog)).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { PermissionDescriptor, RoleRecord } from '../src/api'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { PermissionDescriptor, RolePermissionPreview, RoleRecord } from '../src/api'
|
||||
import {
|
||||
filterRoles, groupPermissionPage, hasSystemAction, shouldAcceptRoleResponse,
|
||||
NARROW_ROLE_QUERY, cellKind, commitRoleBatch, editRoleDraft, filterRoles, hasSystemAction, initialRoleView, memberStatusLabel, previewRoleBatch,
|
||||
rebaseRoleDrafts, roleCellValue, roleLeaveMessage, roleMembersLabel, shouldAcceptRoleResponse,
|
||||
} from '../src/components/RolePermissionsPanel'
|
||||
import { categoryActions, dirtyRoles, rolePatchEntries, stateToValue } from '../src/rolePermissions'
|
||||
|
||||
const roles: RoleRecord[] = [
|
||||
{ roleId: 'role_engineer', name: '软件工程师', description: '研发', enabled: true, roleVersion: 1, bindingCount: 2, permissions: [] },
|
||||
@@ -10,10 +12,14 @@ const roles: RoleRecord[] = [
|
||||
]
|
||||
|
||||
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 },
|
||||
{ actionId: 'system.roles.view', label: '查看角色', description: '', group: '角色管理', scope: 'system', protected: true },
|
||||
{ actionId: 'project.create', label: '创建项目', description: '', group: '项目入口', category: '项目与协作', scope: 'system', protected: false },
|
||||
{ actionId: 'document.read', label: '读取文档', description: '', group: '文档动作', category: '文档工作', scope: 'project', protected: false },
|
||||
{ actionId: 'document.author', label: '编制文档', description: '', group: '文档动作', category: '文档工作', scope: 'project', protected: false },
|
||||
]
|
||||
const editableIds = catalog.filter(item => !item.protected).map(item => item.actionId)
|
||||
const versions = (drafts: Record<string, { role: RoleRecord }>) =>
|
||||
Object.fromEntries(Object.entries(drafts).map(([roleId, draft]) => [roleId, draft.role.roleVersion]))
|
||||
|
||||
describe('role permission UI helpers', () => {
|
||||
it('combines exact or fuzzy search with the role status filter', () => {
|
||||
@@ -22,9 +28,10 @@ describe('role permission UI helpers', () => {
|
||||
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('shows one business category of matrix columns at a time, keeping stable action IDs and skipping protected grants', () => {
|
||||
expect(categoryActions(catalog, '文档工作').map(item => item.actionId)).toEqual(['document.read', 'document.author'])
|
||||
expect(categoryActions(catalog, '项目与协作').map(item => item.actionId)).toEqual(['project.create'])
|
||||
expect(categoryActions(catalog, '').map(item => item.actionId)).toEqual([])
|
||||
})
|
||||
|
||||
it('drops late results and checks explicit system actions', () => {
|
||||
@@ -33,4 +40,83 @@ describe('role permission UI helpers', () => {
|
||||
expect(hasSystemAction(['system.roles.view'], 'system.roles.view')).toBe(true)
|
||||
expect(hasSystemAction(['system.roles.view'], 'system.permissions.manage')).toBe(false)
|
||||
})
|
||||
|
||||
it('writes a matrix cell edit into that role\'s own draft without selecting it first', () => {
|
||||
const first = editRoleDraft({}, roles[0], catalog, 'document.read', stateToValue('project'))
|
||||
const both = editRoleDraft(first, roles[1], catalog, 'document.author', stateToValue('default'))
|
||||
expect(Object.keys(both)).toEqual(['role_engineer', 'role_reviewer'])
|
||||
expect(roleCellValue(both, roles[0], 'document.read')).toEqual({ allowed: false, capAllowed: true })
|
||||
expect(roleCellValue(both, roles[0], 'document.author')).toEqual({ allowed: false, capAllowed: false })
|
||||
expect(both.role_reviewer.role.roleId).toBe('role_reviewer')
|
||||
expect(dirtyRoles(both, editableIds)).toEqual([{ roleId: 'role_engineer', changes: 1 }, { roleId: 'role_reviewer', changes: 1 }])
|
||||
})
|
||||
|
||||
it('uses a two-state control for project.create and three states for project actions', () => {
|
||||
expect(cellKind(catalog[1])).toBe('toggle')
|
||||
expect(cellKind(catalog[2])).toBe('state')
|
||||
const on = editRoleDraft({}, roles[0], catalog, 'project.create', stateToValue('default'))
|
||||
expect(rolePatchEntries(on, versions(on), editableIds)[0].changes).toEqual([{ actionId: 'project.create', allowed: true, capAllowed: true }])
|
||||
})
|
||||
|
||||
it('saves every dirty role through one authoritative preview and one batch commit', async () => {
|
||||
let drafts = editRoleDraft({}, roles[0], catalog, 'document.read', stateToValue('default'))
|
||||
drafts = editRoleDraft(drafts, roles[1], catalog, 'project.create', stateToValue('default'))
|
||||
const entries = rolePatchEntries(drafts, versions(drafts), editableIds)
|
||||
const preview = (roleId: string): RolePermissionPreview => ({ roleId, roleName: roleId, expectedRoleVersion: 1, currentRoleVersion: 1,
|
||||
versionMatches: true, changes: [], linkedExpansion: [], keptLinkedActions: [], conflicts: [] })
|
||||
const client = {
|
||||
previewRolePermissions: vi.fn(async () => entries.map(entry => preview(entry.roleId))),
|
||||
batchPatchRolePermissions: vi.fn(async () => ({ receiptId: 'r1', authorityEpoch: 2, receipts: [] })),
|
||||
}
|
||||
const reviewed = await previewRoleBatch(client, entries)
|
||||
expect(reviewed.blocked).toBe(false)
|
||||
await commitRoleBatch(client, entries, reviewed.previews, 'key-1')
|
||||
expect(client.previewRolePermissions).toHaveBeenCalledWith(entries, { mode: 'patch' })
|
||||
expect(client.batchPatchRolePermissions).toHaveBeenCalledTimes(1)
|
||||
expect(client.batchPatchRolePermissions).toHaveBeenCalledWith([
|
||||
{ roleId: 'role_engineer', expectedRoleVersion: 1, changes: [{ actionId: 'document.read', allowed: true, capAllowed: true }] },
|
||||
{ roleId: 'role_reviewer', expectedRoleVersion: 1, changes: [{ actionId: 'project.create', allowed: true, capAllowed: true }] },
|
||||
], 'key-1')
|
||||
|
||||
const conflicted = [{ ...preview('role_engineer'), conflicts: [{ actionId: 'document.read', reason: '平台上限不允许' }] }]
|
||||
expect((await previewRoleBatch({ previewRolePermissions: async () => conflicted }, entries)).blocked).toBe(true)
|
||||
await expect(commitRoleBatch(client, entries, conflicted, 'key-2')).rejects.toThrow()
|
||||
expect(client.batchPatchRolePermissions).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rebases every dirty role on its latest version after a batch conflict', async () => {
|
||||
let drafts = editRoleDraft({}, roles[0], catalog, 'document.read', stateToValue('default'))
|
||||
drafts = editRoleDraft(drafts, roles[1], catalog, 'document.author', stateToValue('project'))
|
||||
const fresh: Record<string, RoleRecord> = {
|
||||
role_engineer: { ...roles[0], roleVersion: 4, permissions: [{ actionId: 'document.author', allowed: true, capAllowed: true, permissionVersion: 2 }] },
|
||||
role_reviewer: { ...roles[1], roleVersion: 9, permissions: [] },
|
||||
}
|
||||
const getRole = vi.fn(async (roleId: string) => fresh[roleId])
|
||||
const rebased = await rebaseRoleDrafts({ getRole }, drafts, ['role_engineer', 'role_reviewer'], catalog)
|
||||
expect(getRole).toHaveBeenCalledTimes(2)
|
||||
expect(rebased.drafts.role_engineer.values['document.read']).toEqual({ allowed: true, capAllowed: true })
|
||||
expect(rebased.drafts.role_engineer.values['document.author']).toEqual({ allowed: true, capAllowed: true })
|
||||
expect(rolePatchEntries(rebased.drafts, versions(rebased.drafts), editableIds)).toEqual([
|
||||
{ roleId: 'role_engineer', expectedRoleVersion: 4, changes: [{ actionId: 'document.read', allowed: true, capAllowed: true }] },
|
||||
{ roleId: 'role_reviewer', expectedRoleVersion: 9, changes: [{ actionId: 'document.author', allowed: false, capAllowed: true }] },
|
||||
])
|
||||
})
|
||||
|
||||
it('names every dirty role in the leave guard', () => {
|
||||
const message = roleLeaveMessage(['软件工程师', '审核工程师'])
|
||||
expect(message).toContain('「软件工程师」')
|
||||
expect(message).toContain('「审核工程师」')
|
||||
})
|
||||
|
||||
it('names the role member entry and member status for assistive technology', () => {
|
||||
expect(roleMembersLabel(roles[0])).toBe('查看角色 软件工程师 的成员')
|
||||
expect(memberStatusLabel({ enabled: true })).toBe('有效')
|
||||
expect(memberStatusLabel({ enabled: false })).toContain('停用')
|
||||
})
|
||||
|
||||
it('defaults to single-role focus on narrow screens and to the matrix otherwise', () => {
|
||||
expect(initialRoleView(query => query === NARROW_ROLE_QUERY)).toBe('single')
|
||||
expect(initialRoleView(() => false)).toBe('matrix')
|
||||
expect(initialRoleView()).toBe('matrix')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { PermissionDescriptor } from '../src/api'
|
||||
import {
|
||||
createRolePermissionDraft, mergeRoleDraft, rebaseRoleDraft, updateRolePermission, validateRolePermissionDraft, visiblePatch,
|
||||
type RolePermissionValue,
|
||||
categoryActions, createRolePermissionDraft, dirtyRoles, linkedByProjectCreate, mergeRoleDraft, permissionCategories, rebaseRoleDraft,
|
||||
rolePatchEntries, stateToValue, updateRolePermission, validateRolePermissionDraft, valueToState, visiblePatch,
|
||||
type RolePermissionStateName, type RolePermissionValue,
|
||||
} from '../src/rolePermissions'
|
||||
|
||||
const initial: Record<string, RolePermissionValue> = {
|
||||
@@ -48,3 +50,54 @@ describe('role permission draft', () => {
|
||||
expect(rebased.values['document.read']).toEqual({ allowed: false, capAllowed: true })
|
||||
})
|
||||
})
|
||||
|
||||
const catalog: PermissionDescriptor[] = [
|
||||
{ actionId: 'system.roles.view', label: '查看角色', description: '', group: '角色管理', scope: 'system', protected: true },
|
||||
{ actionId: 'document.read', label: '读取文档', description: '', group: '文档动作', category: '文档工作', scope: 'project', protected: false },
|
||||
{ actionId: 'trace.edit', label: '编辑追溯网', description: '', group: '项目内容', category: '项目内容', scope: 'project', protected: false },
|
||||
{ actionId: 'project.create', label: '创建项目', description: '', group: '项目入口', scope: 'system', protected: false },
|
||||
{ actionId: 'project.settings', label: '项目设置', description: '', group: '项目管理', category: '项目与协作', scope: 'project', protected: false },
|
||||
{ actionId: 'document.author', label: '编制文档', description: '', group: '文档动作', category: '文档工作', scope: 'project', protected: false },
|
||||
]
|
||||
|
||||
describe('role permission tri-state and categories', () => {
|
||||
it('maps the three display states to stored values and back', () => {
|
||||
expect(stateToValue('denied')).toEqual({ allowed: false, capAllowed: false })
|
||||
expect(stateToValue('project')).toEqual({ allowed: false, capAllowed: true })
|
||||
expect(stateToValue('default')).toEqual({ allowed: true, capAllowed: true })
|
||||
for (const state of ['denied', 'project', 'default'] as RolePermissionStateName[]) expect(valueToState(stateToValue(state))).toBe(state)
|
||||
expect(valueToState(undefined)).toBe('denied')
|
||||
})
|
||||
|
||||
it('groups actions in the fixed category order and keeps catalog order inside a category', () => {
|
||||
expect(permissionCategories(catalog)).toEqual(['项目与协作', '文档工作', '项目内容'])
|
||||
expect(categoryActions(catalog, '项目与协作').map(item => item.actionId)).toEqual(['project.create', 'project.settings'])
|
||||
expect(categoryActions(catalog, '文档工作').map(item => item.actionId)).toEqual(['document.read', 'document.author'])
|
||||
expect(permissionCategories(catalog).flatMap(category => categoryActions(catalog, category)).some(item => item.protected)).toBe(false)
|
||||
})
|
||||
|
||||
it('counts only roles whose drafts differ from their baseline', () => {
|
||||
const clean = createRolePermissionDraft(initial)
|
||||
const edited = updateRolePermission(updateRolePermission(createRolePermissionDraft(initial), 'document.read', stateToValue('project')),
|
||||
'document.author', stateToValue('default'))
|
||||
const reverted = updateRolePermission(updateRolePermission(createRolePermissionDraft(initial), 'document.read', stateToValue('denied')),
|
||||
'document.read', initial['document.read'])
|
||||
expect(dirtyRoles({ clean, edited, reverted })).toEqual([{ roleId: 'edited', changes: 2 }])
|
||||
})
|
||||
|
||||
it('builds one patch entry per dirty role with only that role\'s changed actions', () => {
|
||||
const a = updateRolePermission(createRolePermissionDraft(initial), 'project.create', { allowed: true, capAllowed: true })
|
||||
const b = updateRolePermission(createRolePermissionDraft(initial), 'document.read', stateToValue('denied'))
|
||||
const entries = rolePatchEntries({ a, b, c: createRolePermissionDraft(initial) }, { a: 3, b: 7, c: 1 }, Object.keys(initial))
|
||||
expect(entries).toEqual([
|
||||
{ roleId: 'a', expectedRoleVersion: 3, changes: [{ actionId: 'project.create', allowed: true, capAllowed: true }] },
|
||||
{ roleId: 'b', expectedRoleVersion: 7, changes: [{ actionId: 'document.read', allowed: false, capAllowed: false }] },
|
||||
])
|
||||
})
|
||||
|
||||
it('marks linked actions only while project.create is allowed', () => {
|
||||
expect(linkedByProjectCreate({ 'project.create': { allowed: true, capAllowed: true } }, 'project.settings')).toBe(true)
|
||||
expect(linkedByProjectCreate({ 'project.create': { allowed: true, capAllowed: true } }, 'document.read')).toBe(false)
|
||||
expect(linkedByProjectCreate({ 'project.create': { allowed: false, capAllowed: true } }, 'project.settings')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
+3
-9
@@ -26,18 +26,12 @@ describe('mergeStartedRun', () => {
|
||||
})
|
||||
|
||||
describe('eventTime', () => {
|
||||
it('parses normal ISO server timestamps', () => {
|
||||
const iso = '2026-10-03T14:50:25.073Z'
|
||||
expect(eventTime({ ts: iso }, 0)).toBe(Date.parse(iso))
|
||||
expect(eventTime({ ts: iso }, 42)).toBe(Date.parse(iso))
|
||||
it('parses the server timestamp', () => {
|
||||
expect(eventTime({ ts: '2026-10-03T14:50:25.073Z' }, 0)).toBe(Date.parse('2026-10-03T14:50:25.073Z'))
|
||||
})
|
||||
|
||||
it('falls back to now when timestamp is missing, invalid or zero-time 0001-01-01T00:00:00Z', () => {
|
||||
it('falls back when the timestamp is missing or unparseable', () => {
|
||||
expect(eventTime({}, 42)).toBe(42)
|
||||
expect(eventTime({ ts: undefined }, 42)).toBe(42)
|
||||
expect(eventTime({ ts: '' }, 42)).toBe(42)
|
||||
expect(eventTime({ ts: 'not-a-time' }, 42)).toBe(42)
|
||||
expect(eventTime({ ts: '0001-01-01T00:00:00Z' }, 42)).toBe(42)
|
||||
expect(eventTime({ ts: '1970-01-01T00:00:00Z' }, 42)).toBe(42)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
// 超级管理员 UI 门控(计划 2026-10-19-0113 R3/R4/R8):身份标签可见;建号为管理员与
|
||||
// 任命/撤销入口只对超级管理员出现;超级管理员不可降级、停用或被普通管理员维护。
|
||||
// 服务端授权仍为权威,这里只锁定前端呈现与请求体。
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { api } from '../src/api'
|
||||
import { auth } from '../src/auth'
|
||||
import { accountActionPolicy, accountIdentityKind, canAppointAdmins } from '../src/accountAuthority'
|
||||
import { AdminAppointmentButton, CreateAdminOption, IdentityChip } from '../src/components/AccountUsersPanel'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
const superAdmin = { isAdmin: true, isSuperAdmin: true }
|
||||
const admin = { isAdmin: true, isSuperAdmin: false }
|
||||
const user = { isAdmin: false, isSuperAdmin: false }
|
||||
const noop = () => undefined
|
||||
|
||||
describe('身份标签', () => {
|
||||
it('超级管理员、管理员与普通用户各有可辨识标签', () => {
|
||||
expect(accountIdentityKind(superAdmin)).toBe('super')
|
||||
expect(accountIdentityKind(admin)).toBe('admin')
|
||||
expect(accountIdentityKind(user)).toBe('user')
|
||||
expect(renderToStaticMarkup(createElement(IdentityChip, { account: superAdmin }))).toContain('超级管理员')
|
||||
expect(renderToStaticMarkup(createElement(IdentityChip, { account: admin }))).toContain('>管理员<')
|
||||
expect(renderToStaticMarkup(createElement(IdentityChip, { account: user }))).toContain('普通用户')
|
||||
})
|
||||
it('isAdmin 不推导超级管理员身份', () => {
|
||||
expect(accountIdentityKind({ isAdmin: true })).toBe('admin')
|
||||
})
|
||||
})
|
||||
|
||||
describe('管理员任命入口只对超级管理员开放', () => {
|
||||
it('建号:超级管理员看到「创建为管理员账号」,普通管理员只见普通建号说明', () => {
|
||||
expect(canAppointAdmins(superAdmin)).toBe(true)
|
||||
expect(canAppointAdmins(admin)).toBe(false)
|
||||
expect(canAppointAdmins(null)).toBe(false)
|
||||
const superHtml = renderToStaticMarkup(createElement(CreateAdminOption, { me: superAdmin, checked: false, disabled: false, onChange: noop }))
|
||||
expect(superHtml).toContain('type="checkbox"')
|
||||
expect(superHtml).toContain('创建为管理员账号')
|
||||
const adminHtml = renderToStaticMarkup(createElement(CreateAdminOption, { me: admin, checked: false, disabled: false, onChange: noop }))
|
||||
expect(adminHtml).not.toContain('checkbox')
|
||||
expect(adminHtml).toContain('仅超级管理员可创建或任命管理员')
|
||||
})
|
||||
it('任命/撤销:超级管理员可任命普通用户、撤销管理员;普通管理员看不到任何任命按钮', () => {
|
||||
expect(accountActionPolicy(superAdmin, user).adminChange).toBe('promote')
|
||||
expect(accountActionPolicy(superAdmin, admin).adminChange).toBe('demote')
|
||||
expect(renderToStaticMarkup(createElement(AdminAppointmentButton, { me: superAdmin, account: user, busy: false, onChange: noop }))).toContain('设为管理员')
|
||||
expect(renderToStaticMarkup(createElement(AdminAppointmentButton, { me: superAdmin, account: admin, busy: false, onChange: noop }))).toContain('撤销管理员')
|
||||
for (const target of [user, admin, superAdmin]) {
|
||||
expect(accountActionPolicy(admin, target).adminChange).toBeNull()
|
||||
expect(renderToStaticMarkup(createElement(AdminAppointmentButton, { me: admin, account: target, busy: false, onChange: noop }))).toBe('')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('超级管理员账号受保护', () => {
|
||||
it('超级管理员本人不可被降级或停用,也不走管理员重置密码', () => {
|
||||
const self = accountActionPolicy(superAdmin, superAdmin)
|
||||
expect(self).toMatchObject({ adminChange: null, toggleEnabled: false, resetPassword: false, maintain: true })
|
||||
expect(self.protectedReason).toContain('不能停用、降级')
|
||||
expect(renderToStaticMarkup(createElement(AdminAppointmentButton, { me: superAdmin, account: superAdmin, busy: false, onChange: noop }))).toBe('')
|
||||
})
|
||||
it('普通管理员不能编辑、停用、降级或重置超级管理员,并获得明确说明', () => {
|
||||
const policy = accountActionPolicy(admin, superAdmin)
|
||||
expect(policy).toMatchObject({ maintain: false, toggleEnabled: false, resetPassword: false, adminChange: null })
|
||||
expect(policy.protectedReason).toContain('超级管理员账号受保护')
|
||||
})
|
||||
it('普通账号的既有维护能力不受影响', () => {
|
||||
expect(accountActionPolicy(admin, user)).toMatchObject({ maintain: true, toggleEnabled: true, resetPassword: true, protectedReason: '' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('账号管理请求体', () => {
|
||||
const stub = (body: unknown) => vi.spyOn(auth, 'fetch').mockImplementation(async () => new Response(JSON.stringify(body)))
|
||||
|
||||
it('adminSetUserAdmin 以乐观版本 PUT /api/admin/users/:userId/admin', async () => {
|
||||
const request = stub({ userId: 'u 1' })
|
||||
await api.adminSetUserAdmin('u 1', 7, true)
|
||||
const [url, options] = request.mock.calls[0]
|
||||
expect(url).toBe('/api/admin/users/u%201/admin')
|
||||
expect(options?.method).toBe('PUT')
|
||||
expect(JSON.parse(String(options?.body))).toEqual({ expectedStateVersion: 7, isAdmin: true })
|
||||
})
|
||||
it('建号只在请求管理员身份时携带 isAdmin', async () => {
|
||||
const request = stub({ userId: 'u1', owner: 'alice' })
|
||||
await api.createManagedUser('alice', { name: 'A' })
|
||||
await api.createManagedUser('bob', {}, true)
|
||||
await api.createAdminUser('carol', 'C')
|
||||
await api.createAdminUser('dave', 'D', undefined, true)
|
||||
const bodies = request.mock.calls.map(call => JSON.parse(String(call[1]?.body)))
|
||||
expect(bodies[0]).toEqual({ owner: 'alice', profile: { name: 'A' } })
|
||||
expect(bodies[1]).toEqual({ owner: 'bob', profile: {}, isAdmin: true })
|
||||
expect(bodies[2]).toEqual({ owner: 'carol', profile: { name: 'C' } })
|
||||
expect(bodies[3]).toEqual({ owner: 'dave', profile: { name: 'D' }, isAdmin: true })
|
||||
})
|
||||
})
|
||||
+1
-60
@@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { api } from '../src/api'
|
||||
import { store, mergeTaskSnapshot } from '../src/store'
|
||||
import { store } from '../src/store'
|
||||
import type { ActivityCard, Conversation, Message, Run, StreamEvent, SurfaceSnapshot } from '../src/contracts'
|
||||
|
||||
function deferred<T>() {
|
||||
@@ -32,7 +32,6 @@ beforeEach(async () => {
|
||||
vi.spyOn(api, 'getPlan').mockResolvedValue(null)
|
||||
vi.spyOn(api, 'getTodos').mockResolvedValue(null)
|
||||
vi.spyOn(api, 'listCards').mockResolvedValue([])
|
||||
vi.spyOn(api,'listCardsSnapshot').mockImplementation(async id=>({cards:await api.listCards(id)}))
|
||||
vi.spyOn(api, 'listTasks').mockResolvedValue([])
|
||||
vi.spyOn(api, 'listArtifacts').mockResolvedValue([])
|
||||
vi.spyOn(api, 'surfaceList').mockResolvedValue([])
|
||||
@@ -519,61 +518,3 @@ describe('shared action feedback', () => {
|
||||
expect(store.getState().cards).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a newer task result when an older projection arrives in a later stream event',()=>{
|
||||
const task=(revision:number,executionState:string)=>({...card(),task:{schemaVersion:1,taskId:'t',attemptId:'a1',cardId:'task',projectId:'p',conversationId:'a',ownerInstanceId:'i',executionEpoch:1,revision,sourceSeq:revision,streamScope:'conversation:a',title:'整理',executionState,outcomeCompleteness:'partial',deliveryState:'available',phase:{id:'reading',label:'读取材料'}}}) as ActivityCard
|
||||
store.applyEvent(event(1,'card.created',{card:task(9,'completed')}))
|
||||
store.applyEvent(event(2,'card.updated',{card:task(8,'running')}))
|
||||
expect(store.getState().cards[0].task?.revision).toBe(9)
|
||||
expect(store.getState().cards[0].task?.executionState).toBe('completed')
|
||||
})
|
||||
it('does not lose an event behind a detected sequence gap',async()=>{
|
||||
vi.mocked(api.listCardsSnapshot).mockResolvedValue({cards:[],replaySeq:0})
|
||||
await store.refreshCards('a')
|
||||
store.applyEvent(event(2,'card.updated',{card:card('later')}))
|
||||
expect(store.getState().lastSeq).toBe(0)
|
||||
store.applyEvent(event(1,'card.created',{card:card('earlier')}))
|
||||
await Promise.resolve()
|
||||
expect(store.getState().cards.map(c=>c.id)).toEqual(['later','earlier'])
|
||||
expect(store.getState().lastSeq).toBe(2)
|
||||
})
|
||||
|
||||
it('rejects old revisions and foreign attempts in the task merge contract',()=>{
|
||||
expect(mergeTaskSnapshot({taskId:'t',attemptId:'a',revision:9},{taskId:'t',attemptId:'a',revision:8}).revision).toBe(9)
|
||||
expect(mergeTaskSnapshot({taskId:'t',attemptId:'a',revision:9},{taskId:'t',attemptId:'b',revision:10}).attemptId).toBe('a')
|
||||
})
|
||||
|
||||
it('keeps the newer explicit preview when a source response arrives late',async()=>{
|
||||
const source={id:'s',artifactId:'a',contentHash:'hash',parserRevision:'v1',blockIndex:0,label:'A'}
|
||||
const pending=deferred<Awaited<ReturnType<typeof api.taskSource>>>()
|
||||
vi.spyOn(api,'taskSource').mockReturnValue(pending.promise)
|
||||
const {docBridge}=await import('../src/components/Workbench')
|
||||
const opening=docBridge.onOpenTaskSource!('card',source)
|
||||
store.requestPreview({id:'b',name:'B',kind:'document',projectId:'p',createdAt:'',updatedAt:'',size:0})
|
||||
pending.resolve({artifact:{id:'a',name:'A',kind:'document',projectId:'p',createdAt:'',updatedAt:'',size:0},source,versionState:'current'})
|
||||
await opening
|
||||
expect(store.getState().previewActive.a).toBe('b')
|
||||
})
|
||||
|
||||
it('rejects a source response after leaving and returning to the same conversation',async()=>{
|
||||
const source={id:'s',artifactId:'a',contentHash:'hash',parserRevision:'v1',blockIndex:0,label:'A'}
|
||||
const pending=deferred<Awaited<ReturnType<typeof api.taskSource>>>()
|
||||
vi.spyOn(api,'taskSource').mockReturnValue(pending.promise)
|
||||
const opening=store.openTaskSource('card',source)
|
||||
await store.openConversation('b');await store.openConversation('a')
|
||||
store.requestPreview({id:'new',name:'New',kind:'document',projectId:'p',createdAt:'',updatedAt:'',size:0})
|
||||
pending.resolve({artifact:{id:'a',name:'A',kind:'document',projectId:'p',createdAt:'',updatedAt:'',size:0},source,versionState:'current'})
|
||||
await opening
|
||||
expect(store.getState().previewActive.a).toBe('new')
|
||||
})
|
||||
it('keeps the activated tab when a source request is still pending',async()=>{
|
||||
store.requestPreview({id:'b',name:'B',kind:'document',projectId:'p',createdAt:'',updatedAt:'',size:0})
|
||||
store.requestPreview({id:'c',name:'C',kind:'document',projectId:'p',createdAt:'',updatedAt:'',size:0})
|
||||
const source={id:'s',artifactId:'a',contentHash:'hash',parserRevision:'v1',blockIndex:0,label:'A'}
|
||||
const pending=deferred<Awaited<ReturnType<typeof api.taskSource>>>()
|
||||
vi.spyOn(api,'taskSource').mockReturnValue(pending.promise)
|
||||
const opening=store.openTaskSource('card',source);store.activatePreview('b')
|
||||
pending.resolve({artifact:{id:'a',name:'A',kind:'document',projectId:'p',createdAt:'',updatedAt:'',size:0},source,versionState:'current'})
|
||||
await opening
|
||||
expect(store.getState().previewActive.a).toBe('b')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user