Compare commits

..
Author SHA1 Message Date
agent-exec-transport b496c4476d agent-exec transport 5cc70be566de95cb35d2d8bdfb05b392/b0fcba7575e5
governance / governance (pull_request) Successful in 22s
ci / test-and-build (pull_request) Successful in 43s
2026-10-20 03:54:20 +00:00
weichen d7768d4ea0 Merge pull request 'Switch account UI to administrator one-time passwords' (#114) from feature/auth-one-time-password-r2 into main
governance / governance (push) Successful in 17s
ci / test-and-build (push) Successful in 27s
2026-10-20 01:56:52 +00:00
agent-exec-transport 3c22d0957a agent-exec transport f9152d30e9410417157ea4a3a9a926ba/e09f009fa9f5
governance / governance (pull_request) Successful in 23s
ci / test-and-build (pull_request) Successful in 36s
2026-10-20 01:48:41 +00:00
agent-exec-transport e296d38ec2 agent-exec transport 0bde562073461c828c740d52a55a8b5d/a5fbfeb4311f 2026-10-20 01:19:46 +00:00
weichen 259021d530 Merge pull request 'Merge the duplicate rdmManagement import in adminRdmSections' (#113) from fix/nonblocking-followups into main
governance / governance (push) Successful in 17s
ci / test-and-build (push) Successful in 39s
2026-10-19 14:31:28 +00:00
agent-exec-transport 7d43ef6ff7 agent-exec transport a4840fd95e17330e094af504afeba096/59a2059a0705
governance / governance (pull_request) Successful in 15s
ci / test-and-build (pull_request) Successful in 36s
2026-10-19 14:25:36 +00:00
weichen 9baf96d937 Merge pull request 'Merge the accepted super-admin changes into main' (#112) from feature/super-admin-merge into main
governance / governance (push) Successful in 16s
ci / test-and-build (push) Successful in 26s
2026-10-19 09:43:13 +00:00
weichen dab16052cf Merge feature/super-admin history onto main
governance / governance (pull_request) Successful in 40s
ci / test-and-build (pull_request) Successful in 54s
2026-10-19 09:40:42 +00:00
agent-exec-transport ce15f7977b agent-exec transport 548284a3917af91d24d89efd66e5ec7c/d0c96d333a18 2026-10-19 09:40:34 +00:00
weichen f560201f8b Merge pull request 'Verify progressive material states in a real browser' (#79) from feature/fast-slow-u2-u3 into main
governance / governance (push) Successful in 15s
ci / test-and-build (push) Successful in 26s
2026-10-19 09:20:39 +00:00
agent-exec-transport 6f8e7ec604 agent-exec transport 9f91fe4ba0c0b0412d4616549f170f87/cff15f49e8bf
governance / governance (pull_request) Successful in 17s
ci / test-and-build (pull_request) Successful in 37s
2026-10-19 05:25:04 +00:00
agent-exec-transport aa49e0dbf3 agent-exec transport 3646d6259c27d42bf24f52c61364d67e/bea155d33d2e
governance / governance (pull_request) Successful in 14s
ci / test-and-build (pull_request) Successful in 30s
2026-10-19 05:10:37 +00:00
weichen 9df5517e9c Merge Frontend main into feature/super-admin
Why: Synchronize Frontend main (IAM U3 account UI #86) into the super-admin branch and resolve conflict in api.ts, AccountUsersPanel.tsx and adminSections.tsx as unions.

Evidence: npm test passed (49 files, 574 tests), check-knip passed.

Impact: AccountUsersPanel supports both super-admin identity/elevation and U3 initialization/credential states.

Refs: #627, Frontend PR #86
2026-10-19 03:49:39 +00:00
weichen b4fec2fdf4 Merge feature/fast-slow-u2-u3 history after syncing with main
governance / governance (pull_request) Successful in 20s
ci / test-and-build (pull_request) Successful in 35s
2026-10-19 03:38:49 +00:00
agent-exec-transport 164ea66039 agent-exec transport 9f1c4114b4dc96cd086583d1c03cd7a4/07c6c40e6ebc 2026-10-19 03:37:22 +00:00
weichen 23877af960 Fix unused exports in accountAuthority for knip guard 2026-10-19 03:23:04 +00:00
agent-exec-transport e6bb387f71 agent-exec transport 1923e710321578c193e3dc882b41cf2f/7f9287b28660 2026-10-19 03:17:22 +00:00
weichen 1544781c28 Verify queued and failed material states in browser
Why: Users must see truthful first replies when work queues or cannot start.

Evidence: RED and GREEN HTTP browser receipts plus Frontend 269 tests and production build.

Impact: Adds two task-owned Playwright scenarios; production UI is unchanged.

Refs: U3 r0.5 independent review finding
2026-10-14 09:13:25 +00:00
weichen cfabed8fae Cover safe material intake across browser paths
Why: The first reply and task reuse need end-to-end checks across long and small material inputs.

Evidence: Dev evidence receipts for ten Playwright scenarios, Frontend tests, and build.

Impact: Adds browser-only coverage; production UI and API behavior are unchanged.

Refs: U3 r0.5 implementation plan Task 4
2026-10-14 08:40:21 +00:00
weichen c9d5acf089 Count batched failure notices in browser validation
Why: One system reminder can contain multiple failed child notices, so the browser test must wait for the whole batch rather than one message per child.

Evidence: The first post-merge HTTP/Playwright run failed only at this false one-to-one assumption; all six task-owned browser cases passed after counting notice entries in the persisted reminder.

Impact: Test-only correction; no production UI behavior change.

Refs: U3 r0.4 Task 6 review closure
2026-10-14 04:06:22 +00:00
weichen 139f2ce654 Merge current Frontend main into U3 browser checks
Why: Keep the approved RDM export controls and the U3 material browser coverage in one component candidate.

Evidence: Changes in store.ts touch separate state fields and behavior; full Frontend tests/build and task-owned browser cases will be rerun after this merge.

Impact: Includes upstream RDM export UI without changing U3 source or notice contracts.

Refs: Dev U3 r0.4 Task 6 final review synchronization
2026-10-14 04:04:05 +00:00
weichen 01ec12e77c Wait for settled material notices in browser checks
Why: A failed task card can appear before its notification turn, and the second finding must be observed while work is still running.

Evidence: The strengthened browser checks first exposed missing fixture notice text and a zero-width second-stage window; after fixture correction all six task-owned HTTP/Playwright scenarios passed. Frontend 265 unit tests and build passed.

Impact: Test coverage only; no production UI change.

Refs: U3 r0.4 independent review Important 5
2026-10-14 03:57:38 +00:00
weichen 34950997f8 Cover progressive material outcomes in browser tests
Why: The r0.4 acceptance must show running findings, zero and partial failure, and a source-bound choice in the real UI.

Evidence: Six task-owned HTTP/Playwright cases passed; npm test (265 tests) and npm run build passed.

Impact: Browser test coverage only; no production UI change.

Refs: U3 r0.4 Task 6
2026-10-14 03:34:23 +00:00
weichen 006c815675 Check pending membership in the real HTTP work view
Why: The U3 browser gate must show a host-generated pending question for underdetermined source counts.

Evidence: The new assertion failed against the old mechanism fixture while existing source-navigation and focus-flow tests remained observable; Backend fixture now emits structured premises.

Impact: Browser test inputs and assertion only; no production UI change.

Refs: Backend 756679d; Dev task feature/fast-slow-u2-u3-plan
2026-10-13 17:02:03 +00:00
weichen 9d7681cec4 Keep generated evidence helpers module-local
Why: The Core mirror must satisfy the Frontend unused-export guard without changing the candidate shape.

Evidence: Dev make contracts regenerated the file; Frontend knip 51 <= 52 and build passed.

Impact: Type-only export visibility change.

Refs: Core b474394; Dev task feature/fast-slow-u2-u3-plan
2026-10-13 16:49:56 +00:00
weichen 5bc2e12c01 Sync U3 evidence candidate types from Core
Why: Frontend generated types must follow the single Core checkpoint contract.

Evidence: Dev make contracts generated the mirror; npm test and npm run build passed.

Impact: Type-only mirror update; persisted task cards and reading behavior stay unchanged.

Refs: Core c722c16; Dev task feature/fast-slow-u2-u3-plan
2026-10-13 16:34:07 +00:00
weichen 64ba640b77 Merge remote-tracking branch 'origin/main' into feature/fast-slow-u2-u3 2026-10-13 15:49:29 +00:00
weichen 329bdf53d6 Merge remote-tracking branch 'origin/main' into feature/fast-slow-u2-u3 2026-10-13 10:19:21 +00:00
weichen 0bbccbe39e Merge current login recovery browser coverage
Why: Preserve current main before publishing the U2/U3 candidate.

Evidence: Exact main object fetched; browser-only upstream changes.

Impact: Retain both sets of browser coverage.

Refs: Agent-System-Dev U2/U3 delivery
2026-10-13 05:28:02 +00:00
weichen 3087b0621c Assert literal source records in work checkpoint browser flows
Why: Material records now display validated source quotations rather than unverified model paraphrases.

Evidence: Existing browser coverage retains refresh, changed-version, preview and focus checks; the updated assertions target actual source text.

Impact: Browser-only expectations change; product bundle remains the independently reviewed version.

Refs: Approved R05 and host material-record guard
2026-10-13 04:31:50 +00:00
weichen 4d1d64a83e Merge commit '9fc7da2ace8578d87d2601adcbf831e0b955aa68' into feature/fast-slow-u2-u3 2026-10-13 04:06:22 +00:00
weichen 861c45edb5 Keep delayed source responses behind the current preview intent
Why: Late source requests must not replace a newer preview or survive conversation navigation.

Evidence: Frontend typecheck, 221 unit tests, knip ratchet and build passed; delayed-response regressions reproduced the old failure.

Impact: The existing store owns source requests and invalidates stale responses on preview and context changes.

Refs: Agent-System-Dev feature-fast-slow-u2-u3-plan; independent review finding 4
2026-10-13 03:55:04 +00:00
weichen c63f6c7470 Gate checkpoint browser tests on an explicit owned instance
Why: Unrelated browser suites should skip this opt-in runtime acceptance instead of importing an absent manifest.

Evidence: Playwright discovers the two tests without an instance; explicit owned-instance replay passed previously.

Impact: Preserves strict manifest verification when invoked.

Refs: fast-slow-u2-u3 Task 7
2026-10-13 03:16:25 +00:00
weichen 75a420210d Exercise task recovery and source reading through real HTTP
Why: DOM fixtures alone cannot verify persisted checkpoints and source version handling.

Evidence: SELF source navigation and changed-version checks pass; reading replay is being repeated after the deferred runtime port fix.

Impact: Adds an explicit task-owned instance browser suite without paid model calls.

Refs: fast-slow-u2-u3 Task 7
2026-10-13 03:08:33 +00:00
weichen 175210f4bb Open processed source versions through the existing preview
Why: Source context must remain attached to the bytes being read.

Evidence: SELF frontend tests and production build pass.

Impact: Carries an optional expected hash and preserves one preview location.

Refs: fast-slow-u2-u3 Task 5 source-version acceptance
2026-10-13 02:51:07 +00:00
weichen b00fa4433b Merge commit 'dcd2699933eff51c06193cac7beb15c3d20a01ff' into feature/fast-slow-u2-u3 2026-10-13 02:44:19 +00:00
weichen 8be95c8807 Keep critical coverage and correction notices in conversation work
Why: A folded auxiliary panel must not hide material limits or withdrawn conclusions.

Evidence: SELF 213 frontend tests, type checks, knip ratchet, and bundle build pass.

Impact: Prioritizes existing task cards without opening files or changing focus.

Refs: fast-slow-u2-u3 Task 5
2026-10-13 02:36:57 +00:00
weichen 54fe339e65 Recover task projections without regressing work or reading state
Why: Replayed events, snapshot races, and source navigation must preserve authoritative work and user focus.

Evidence: SELF make evidence records 213 frontend tests, type checks, knip ratchet, and production build.

Impact: Uses the existing store for task revisions and gap replay; opens sources in the existing preview with version context.

Refs: fast-slow-u2-u3 Task 5; generated public contract types account for the measured knip baseline increase
2026-10-13 01:59:40 +00:00
weichen 82c08bd983 Consume persistent work contracts
Why: keep the workbench wire types aligned with Core.

Evidence: TypeScript check passed on the synchronized contract.

Impact: generated types only.

Refs: U2/U3 Task1
2026-10-13 01:09:31 +00:00
42 changed files with 1345 additions and 2507 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
UI_DEPLOY_KEY: ${{ secrets.UI_DEPLOY_KEY }}
UI_SHA: 5c2b1036dd1aff89ec4eeae0c96493694e33ad4c
UI_SHA: fadabceadeec210d52cda7e9cec71e988d2e4482
run: |
set -euo pipefail
printf 'context server=%s repository=%s sha=%s\n' "${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}" "${GITHUB_SHA}"
-419
View File
@@ -1,419 +0,0 @@
// 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)
})
})
+5 -5
View File
@@ -1,5 +1,5 @@
// 管理员随机临时密码与登录入口回归:管理员重置 → 用户密码登录 → 强制改密;
// 登录页不再提供一次性 Key 登录 tab。Key 的分发/管理 API 与非登录页流程保留。
// 管理员发放一次性登录密码与登录入口回归:管理员生成一次性密码
// → 用户密码登录 → 强制改密;登录页不再提供一次性 Key 登录 tab。
// 驱动:任意 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()
+202
View File
@@ -0,0 +1,202 @@
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
View File
@@ -1,3 +1,3 @@
{
"exports": 51
"exports": 58
}
+38
View File
@@ -0,0 +1,38 @@
// 账号管理动作呈现门控(超级管理员 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: '',
}
}
+26 -108
View File
@@ -14,26 +14,8 @@ const BASE = ''
* Registry.AddAdminZone 声明;入口不做前端权限合并,区内数据能力由组件
* 自身命令面 fail-closed。 */
export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number }
/** category:角色矩阵业务分类(项目与协作/文档工作/项目内容);受保护系统授权为空。 */
export interface PermissionDescriptor { actionId: string; label: string; description: string; group: string; category?: string; scope: 'system' | 'project'; protected: boolean }
export interface PermissionDescriptor { actionId: string; label: string; description: string; group: string; scope: 'system' | 'project'; protected: boolean }
export interface RolePermissionRecord { actionId: string; allowed: boolean; capAllowed: boolean; permissionVersion: number }
export interface 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[]
@@ -46,56 +28,29 @@ export interface UserRoleBinding {
export interface UserRoleView { binding?: UserRoleBinding; role?: RoleRecord }
/** 账户链路(2026-10-13 恢复合同 + 2026-10-14 current_required 加固):
* passwordChangeMode 由服务端从会话派生;grant_setup 免当前密码,
* passwordChangeMode 由服务端从会话派生;grant_setup/one_time_password 免当前密码,
* current_required 必须校验当前密码。 */
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
}
export type PasswordChangeMode = 'grant_setup' | 'current_required' | 'one_time_password'
/** 账号资料与档案视图(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; passwordSet?: boolean
enabled?: boolean; stateVersion?: number; isAdmin?: boolean; isSuperAdmin?: 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; passwordSet: boolean
enabled: boolean; stateVersion: number; isAdmin: boolean; isSuperAdmin: boolean; passwordSet: boolean
initializationState: string; initializationGeneration: number
role?: AccountRoleSummary | null
}
interface AccountSearchPage { accounts: ManagedAccountRecord[]; nextCursor?: string; directoryVersion: string }
/** 批量角色分配条目:一切以 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
export interface AdminIssuedOneTimePassword {
owner: string
id: string
oneTimePassword: string
mustChangePassword: boolean
}
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
@@ -140,35 +95,13 @@ 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)),
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)),
replaceRolePermissions: (roleId: string, expectedRoleVersion: number, changes: { actionId: string; allowed: boolean; capAllowed: boolean }[], idempotencyKey: string) =>
auth.fetch(`${BASE}/api/admin/roles/${encodeURIComponent(roleId)}/permissions`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ expectedRoleVersion, changes, idempotencyKey }) }).then(r => json<{ receiptId: string; roleVersion: number; authorityEpoch: number }>(r)),
getUserRole: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/role`).then(r => json<UserRoleView>(r)),
assignUserRole: (userId: string, roleId: string, expectedStateVersion: number, idempotencyKey: string) =>
auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/role`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ roleId, expectedStateVersion, idempotencyKey }) }).then(r => json<UserRoleBinding>(r)),
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' },
@@ -193,19 +126,11 @@ export const api = {
projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json<SurfaceMeta[]>(r)),
/** 单页权威快照(含 identity + spec),供列表水合与预览渲染。 */
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<SurfaceSnapshot>(r)),
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; sessionScope?: string; passwordChangeMode?: PasswordChangeMode; isAdmin?: boolean; mustChangePassword?: boolean; passwordSet?: boolean; systemActions?: string[]; linkedCapabilities?: LinkedCapabilities }>(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)),
// ---- 认证与会话(管理员发放制,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; 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 }
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string; passwordChangeMode?: PasswordChangeMode }
auth.beginSession()
return me
}),
@@ -216,37 +141,28 @@ 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; mustChangePassword: boolean; reloginRequired: boolean }>
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; reloginRequired: boolean }>
}),
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)),
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)),
// 管理员建号:可提供初始密码;省略密码时由服务端生成并仅返回一次。
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)),
// 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)),
searchAdminUsers: (request: { query: string; mode: 'exact' | 'fuzzy'; status: 'all' | 'enabled' | 'disabled'; fields: string[]; cursor?: string; limit?: number }) => {
const params = new URLSearchParams({ query: request.query, mode: request.mode, status: request.status,
fields: request.fields.join(','), limit: String(request.limit ?? 20) })
if (request.cursor) params.set('cursor', request.cursor)
return auth.fetch(`${BASE}/api/admin/users/search?${params}`).then(r => json<AccountSearchPage>(r))
},
createManagedUser: (owner: string, profile: Partial<AccountProfile>) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile }) }).then(r => json<ManagedAccountRecord>(r)),
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)),
adminGetUser: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}`).then(r => json<ManagedAccountRecord>(r)),
adminPatchProfile: (userId: string, expectedProfileVersion: number, patch: Partial<AccountProfile>) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/profile`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedProfileVersion, patch }) }).then(r => json<ManagedAccountRecord>(r)),
adminSetUserEnabled: (userId: string, expectedStateVersion: number, enabled: boolean) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/state`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedStateVersion, enabled }) }).then(r => json<ManagedAccountRecord>(r)),
// ---- 管理员:一次性登录 Key(HTTP 签发端点已被恢复提案契约替换为 410,客户端方法随之移除;
// list/revoke 仍有效,供恢复提案视图复用;兑换入口不变)----
adminListLoginKeys: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`).then(r => json<{ keys: { id: string; owner: string; purpose: string; status: string; expiresAt: string; createdAt: string; createdBy?: string }[] }>(r)).then(d => d.keys ?? []),
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)),
// ---- 本人:资料与恢复提案确认(须完整密码会话)----
// 任命/撤销管理员:仅超级管理员(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)),
// ---- 本人:资料(须完整密码会话)----
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)),
@@ -283,6 +199,8 @@ 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) =>
+59 -227
View File
@@ -1,19 +1,13 @@
import { useEffect, useRef, useState } from 'react'
import {
api, type AccountProfile, type ManagedAccountRecord, type PermissionDescriptor, type RoleRecord, type UserRoleAssignmentPreview, type UserRoleView,
api, type AccountProfile, type ManagedAccountRecord, type PermissionDescriptor, type RoleRecord, type UserRoleView,
} from '../api'
import { useStore } from '../store'
import { Collapsible, Icon } from '@ui'
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'
import { hasSystemAction } from './RolePermissionsPanel'
import { accountActionPolicy, accountIdentityKind, canAppointAdmins } from '../accountAuthority'
type IdentityFlags = { isAdmin?: boolean; isSuperAdmin?: boolean }
const emptyProfile = (): AccountProfile => ({ name: '', phone: '', email: '', staffNo: '', department: '', notes: '' })
const fields: { key: keyof AccountProfile; label: string }[] = [
@@ -22,7 +16,7 @@ const fields: { key: keyof AccountProfile; label: string }[] = [
]
export const credentialLabel = (account: Pick<ManagedAccountRecord, 'initializationState' | 'passwordSet'>) =>
account.initializationState === 'pending' ? '待可信 owner 初始化' : account.passwordSet ? '已设置密码' : '未设置密码'
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}>
@@ -32,6 +26,25 @@ 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)
@@ -39,20 +52,9 @@ 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('')
@@ -65,6 +67,7 @@ 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')
@@ -78,13 +81,6 @@ 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
@@ -93,7 +89,6 @@ 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))
}
@@ -110,7 +105,6 @@ 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 }
@@ -125,13 +119,6 @@ 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([])
@@ -141,7 +128,7 @@ export function AccountUsersPanel() {
}
useEffect(() => {
if ((!selected && !batchOpen) || !canViewRoles) { setRoles([]); setRoleLoading(false); return }
if (!selected || !canViewRoles) { setRoles([]); setRoleLoading(false); return }
const request = ++roleSequence.current
setRoleLoading(true); setNextRoleCursor('')
const timer = window.setTimeout(() => {
@@ -153,7 +140,7 @@ export function AccountUsersPanel() {
.catch(error => { if (request === roleSequence.current) { setErr(String(error)); setRoleLoading(false) } })
}, 180)
return () => { window.clearTimeout(timer) }
}, [selected?.userId, batchOpen, roleQuery, roleCursor, canViewRoles])
}, [selected?.userId, roleQuery, roleCursor, canViewRoles])
// 写入成功会推进目录版本,旧页游标随之失效:从第一页重读,刷新失败不改写成功结果。
const refreshFirst = async () => {
@@ -165,10 +152,10 @@ export function AccountUsersPanel() {
if (!newOwner.trim()) return
setBusy(true); setErr(''); setNotice('')
try {
const account = await api.createManagedUser(newOwner.trim(), newProfile)
setNewOwner(''); setNewProfile(emptyProfile())
const account = await api.createManagedUser(newOwner.trim(), newProfile, canAppointAdmins(me) && newIsAdmin)
setNewOwner(''); setNewProfile(emptyProfile()); setNewIsAdmin(false)
await open(account)
setNotice(`已创建 ${account.owner};账号资料可空,角色另行分配`)
setNotice(`已创建 ${account.owner}${account.isAdmin ? '(管理员)' : ''};账号资料可空,角色另行分配`)
await refreshFirst()
} catch (error) { setErr(String(error).includes('409') ? '登录名已存在' : String(error)) }
finally { setBusy(false) }
@@ -218,194 +205,58 @@ export function AccountUsersPanel() {
finally { setBusy(false) }
}
// 选择集合一旦变化,旧预览不再对应提交集合,必须重新预览。
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)
const setAdmin = async (isAdmin: boolean) => {
if (!selected) return
setBusy(true); setErr(''); setNotice('')
try {
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)) }
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)) }
finally { setBusy(false) }
}
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>
const policy = selected ? accountActionPolicy(me, selected) : null
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 ? <div className="rp-editor">
{selected && policy ? <div className="rp-editor">
<div className="ad-row"><button className="ad-mini" onClick={() => { sequence.current++; setSelected(null) }}>返回列表</button>
<b>{selected.owner}</b><span className="ad-dim ad-mono">{selected.userId}</span></div>
<b>{selected.owner}</b><IdentityChip account={selected} /><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>
{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="编辑可选资料">
{policy.protectedReason && <p className="ad-hint" role="note">{policy.protectedReason}</p>}
{allowed('system.users.edit') && policy.maintain && <Collapsible id={`user-profile-${selected.userId}`} trigger="编辑可选资料">
<ProfileInputs profile={draft} onChange={setDraft} prefix="user-profile" />
<button className="lg-submit ad-create-btn" disabled={busy} onClick={saveProfile}>保存资料</button>
</Collapsible>}
<div className="ad-row">
{canAssignRoles && <><select className="lg-input" aria-label="用户生效角色" value={selectedRoleId} onChange={event => setSelectedRoleId(event.target.value)}>
{canAssignRoles && policy.maintain && <><select className="lg-input" aria-label="用户生效角色" value={selectedRoleId} onChange={event => setSelectedRoleId(event.target.value)}>
<option value="">请选择角色</option>{roles.map(role => <option key={role.roleId} value={role.roleId}>{role.name}</option>)}
</select><button className="ad-mini" disabled={busy || !selectedRoleId || selectedRoleId === roleView?.role?.roleId} onClick={assignRole}>替换角色</button></>}
{allowed('system.users.disable') && <button className="ad-mini" disabled={busy} onClick={toggleState}>{selected.enabled ? '停用账号' : '启用账号'}</button>}
{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} />
</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>}
{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>}
{canViewGrants && <Collapsible id={`system-grants-${selected.userId}`} trigger="明确系统权限">
<div className="rp-grants">{catalog.filter(item => item.scope === 'system').map(item => <label className="ad-check" key={item.actionId}>
<input type="checkbox" checked={grants.includes(item.actionId)} disabled={!canManageGrants || busy} onChange={event => setGrants(current => event.target.checked
<input type="checkbox" checked={grants.includes(item.actionId)} disabled={!canManageGrants || !policy.maintain || busy} onChange={event => setGrants(current => event.target.checked
? [...new Set([...current, item.actionId])] : current.filter(value => value !== item.actionId))} />{item.label}<span className="ad-dim ad-mono">{item.actionId}</span>
</label>)}</div>
{canManageGrants && <button className="lg-submit ad-create-btn" disabled={busy} onClick={saveGrants}>保存系统权限</button>}
{canManageGrants && policy.maintain && <button className="lg-submit ad-create-btn" disabled={busy} onClick={saveGrants}>保存系统权限</button>}
</Collapsible>}
{!canViewGrants && allowed('system.permissions.manage') && <p className="ad-hint">需要同时具备系统权限查看权,才能安全编辑现有授权</p>}
</div> : <>
@@ -418,33 +269,14 @@ 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>
{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>
<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>
<td>{credentialLabel(account)}</td><td><button className="ad-mini" onClick={() => open(account)}>查看与管理</button></td></tr>)}
{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>
{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>
<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>
}
+1 -1
View File
@@ -4,7 +4,7 @@ import { useState } from 'react'
import { api } from '../api'
import { store, useStore } from '../store'
import { Icon } from '@ui'
import { humanChangePasswordError } from './LoginPage'
import { humanChangePasswordError } from './loginErrors'
function Shell({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
return (
+5 -5
View File
@@ -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">{isAdmin ? '根管理员' : '按系统权限开放'} · 角色与权限由服务端判定</span>
<span className="az-sub">{me?.isSuperAdmin ? '超级管理员' : isAdmin ? '根管理员' : '按系统权限开放'} · 角色与权限由服务端判定</span>
<span style={{ flex: 1 }} />
<span className="az-tenant">{me?.owner ?? ''}</span>
</div>
+3 -1
View File
@@ -29,7 +29,9 @@ export function ConversationWork() {
if (card.kind === 'confirm' && card.anchorMessageId
&& messages.some(message => message.role === 'user' && message.id === card.anchorMessageId)) continue
const summary = activitySummary(card)
if (card.status === 'action_required' || card.work?.decision?.state === 'open' || summary.failed || (card.status === 'done' && summary.remaining)) required.push(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)
else remaining.push(card)
}
// Preserve the host's newest-first order for completed results and history.
+7 -47
View File
@@ -1,11 +1,13 @@
// 登录与改密(管理员发放制,R2 + 2026-10-13 登录恢复合同):
// LoginPage —— 用户名+密码唯一入口;
// ChangePasswordGate —— 受限会话强制设密:模式由服务端 passwordChangeMode 派生,
// grant_setup 免当前密码(激活/恢复 Key 兑换),current_required 必须校验当前密码
// grant_setup 与 one_time_password 免当前密码,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
@@ -74,12 +76,6 @@ 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 }) {
@@ -96,8 +92,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' ||
(me.passwordChangeMode !== 'grant_setup' && me.passwordChangeMode !== 'current_required')) {
setErr('当前设密凭据不再有效,请重新登录;恢复场景请先核对「设置 → 账号」中的恢复提案状态。')
!isForcedPasswordMode(me.passwordChangeMode)) {
setErr('当前设密凭据不再有效,请重新登录。')
return
}
setMode(me.passwordChangeMode)
@@ -113,7 +109,7 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
if (next.length < 8) { setErr('新密码至少 8 位'); return }
setBusy(true)
try {
await api.changePassword(requiresCurrentPassword(mode) ? current : '', next)
await submitForcedPassword(mode, current, next)
setDone(true)
} catch (e) {
setErr(humanChangePasswordError(String(e)))
@@ -136,12 +132,7 @@ 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>
{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>}
<ForcedPasswordModeSection mode={mode} current={current} onCurrentChange={setCurrent} />
<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>
@@ -155,34 +146,3 @@ 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*/, '')
}
+9 -88
View File
@@ -1,12 +1,9 @@
// 我的账号与恢复(设置 → 账号):可选资料编辑 + 恢复提案的属主确认视图。
// 管理员只能建立提案;确认必须使用提案之前已存在、来源可信的完整密码会话
//(backend eligibleRecoveryProof 契约)。确认产出的一次性 grant 只在本次响应
// 出现,可就地兑换进受限设密会话(完成前不放行工作台)。
// 我的账号(设置 → 账号):可选资料编辑 + 忘记密码指引。忘记密码只有一个处置:
// 由管理员生成一次性登录密码,用户用它登录后进入强制改密门,设置新密码才放行。
import { useEffect, useRef, useState } from 'react'
import { api, type AccountProfile, type AccountRecord, type RecoveryProposal } from '../api'
import { api, type AccountProfile, type AccountRecord } 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: '邮箱' },
@@ -17,8 +14,6 @@ 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)
@@ -27,9 +22,9 @@ export function MyAccountSettings() {
useEffect(() => {
if (!me?.auth) return
const seq = ++sequence.current
Promise.all([api.meProfile(), api.listMyRecoveryProposals()]).then(([profile, statuses]) => {
api.meProfile().then(profile => {
if (seq !== sequence.current) return
setAccount(profile); setDraft(profile.profile); setProposals(statuses)
setAccount(profile); setDraft(profile.profile)
}).catch(error => { if (seq === sequence.current) setErr(String(error)) })
return () => { sequence.current++ }
}, [me?.owner, me?.auth])
@@ -44,74 +39,14 @@ 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) }
}
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="我的账号与恢复">
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>
@@ -121,21 +56,7 @@ 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">忘记密码的恢复:由管理员发起提案,本人在此用当前登录会话确认后获得一次性 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>}
<p className="ad-hint">忘记密码:请联系管理员生成一次性登录密码。用它登录后必须立即设置新密码(强制改密),原密码随生成即失效。</p>
{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>
+7 -4
View File
@@ -24,6 +24,7 @@ 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)
@@ -35,12 +36,12 @@ export function PreviewPane() {
setContent(null)
if (!art || !isTextKind(art)) return
let cancelled = false
fetch(api.artifactContentUrl(art.id))
.then(r => r.text())
fetch(contentUrl)
.then(r => {if(!r.ok)throw new Error('source unavailable');return r.text()})
.then(text => { if (!cancelled) setContent(text) })
.catch(() => { if (!cancelled) setContent('(读取失败)') })
return () => { cancelled = true }
}, [art?.id])
}, [art?.id,contentUrl])
if (!active) return null
if (active.id.startsWith('surface:')) {
@@ -96,6 +97,7 @@ 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>}
</>
)}
@@ -108,6 +110,7 @@ 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 ?? ''}
@@ -118,7 +121,7 @@ export function PreviewPane() {
id: art.id,
name: art.name,
mime: art.mime,
contentUrl: api.artifactContentUrl(art.id),
contentUrl,
downloadUrl: api.artifactDownloadUrl(art.id),
}} />
) : <div className="empty-note" style={{ padding: 14 }}>这个文件已不在当前项目里</div>}
+151 -504
View File
@@ -1,18 +1,8 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { api, type PermissionDescriptor, type RoleRecord } from '../api'
import {
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,
createRolePermissionDraft, rebaseRoleDraft, updateRolePermission, validateRolePermissionDraft, visiblePatch,
type RolePermissionDraft, type RolePermissionValue,
} from '../rolePermissions'
import { Collapsible, Icon } from '@ui'
import { useStore } from '../store'
@@ -20,14 +10,6 @@ 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[] {
@@ -41,6 +23,11 @@ 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
}
@@ -49,16 +36,6 @@ 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 => {
@@ -72,315 +49,49 @@ 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<RoleDraftMap>({})
const [drafts, setDrafts] = useState<Record<string, RolePermissionDraft>>({})
const [confirmDisable, setConfirmDisable] = useState(false)
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [query, setQuery] = useState('')
const [mode, setMode] = useState<'exact' | 'fuzzy'>('fuzzy')
const [status, setStatus] = useState<'all' | 'enabled' | 'disabled'>('all')
const [category, setCategory] = useState('')
const [group, setGroup] = useState('')
const [actionFilter, setActionFilter] = useState('')
const [roleCursor, setRoleCursor] = useState('')
const [roleTrail, setRoleTrail] = useState<string[]>([])
const [nextRoleCursor, setNextRoleCursor] = useState('')
const [view, setView] = useState<'single' | 'matrix'>(() => initialRoleView(
typeof window !== 'undefined' && typeof window.matchMedia === 'function' ? media => window.matchMedia(media).matches : undefined))
const [page, setPage] = useState(0)
const [view, setView] = useState<'single' | 'matrix'>('single')
const [newName, setNewName] = useState('')
const [newDescription, setNewDescription] = useState('')
const [busy, setBusy] = useState(false)
const [roleLoading, setRoleLoading] = useState(false)
const [notice, setNotice] = useState('')
const [err, setErr] = useState('')
const [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 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 dirty = !!draft && visiblePatch(draft, allEditableIds).length > 0
const groups = useMemo(() => [...new Set(catalog.map(item => item.group))], [catalog])
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 pageItems = useMemo(() => groupPermissionPage(catalog, group, page, 6), [catalog, group, page])
const editablePageItems = pageItems.filter(item => !item.protected)
const changes = draft ? visiblePatch(draft, allEditableIds) : []
const load = async (pageCursor = roleCursor, refreshCatalog = false, preferredId = selectedId,
filters = { query, mode, status, action: actionFilter }) => {
@@ -396,7 +107,6 @@ 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
@@ -404,33 +114,34 @@ 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, sequence)
if (nextSelected) selectRole(nextSelected, descriptors, sequence)
else setSelectedId('')
// DR10:目录失败是部分失败态,需在角色选中(selectRole 会清空提示)之后仍可见。
if (catalogResult.status === 'rejected') setErr(prev => prev || `权限目录加载失败:${catalogResult.reason}`)
}
const selectRole = (role: RoleRecord, sequence?: number) => {
const selectRole = (role: RoleRecord, descriptors = catalog, sequence?: number) => {
const nextSequence = sequence ?? ++requestSequence.current
if (!shouldAcceptRoleResponse(requestSequence.current, nextSequence)) return
setSelectedId(role.roleId)
setName(role.name)
setDescription(role.description)
// DR9:草稿按 roleId 隔离保存;翻页、过滤、视图切换回到该角色时保留,
// 切换角色永不转移他人草稿。仅首次选中时以服务端值为基线。
setDrafts(prev => prev[role.roleId] ? prev
: { ...prev, [role.roleId]: createRolePermissionDraft(roleValues(role, descriptors), nextSequence) })
setNotice('')
setErr('')
}
// DR9:离开管理区须显式决定(覆盖所有脏角色);带未保存草稿时刷新走浏览器原生确认。
const dirtyKey = dirtyNames.join('\u0000')
// DR9:离开管理区须显式决定;带未保存草稿时刷新走浏览器原生确认。
useEffect(() => {
if (!dirtyNames.length) { roleDraftLeaveGuard.current = null; return }
const message = roleLeaveMessage(dirtyNames)
if (!dirty || !selected) { roleDraftLeaveGuard.current = null; return }
const message = `角色「${selected.name}」有未保存的权限修改,离开将放弃这些修改。确定离开吗?`
roleDraftLeaveGuard.current = () => window.confirm(message)
return () => { roleDraftLeaveGuard.current = null }
}, [dirtyKey])
const dirty = dirtyList.length > 0
}, [dirty, selected?.roleId, selected?.name])
useEffect(() => {
if (!dirty) return
const handler = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = '' }
@@ -444,13 +155,6 @@ 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)
@@ -460,7 +164,7 @@ export function RolePermissionsPanel() {
setNewName(''); setNewDescription('')
setQuery(''); setStatus('all'); setActionFilter(''); setRoleCursor(''); setRoleTrail([])
setRoles(current => [role, ...(current ?? []).filter(item => item.roleId !== role.roleId)])
selectRole(role, ++requestSequence.current)
selectRole(role, catalog, ++requestSequence.current)
setNotice(`已创建角色 ${role.name}`)
await load('', true, role.roleId, { query: '', mode, status: 'all', action: '' })
setNotice(`已创建角色 ${role.name}`)
@@ -474,15 +178,15 @@ export function RolePermissionsPanel() {
setRoleLoading(false)
setBusy(true); setErr(''); setNotice('')
try {
const updated = await api.patchRole(selected.roleId, selected.roleVersion, { name: name.trim(), description: description.trim() })
setDrafts(prev => adoptRoleSnapshot(prev, selected.roleVersion, updated))
await api.patchRole(selected.roleId, selected.roleVersion, { name: name.trim(), description: description.trim() })
await load(); setNotice('角色资料已保存')
} catch (error) {
if (String(error).includes('409')) {
try {
const fresh = await api.getRole(roleId)
if (requestSequence.current !== operationSequence || selectedIdRef.current !== roleId) return
setRoles(current => mergeRoles(current, [fresh]))
setRoles(current => current?.some(role => role.roleId === fresh.roleId)
? current.map(role => role.roleId === fresh.roleId ? fresh : role) : [fresh, ...(current ?? [])])
setErr('角色资料版本冲突;已读取最新版本并保留本地名称与描述,请核对后重试')
} catch (refreshError) { setErr(String(refreshError)) }
} else setErr(String(error))
@@ -497,190 +201,105 @@ export function RolePermissionsPanel() {
setRoleLoading(false)
setBusy(true); setErr(''); setNotice('')
try {
const updated = await api.patchRole(selected.roleId, selected.roleVersion, { enabled: !selected.enabled })
setDrafts(prev => adoptRoleSnapshot(prev, selected.roleVersion, updated))
await api.patchRole(selected.roleId, selected.roleVersion, { enabled: !selected.enabled })
await load(); setNotice(selected.enabled ? '角色已停用,现有绑定不再授予权限' : '角色已启用')
} catch (error) {
if (String(error).includes('409')) {
try {
const fresh = await api.getRole(roleId)
if (requestSequence.current !== operationSequence || selectedIdRef.current !== roleId) return
setRoles(current => mergeRoles(current, [fresh]))
setRoles(current => current?.some(role => role.roleId === fresh.roleId)
? current.map(role => role.roleId === fresh.roleId ? fresh : role) : [fresh, ...(current ?? [])])
setErr('角色状态版本冲突;已刷新当前状态,请重新确认')
} catch (refreshError) { setErr(String(refreshError)) }
} else setErr(String(error))
} finally { setBusy(false) }
}
const editCell = (role: RoleRecord, actionId: string, value: RolePermissionValue) => {
setDrafts(prev => editRoleDraft(prev, role, catalog, actionId, value))
setPreview(null)
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 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 savePermissions = async () => {
if (!selected || !draft) return
const roleId = selected.roleId
const operationSequence = ++requestSequence.current
setRoleLoading(false)
const invalid = validateRolePermissionDraft(draft)
if (invalid) { setErr(`${invalid.actionId} 的默认权限不能超过角色上限`); return }
const full = Object.entries(draft.values).map(([actionId, value]) => ({ actionId, ...value }))
setBusy(true); setErr(''); setNotice('')
try {
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} 个角色的权限设置`)
await api.replaceRolePermissions(selected.roleId, selected.roleVersion, full, crypto.randomUUID())
setDrafts(current => {
if (!(roleId in current)) return current
const next = { ...current }; delete next[roleId]; return next
})
await load(roleCursor, true, selected.roleId); setNotice('角色权限已保存')
} catch (error) {
if (String(error).includes('409')) {
try {
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)) }
try { await rebasePermissionConflict(roleId, draft, operationSequence) } catch (refreshError) { setErr(String(refreshError)) }
} else setErr(String(error))
} finally { setBusy(false); restoreFocus() }
}
finally {
setBusy(false)
// DR10:保存/冲突/失败后焦点稳定——若焦点因按钮短暂 disabled 掉回 body,则归还。
requestAnimationFrame(() => {
if (savePermsRef.current && !savePermsRef.current.disabled && document.activeElement === document.body) savePermsRef.current.focus()
})
}
}
const cancelAll = () => {
if (!window.confirm(`确定放弃 ${dirtyNames.map(name => `「${name}」`).join('、')} 的全部未保存权限修改吗?`)) return
setDrafts({}); setPreview(null); setErr(''); setNotice('已取消全部未保存的权限修改')
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 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 editVisible = (field: 'allowed' | 'capAllowed', checked: boolean) => {
const start = drafts[selectedId]
if (!start) return
let next = start
for (const item of editablePageItems) {
const current = next.values[item.actionId] ?? { allowed: false, capAllowed: false }
const value = field === 'allowed'
? { allowed: checked, capAllowed: checked ? true : current.capAllowed }
: { allowed: checked ? current.allowed : false, capAllowed: checked }
next = updateRolePermission(next, item.actionId, value)
}
setDrafts(prev => prev[selectedId] ? { ...prev, [selectedId]: next } : prev)
}
const matrix = () => <div className="rp-matrix-wrap" role="region" aria-label={`角色矩阵 · ${activeCategory}`} tabIndex={0}>
const matrix = () => <div className="rp-matrix-wrap">
<table className="ad-table rp-matrix">
<thead><tr><th scope="col">角色</th>{columns.map(item => <th scope="col" key={item.actionId} title={item.description}>{item.label}</th>)}</tr></thead>
<thead><tr><th>角色</th>{pageItems.map(item => <th key={item.actionId}>{item.label}</th>)}</tr></thead>
<tbody>{filtered.map(role => <tr key={role.roleId} className={role.roleId === selectedId ? 'rp-selected' : ''}>
<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>)}
<td><button className="ad-mini" disabled={busy || roleLoading} onClick={() => selectRole(role)}>{role.name}</button><span className="ad-dim"> · {role.bindingCount} 人</span></td>
{pageItems.map(item => {
const value = role.roleId === selectedId && draft ? draft.values[item.actionId] : roleValue(role, item.actionId)
return <td key={item.actionId}>
{item.protected ? <span className="ad-dim">系统授权</span> : <>
<label className="ad-check"><input type="checkbox" aria-label={`${role.name} ${item.label} 默认允许`} checked={value?.allowed ?? false}
disabled={busy || roleLoading || !canManagePermissions || role.roleId !== selectedId} onChange={event => editValue(item.actionId, 'allowed', event.target.checked)} />默认</label>
<label className="ad-check"><input type="checkbox" aria-label={`${role.name} ${item.label} 角色上限`} checked={value?.capAllowed ?? false}
disabled={busy || roleLoading || !canManagePermissions || role.roleId !== selectedId} onChange={event => editValue(item.actionId, 'capAllowed', event.target.checked)} />上限</label>
</>}
</td>
})}
</tr>)}</tbody>
</table>
{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">
@@ -698,6 +317,9 @@ 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>)}
@@ -705,35 +327,60 @@ export function RolePermissionsPanel() {
</div>
</Collapsible>
<div className="ad-row rp-view-toggle">
{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 ${view === 'single' ? 'on' : ''}`} onClick={() => setView('single')}>单角色配置</button>
{canViewPermissions && <button className={`ad-mini ${view === 'matrix' ? 'on' : ''}`} onClick={() => setView('matrix')}>角色矩阵</button>}
<button className="ad-mini" disabled={page === 0} onClick={() => setPage(value => value - 1)}>上一组</button>
<button className="ad-mini" disabled={(page + 1) * 6 >= catalog.filter(item => !group || item.group === group).length} onClick={() => setPage(value => value + 1)}>下一组</button>
<button className="ad-mini" disabled={busy || roleLoading || !roleTrail.length} onClick={() => { setRoleCursor(roleTrail[roleTrail.length - 1]); setRoleTrail(roleTrail.slice(0, -1)) }}>上一页角色</button>
<button className="ad-mini" disabled={busy || roleLoading || !nextRoleCursor} onClick={() => { setRoleTrail([...roleTrail, roleCursor]); setRoleCursor(nextRoleCursor) }}>下一页角色</button>
{view === 'matrix' && canManagePermissions && <>
<button className="ad-mini" disabled={busy || roleLoading || !editablePageItems.length} onClick={() => editVisible('allowed', true)}>当前页默认全选</button>
<button className="ad-mini" disabled={busy || roleLoading || !editablePageItems.length} onClick={() => editVisible('allowed', false)}>当前页默认清除</button>
<button className="ad-mini" disabled={busy || roleLoading || !editablePageItems.length} onClick={() => editVisible('capAllowed', true)}>当前页上限全开</button>
<button className="ad-mini" disabled={busy || roleLoading || !editablePageItems.length} onClick={() => editVisible('capAllowed', false)}>当前页上限关闭</button>
</>}
</div>
{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>}
{notice && <div className="ad-notice" role="status"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
{view === 'matrix' ? matrix() : <div className="rp-single">
<aside className="rp-role-list">
{(filtered).map(role => <button key={role.roleId} disabled={busy || roleLoading} className={`az-nav-item ${role.roleId === selectedId ? 'on' : ''}`} onClick={() => selectRole(role)}>
<span>{role.name}</span><span className="ad-dim">{role.bindingCount} 人</span>
</button>)}
</aside>
<section className="rp-editor">
{selected && draft ? <>
<div className="ad-row">
<input className="lg-input" aria-label="角色名称" value={name} disabled={!canManageRoles || busy || roleLoading} onChange={event => setName(event.target.value)} />
<input className="lg-input" aria-label="角色描述" value={description} disabled={!canManageRoles || busy || roleLoading} onChange={event => setDescription(event.target.value)} />
{canManageRoles && <button className="ad-mini" disabled={busy || roleLoading} onClick={saveMetadata}>保存资料</button>}
{canManageRoles && (selected.enabled
? <button className="ad-mini" disabled={busy || roleLoading} onClick={() => setConfirmDisable(true)}>停用角色</button>
: <button className="ad-mini" disabled={busy || roleLoading} onClick={setEnabled}>启用角色</button>)}
</div>
{confirmDisable && selected.enabled && <div className="rp-disable-confirm" role="alertdialog" aria-label={`确认停用角色 ${selected.name}`}>
<span>停用角色「{selected.name}」?该角色现有 {selected.bindingCount} 个用户绑定,停用后这些绑定将不再授予权限。</span>
<button className="ad-mini" disabled={busy || roleLoading} onClick={() => { setConfirmDisable(false); void setEnabled() }}>确认停用</button>
<button className="ad-mini" disabled={busy} onClick={() => setConfirmDisable(false)}>取消</button>
</div>}
{canViewPermissions ? <><table className="ad-table"><thead><tr><th>权限</th><th>默认允许</th><th>角色上限</th><th>说明</th></tr></thead>
<tbody>{pageItems.map(item => {
const value = draft.values[item.actionId] ?? { allowed: false, capAllowed: false }
return <tr key={item.actionId}><td>{item.label}<div className="ad-dim ad-mono">{item.actionId}</div></td>
<td><input type="checkbox" aria-label={`${item.label} 默认允许`} checked={value.allowed} disabled={busy || roleLoading || !canManagePermissions || item.protected}
onChange={event => editValue(item.actionId, 'allowed', event.target.checked)} /></td>
<td><input type="checkbox" aria-label={`${item.label} 角色上限`} checked={value.capAllowed} disabled={busy || roleLoading || !canManagePermissions || item.protected}
onChange={event => editValue(item.actionId, 'capAllowed', event.target.checked)} /></td>
<td className="ad-dim">{item.protected ? '受保护系统授权,不能由普通角色授予' : item.description}</td></tr>
})}</tbody></table>
<Collapsible id={`role-diff-${selected.roleId}`} trigger={`变更预览(${changes.length})`}>
{changes.length === 0 ? <p className="ad-hint">没有权限变化</p> : <ul className="rp-diff">{changes.map(change =>
<li key={change.actionId}><span className="ad-mono">{change.actionId}</span>:默认 {change.allowed ? '允许' : '拒绝'},上限 {change.capAllowed ? '允许' : '拒绝'}</li>)}</ul>}
</Collapsible>
{canManagePermissions && <button ref={savePermsRef} className="lg-submit ad-create-btn" disabled={busy || roleLoading || changes.length === 0 || editablePageItems.length === 0} onClick={savePermissions}>保存权限</button>}
</> : <p className="ad-hint">当前账号没有查看角色权限目录的系统权限</p>}
</> : <p className="ad-hint">请选择角色</p>}
</section>
</div>}
</div>
}
+1
View File
@@ -14,6 +14,7 @@ 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)
+92 -72
View File
@@ -11,28 +11,33 @@ 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 }: { project: Project }) {
export function RdmMembersSection({
project,
initialCanManage,
initialRows,
}: {
project: Project
initialCanManage?: boolean
initialRows?: MemberRow[]
}) {
const projectId = project.id
const writeError = projectWorkError(project)
const cmd = <T,>(id: string, params: Record<string, unknown> = {}) => api.rdmProjectCommand<T>(project, id, params)
const [rows, setRows] = useState<MemberRow[] | null>(null)
const [canManage, setCanManage] = useState(initialCanManage ?? false)
const [rows, setRows] = useState<MemberRow[] | null>(initialRows ?? null)
const [memberNext, setMemberNext] = useState('')
const [memberTotal, setMemberTotal] = useState<number | null>(null)
const [directoryOk, setDirectoryOk] = useState(true)
@@ -58,15 +63,23 @@ export function RdmMembersSection({ project }: { project: Project }) {
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])
}, [project, canManage])
const candOptions = useMemo(() => ({ mode: candMatch, searchFields: SEARCH_FIELDS[candFields].fields, roleId: candRoleId }),
[candMatch, candFields, candRoleId])
@@ -94,6 +107,9 @@ export function RdmMembersSection({ project }: { project: Project }) {
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)
@@ -143,44 +159,48 @@ export function RdmMembersSection({ project }: { project: Project }) {
return (
<div>
{writeError && <p className="ad-hint">{writeError}</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>
{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>
<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>
) : (
<p className="ad-hint">仅项目创建者可管理成员;你可以查看成员列表。</p>
)}
<div className="ad-row">
<input className="lg-input" aria-label="搜索成员" placeholder="搜索成员账号或姓名" value={memberQuery}
onChange={e => setMemberQuery(e.target.value)} />
@@ -189,7 +209,16 @@ export function RdmMembersSection({ project }: { project: Project }) {
</div>
{!directoryOk && <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>
<thead>
<tr>
<th>成员</th>
<th>专业角色</th>
<th>项目身份</th>
<th>添加人</th>
<th>添加时间</th>
{canManage && <th></th>}
</tr>
</thead>
<tbody>
{(rows ?? []).map(m => (
<tr key={m.owner}>
@@ -198,21 +227,23 @@ export function RdmMembersSection({ project }: { project: Project }) {
<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>
<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>
{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>
)}
</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>}
{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>}
</tbody>
</table>
{memberNext && rows !== null && (
@@ -270,10 +301,7 @@ 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 || '—'}
{dutyConditionNote(a.action) && <span className="ad-dim ad-cond-note">{dutyConditionNote(a.action)}</span>}
</td>
<td className="ad-dim">{a.reason || '—'}</td>
{canManage ? (
<td>
<select className="lg-input" value={overrideOf(a)} style={{ minWidth: 120 }}
@@ -364,9 +392,6 @@ 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>
@@ -392,11 +417,6 @@ 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></>}
+39 -146
View File
@@ -1,22 +1,21 @@
// 系统管理分区面板(重设计方案 阶段②):普通用户界面不再暴露的系统能力
// 全部收进这里,由 AdminZone 以分区注册表呈现。数据入口与原弹窗完全一致
//(api.* 不变),只换呈现位置;插件面板/声明工作区经 UIRenderer 内联渲染。
import { useEffect, useRef, useState } from 'react'
import { api, type RecoveryProposal } from '../api'
import { useEffect, useState } from 'react'
import { api } 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 {
recoveryAttemptKey, recoveryProposalErrorMessage, recoveryProposalHint, recoveryProposalNotice, recoveryProposalRejected,
recoveryStatusLabel, type RecoveryAttempt,
} from '../recovery'
import { accountActionPolicy, canAppointAdmins } from '../accountAuthority'
import { CreateAdminOption, IdentityChip } from './AccountUsersPanel'
import { GeneratedCredentialPanel, oneTimePasswordCredential, type GeneratedCredential } from './oneTimePasswordUi'
// ---- 用户管理:页面建号可设置/生成初始密码,管理员重置生成临时密码;
// 保留恢复提案与 Key 管理作为既有非登录页恢复流程。 ----
interface AdminUserRow { userId: string; owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number }
// ---- 用户管理:页面建号可设置/生成初始密码;忘记密码的唯一处置是
// 管理员生成一次性登录密码,用户登录后必须立即改密。 ----
interface AdminUserRow { userId: string; owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number }
export function UsersPanel() {
const me = useStore(s => s.me)
@@ -26,112 +25,37 @@ export function UsersPanel() {
const [owner, setOwner] = useState('')
const [name, setName] = useState('')
const [initialPassword, setInitialPassword] = useState('')
const [generatedCredential, setGeneratedCredential] = useState<{ owner: string; password: string; kind: 'initial' | 'temporary' } | null>(null)
const [createAsAdmin, setCreateAsAdmin] = useState(false)
const [generatedCredential, setGeneratedCredential] = useState<GeneratedCredential | 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)
const out = await api.createAdminUser(owner.trim(), name.trim(), initialPassword.trim() || undefined, canAppointAdmins(me) && createAsAdmin)
setGeneratedCredential(out.initialPassword ? { owner: out.owner, password: out.initialPassword, kind: 'initial' } : null)
setNotice(out.initialPassword ? `已创建 ${out.owner},请将下方一次性初始密码交付给用户。首次登录后必须改密。` : `已创建 ${out.owner}。用户首次登录后必须改密。`)
setOwner(''); setName(''); setInitialPassword('')
setOwner(''); setName(''); setInitialPassword(''); setCreateAsAdmin(false)
await refresh()
} catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) }
}
const resetPassword = async (u: AdminUserRow) => {
const issueOneTimePassword = async (u: AdminUserRow) => {
setErr(''); setNotice(''); setGeneratedCredential(null); setBusy(true)
try {
const out = await api.adminResetUserPassword(u.owner)
setGeneratedCredential({ owner: u.owner, password: out.temporaryPassword, kind: 'temporary' })
setNotice(`已为 ${u.owner} 生成临时密码。该用户登录后必须立即修改密码。`)
const out = await api.adminIssueOneTimePassword(u.owner)
setGeneratedCredential(oneTimePasswordCredential(out))
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 && (
@@ -142,65 +66,34 @@ 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 && <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>}
{generatedCredential && <GeneratedCredentialPanel credential={generatedCredential} onClose={() => setGeneratedCredential(null)} />}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
{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>
)}
{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>
</div>
)
}
+21
View File
@@ -0,0 +1,21 @@
/** 登录错误文案:把 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*/, '')
}
+21
View File
@@ -0,0 +1,21 @@
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>
}
+27
View File
@@ -0,0 +1,27 @@
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)
}
+27 -1
View File
@@ -150,6 +150,7 @@ 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;
}
@@ -351,7 +352,7 @@ export interface PluginPageDecl {
export interface ParsedDocument {
meta: {
filename: string; mime?: string; parser: string; confidence?: number;
filename: string; mime?: string; parser: string; confidence?: number; origin?: DocumentOrigin;
crossCheck?: { with: string; diffBlocks: number; verdict: 'agree' | 'differ' };
parsedAt: string;
};
@@ -414,3 +415,28 @@ 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 }
-48
View File
@@ -1,48 +0,0 @@
// 项目有效权限说明的纯文案层:只消费服务端字段(/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] ?? ''
}
+2 -1
View File
@@ -3,13 +3,14 @@
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 }
next[i] = { ...next[i], name: tab.name || next[i].name,sourceContext:tab.sourceContext }
return { tabs: next, activeId: tab.id }
}
return { tabs: [...tabs, tab], activeId: tab.id }
+1 -2
View File
@@ -212,6 +212,7 @@ export interface RdmMemberRow {
}
export interface RdmMemberPage {
members: RdmMemberRow[]; next?: string; total?: number; accountDirectoryAvailable?: boolean
canManage?: boolean
}
/** 与 @ui PickItem 结构兼容(纯逻辑层不依赖组件库);id 为稳定 userId,owner 为提交用账号。 */
export interface PersonPickItem {
@@ -353,8 +354,6 @@ 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
}
}
-92
View File
@@ -1,92 +0,0 @@
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')
})
})
-50
View File
@@ -1,50 +0,0 @@
// 恢复提案展示与错误文案(纯函数):管理端 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*/, '')
}
-143
View File
@@ -1,143 +0,0 @@
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 })),
}
}
-72
View File
@@ -1,58 +1,5 @@
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>
@@ -121,22 +68,3 @@ 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)),
}))
}
+77 -16
View File
@@ -1,7 +1,7 @@
// 应用状态:服务端权威(事件 + Query 对账),前端 store 是投影。
// useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。
import { useSyncExternalStore } from 'react'
import { type AdminZoneSection, type LinkedCapabilities, api, connectEvents } from './api'
import { type AdminZoneSection, 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,6 +23,7 @@ export function isFontSizeChoice(value: number): value is FontSizeChoice {
}
import type {
ActivityCard,
TaskSourceRef,
AgentProfile,
Artifact,
Conversation,
@@ -90,10 +91,7 @@ export interface UIState {
projectSettingsId: string | null
}
export interface Me {
owner: string; name: string; auth: boolean; isAdmin?: boolean; mustChangePassword?: boolean; systemActions?: string[]
linkedCapabilities?: LinkedCapabilities
}
export interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; isSuperAdmin?: boolean; mustChangePassword?: boolean; systemActions?: string[] }
interface AppState {
contextObs: Record<string, ContextObs>
@@ -235,7 +233,8 @@ function syncFilesEntry(hasTabs: boolean) {
function setUI(patch: Partial<UIState>) {
const ui = { ...state.ui, ...patch }
state = { ...state, ui }
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) }
try { localStorage.setItem('as.ui', JSON.stringify(ui)) } catch { /* ignore */ }
applyTheme(ui.theme)
applyFontSize(ui.fontSize)
@@ -301,6 +300,9 @@ 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
@@ -356,12 +358,24 @@ 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))
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)]
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})]
}
function hitlSource(card: ActivityCard): string | undefined {
@@ -533,7 +547,7 @@ export const store = {
projectSeq = 0
projectCardsRequest++
projectCardVersion = 0; projectCardVersions = {}; projectCardPatches = {}
conversationEpoch++; streamSeq = 0; cardVersion = 0; cardVersions = {}; cardPatches = {}
conversationEpoch++; streamSeq = 0; streamHasWatermark=false;streamPending.clear();gapReconnectPending=false; 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: [],
@@ -624,7 +638,7 @@ export const store = {
async openConversation(convId: string) {
if (sse) { sse.close(); sse = null }
const epoch = ++conversationEpoch
streamSeq = 0; cardVersion = 0; cardVersions = {}; cardPatches = {}
streamSeq = 0; streamHasWatermark=false;streamPending.clear();gapReconnectPending=false; 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 })
@@ -722,15 +736,21 @@ 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 }) },
onOpen: () => { if (state.currentConvId === convId && conversationEpoch === epoch) {set({ connected: true });void store.refreshCards(convId)} },
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.isFinite(e.seq) || e.seq <= streamSeq) return
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
}
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.
@@ -881,6 +901,7 @@ 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 }
@@ -1077,6 +1098,7 @@ 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 })
@@ -1088,8 +1110,10 @@ export const store = {
const request = ++cardsRequest
const started = cardVersion
try {
const snapshot = await api.listCards(convId)
const out = await api.listCardsSnapshot(convId)
const snapshot=out.cards??[]
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)
@@ -1291,7 +1315,8 @@ export const store = {
applyProjectEvent(e: StreamEvent) {
if (!Number.isFinite(e.seq) || e.seq <= projectSeq) return
projectSeq = e.seq
const upsert = (card: ActivityCard) => {
const upsert = (incoming: ActivityCard) => {
const card=mergeTaskCard(state.projectCards.find(c=>c.id===incoming.id),incoming)
if (card.projectId !== state.currentProjectId) return
projectCardVersions[card.id] = ++projectCardVersion
const list = store.getState().projectCards
@@ -1404,6 +1429,41 @@ 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 })
@@ -1455,7 +1515,7 @@ export const store = {
togglePreviewExpanded() { setUI({ previewExpanded: !state.ui.previewExpanded }) },
activatePreview(id: string) {
const conv = previewTabsKey(state.currentConvId)
set({ previewActive: { ...state.previewActive, [conv]: id } })
set({ previewActive: { ...state.previewActive, [conv]: id },previewOpenIntent:state.previewOpenIntent+1 })
},
closePreview(id: string) {
const conv = previewTabsKey(state.currentConvId)
@@ -1463,6 +1523,7 @@ 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)
@@ -1519,5 +1580,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) ? now : t
return Number.isNaN(t) || t <= 0 ? now : t
}
+9 -59
View File
@@ -519,81 +519,31 @@ 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; 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-view-toggle { justify-content: flex-end; }
.rp-view-toggle .ad-mini.on { background: var(--bg-active); color: var(--text); }
.rp-single { display: grid; grid-template-columns: minmax(180px, 240px) minmax(0, 1fr); gap: 16px; align-items: start; }
.rp-disable-confirm { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-top: 8px; font-size: var(--fs-ui-sm); color: var(--warn-text); background: var(--warn-soft); border: 1px solid color-mix(in srgb, var(--warn) 35%, transparent); border-radius: var(--radius); padding: 8px 11px; }
.rp-role-list { display: flex; flex-direction: column; gap: 2px; padding: 8px; border: 1px solid var(--border-soft); border-radius: var(--radius-lg); background: var(--bg-panel); }
.rp-role-list .az-nav-item { justify-content: space-between; width: 100%; }
.rp-editor { min-width: 0; display: flex; flex-direction: column; gap: 12px; }
.rp-matrix-wrap { overflow: 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-matrix-wrap { overflow-x: auto; border: 1px solid var(--border-soft); border-radius: var(--radius-lg); }
.rp-matrix { min-width: 760px; }
.rp-matrix th:first-child, .rp-matrix td:first-child { position: sticky; left: 0; background: var(--bg-panel); z-index: 1; }
.rp-matrix tr.rp-selected td { background: var(--bg-active); }
.rp-matrix tr.rp-selected td:first-child { background: var(--bg-active); }
.rp-diff { margin: 0; padding-left: 20px; color: var(--text-dim); font-size: var(--fs-ui-sm); }
.rp-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; }
.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) {
@media (max-width: 820px) {
.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; }
+3 -31
View File
@@ -1,38 +1,10 @@
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 pending trusted-owner initialization before password state', () => {
expect(credentialLabel({ initializationState: 'pending', passwordSet: false })).toBe('待可信 owner 初始化')
it('shows whether password is ready without legacy initialization labels', () => {
expect(credentialLabel({ initializationState: 'pending', passwordSet: false })).toBe('待设置密码')
expect(credentialLabel({ initializationState: 'initialized', passwordSet: true })).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('软件工程师(已停用)')
expect(credentialLabel({ initializationState: 'legacy', passwordSet: false })).toBe('待设置密码')
})
})
+53 -13
View File
@@ -1,7 +1,17 @@
// 登录/改密门错误文案:登录与改密的 401 语义不同,映射分开锁定
// (后端错误串是现行 HTTP 契约的一部分,这里逐字对拍防漂移)。
import { describe, expect, it } from 'vitest'
import { humanChangePasswordError, humanLoginError, humanRedeemError } from '../src/components/LoginPage'
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'
describe('humanLoginError(登录)', () => {
it('401 统一为用户名或密码不正确', () => {
@@ -31,19 +41,49 @@ 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' }),
}))
})
})
+37
View File
@@ -0,0 +1,37 @@
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('一次性登录密码仅显示一次')
})
})
-63
View File
@@ -1,63 +0,0 @@
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')
})
})
+243
View File
@@ -0,0 +1,243 @@
// 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('仅项目创建者可管理成员')
})
})
-133
View File
@@ -1,133 +0,0 @@
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({})
})
})
+9 -95
View File
@@ -1,10 +1,8 @@
import { describe, expect, it, vi } from 'vitest'
import type { PermissionDescriptor, RolePermissionPreview, RoleRecord } from '../src/api'
import { describe, expect, it } from 'vitest'
import type { PermissionDescriptor, RoleRecord } from '../src/api'
import {
NARROW_ROLE_QUERY, cellKind, commitRoleBatch, editRoleDraft, filterRoles, hasSystemAction, initialRoleView, memberStatusLabel, previewRoleBatch,
rebaseRoleDrafts, roleCellValue, roleLeaveMessage, roleMembersLabel, shouldAcceptRoleResponse,
filterRoles, groupPermissionPage, hasSystemAction, 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: [] },
@@ -12,14 +10,10 @@ const roles: RoleRecord[] = [
]
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 },
{ 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 },
]
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', () => {
@@ -28,10 +22,9 @@ describe('role permission UI helpers', () => {
expect(filterRoles(roles, '', 'fuzzy', 'disabled').map(role => role.roleId)).toEqual(['role_reviewer'])
})
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('groups and pages matrix columns without changing their stable action IDs', () => {
expect(groupPermissionPage(catalog, '文档动作', 0, 1).map(item => item.actionId)).toEqual(['document.read'])
expect(groupPermissionPage(catalog, '文档动作', 1, 1).map(item => item.actionId)).toEqual(['document.author'])
})
it('drops late results and checks explicit system actions', () => {
@@ -40,83 +33,4 @@ 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')
})
})
+2 -55
View File
@@ -1,9 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { PermissionDescriptor } from '../src/api'
import {
categoryActions, createRolePermissionDraft, dirtyRoles, linkedByProjectCreate, mergeRoleDraft, permissionCategories, rebaseRoleDraft,
rolePatchEntries, stateToValue, updateRolePermission, validateRolePermissionDraft, valueToState, visiblePatch,
type RolePermissionStateName, type RolePermissionValue,
createRolePermissionDraft, mergeRoleDraft, rebaseRoleDraft, updateRolePermission, validateRolePermissionDraft, visiblePatch,
type RolePermissionValue,
} from '../src/rolePermissions'
const initial: Record<string, RolePermissionValue> = {
@@ -50,54 +48,3 @@ 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)
})
})
+9 -3
View File
@@ -26,12 +26,18 @@ describe('mergeStartedRun', () => {
})
describe('eventTime', () => {
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('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('falls back when the timestamp is missing or unparseable', () => {
it('falls back to now when timestamp is missing, invalid or zero-time 0001-01-01T00:00:00Z', () => {
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)
})
})
+97
View File
@@ -0,0 +1,97 @@
// 超级管理员 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 })
})
})
+60 -1
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '../src/api'
import { store } from '../src/store'
import { store, mergeTaskSnapshot } from '../src/store'
import type { ActivityCard, Conversation, Message, Run, StreamEvent, SurfaceSnapshot } from '../src/contracts'
function deferred<T>() {
@@ -32,6 +32,7 @@ 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([])
@@ -518,3 +519,61 @@ 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')
})