Compare commits

...
Author SHA1 Message Date
weichenandCursor 22ef3c4b67 Fold-close the IAM U3 Frontend task card before merge
governance / governance (pull_request) Successful in 15s
ci / test-and-build (pull_request) Successful in 29s
Why: close the card in the same PR before merging.
Evidence: pr-check PR_CHECK_OK on 080c630.
Impact: task card only.
Refs: Frontend PR #86
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 01:07:00 +00:00
weichenandCursor 080c630816 Add the IAM U3 Frontend task card for the PR gate
governance / governance (pull_request) Successful in 14s
ci / test-and-build (pull_request) Successful in 33s
Why: the branch predates Frontend governance adoption and had no task card, so pr-check refused PR #86.
Evidence: card records the agent-exec units, host verification log, CI and independent review of e4041b3.
Impact: task card only.
Refs: Frontend PR #86
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 01:06:46 +00:00
weichen e4041b36b9 Merge IAM U3 account UI history onto Frontend main
governance / governance (pull_request) Successful in 14s
ci / test-and-build (pull_request) Successful in 28s
Why: PR #86 predates IAM U4 and the account-chain UI on main; the U3 behaviour was ported onto main by agent-exec transports d47c493, 72c941c and afd9e75, so this merge only records the U3 branch history.
Evidence: tree equals afd9e75; host tsc -b, vitest 565 passed, build and knip on 72c941c (SELF); independent review blocking item fixed in afd9e75.
Impact: no tree change versus afd9e75.
Refs: Frontend PR #86, base main 1650b4f
2026-10-19 01:04:53 +00:00
agent-exec-transport afd9e7504b agent-exec transport a522bfd9db3df00c549eaa7e63f94562/3f39344cbd2e 2026-10-18 16:57:45 +00:00
agent-exec-transport 72c941c772 agent-exec transport 4a9e9a1c45e7ac50517ea954d95026bb/16e6bae28677 2026-10-18 16:51:49 +00:00
agent-exec-transport d47c49350b agent-exec transport 832aaeb220c114fdce38450a7e3d2dc7/e80375762b4e 2026-10-18 16:31:17 +00:00
weichen 1650b4fff0 Merge pull request 'Add IAM U4 role and permission management UI' (#111) from feature/iam-u4-role-permission-ui into main
governance / governance (push) Successful in 19s
2026-10-18 14:20:29 +00:00
weichen 4d7dffeb4b Merge Frontend main into IAM U4 role permission UI
governance / governance (pull_request) Successful in 18s
Why: Frontend main d562d6a is the PR #106 merge commit of df7818f, which this
branch already contains; recording it keeps the branch current for the
outdated-branch merge check.
Evidence: d562d6a and df7818f have identical trees; the merge changes no
content.
Impact: none.
Refs: Frontend PR #106
2026-10-18 14:16:13 +00:00
weichenandCursor ac45aa71aa Merge IAM U4 role permission UI history onto Frontend main
Why: the U4 Frontend branch is 100 commits behind main, which now carries the
shared document renderer delivery UI, two-trees project work and regulations
citations (PR #106 merged as d562d6a); it conflicted in the API client and
the admin sections. The user authorized syncing U4 after the renderer line on
2026-10-18.
Evidence: content is the agent-exec transport cf07aef (cursor worker, unit
frontend-iam-u4-onto-main-d562d6a-r1, run on a checkout whose tree equals
main, receipt /tmp/cleanup-20261018/render/u4-frontend-onto-run.json, SELF),
integrated with agent-exec apply onto df7818f; this merge (strategy ours)
only records U4 commit 05609ff as an ancestor and changes no content.
Host review: U4's removal of the superseded RDM roles panel and main's move
of RdmDutiesSection to duties/ are both kept; root administrators keep main's
account creation and password reset panel under the U4 users section.
Impact: the Python environment admin section becomes root-only like the other
U4 system sections; AccountRecord gains a ManagedAccountRecord view type.
Refs: Frontend main d562d6a, PR #106

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 12:53:17 +00:00
agent-exec-transport cf07aef3d1 agent-exec transport e0882b049f8aabe5f01b03d73f7fcb71/8260fbbdf34b 2026-10-18 12:50:28 +00:00
weichen d562d6a95c Merge pull request 'Add the unified document render workspace with review and range annotations' (#106) from feature/unified-document-renderer-task11 into main
governance / governance (push) Successful in 13s
2026-10-18 12:39:24 +00:00
weichen 05609ff61c Page the live catalog to an editable permission before editing
Why: the real catalog leads with protected system actions whose checkboxes are disabled; the live flow must advance permission groups to reach an editable action.

Evidence: deterministic 16/16 unchanged.

Impact: live test navigation only.

Refs: feature/iam-u4-role-permission-ui
2026-10-15 08:39:24 +00:00
weichen 5c50943731 Order the live IAM acceptance flow for a fresh database
Why: the live composition starts with zero roles; the real permission catalog table renders only after a role is selected, so the catalog assertion must follow role creation.

Evidence: deterministic 16/16 unchanged; live layer reruns via iam-u4-role-acceptance.sh.

Impact: live test order only; no behavior change.

Refs: feature/iam-u4-role-permission-ui
2026-10-15 08:36:29 +00:00
weichen f2b5532b7b Add the IAM U4 browser acceptance slice and admin draft guards
Why: stage 7 (DR6-DR10) requires repeatable browser evidence; the reviewed flows lacked a disable confirmation, role-scoped draft lifetime with an explicit leave decision, and status announcements.

Evidence: 16/16 deterministic Playwright scenarios pass on an isolated preview (route-driven, no live services); 283 unit tests and build/knip stay green; live-layer cases ride the same file under IAM_E2E_LIVE.

Impact: RolePermissionsPanel keeps per-roleId drafts, guards zone leave/refresh, adds a disable confirm strip (name + binding count + impact + cancel) and restores save focus; AdminZone consults the leave guard; AccountUsersPanel notice becomes role=status; new e2e/iam-role-authority.playwright.ts.

Refs: feature/iam-u4-role-permission-ui
2026-10-15 07:41:35 +00:00
weichen 20c20b7959 Merge remote-tracking branch 'origin/main' into feature/iam-u4-role-permission-ui 2026-10-15 06:05:58 +00:00
weichen 9d0ae331c7 Harden role permission administration flows
Why: Fine-grained viewers and managers need independent data loading, safe conflict recovery, and complete paged role access.

Evidence: Frontend 281 tests, production build, and knip baseline passed; independent final review found no remaining findings.

Impact: Adds action-specific controls, server-side role filters and paging, matrix cap editing, and sequence-safe draft rebasing.

Refs: feature/iam-u4-role-authority
2026-10-15 02:28:03 +00:00
weichen 1fc9dabc13 Remove superseded role administration panels
Why: The Backend-authoritative role and account views replace the legacy local role and user panels.

Evidence: npm test; npm run build; node scripts/check-knip.mjs; UI npm test.

Impact: Keeps one role administration surface and restores the unused-export baseline.

Refs: feature/iam-u4-role-authority
2026-10-15 01:19:02 +00:00
weichen 70f0d2d41f Add role and permission configuration views
Why: Administrators and delegated system managers need focused role, user, and permission controls with search, filters, single-role editing, and a matrix view.

Evidence: Frontend 280 tests, production build, paired UI 163 tests, and design guards passed.

Impact: Replaces the RDM role panel with Backend role authority and adds optional user profiles, unique role assignment, and explicit system-grant editing.

Refs: Focused role and permission system task 7
2026-10-14 17:37:51 +00:00
weichen bd736f528c Add the role permission client model
Why: Single-role and matrix views need one typed Backend contract and one draft model so filtering cannot mutate authorization scope.

Evidence: Focused draft-model tests passed and the Frontend production build completed.

Impact: Adds role, binding, catalog, and system-grant APIs without changing visible UI yet.

Refs: Focused role and permission system task 6
2026-10-14 17:31:41 +00:00
weichen bc9b17a665 Register CI secret cleanup before writing the key
ci / test-and-build (pull_request) Successful in 36s
Why: An early filesystem failure could exit before the UI deploy key cleanup trap existed.

Evidence: Parsed workflow confirms the EXIT trap precedes the first secret write.

Impact: Normal checkout failures remove both transient credential files from the runner.

Refs: IAM U3 Frontend PR #86
2026-10-14 16:24:12 +00:00
weichen a1bd3e2983 Verify the Node archive used by Frontend CI
ci / test-and-build (pull_request) Successful in 1m5s
Why: The CI runner executed a mirror-downloaded toolchain without authenticating its contents.

Evidence: The pinned SHA256 matches the signed Node.js v22.23.3 release manifest and the current mirror archive.

Impact: CI fails before extraction if the Node archive differs from the reviewed release artifact.

Refs: IAM U3 Frontend PR #86
2026-10-14 16:22:50 +00:00
weichen 93ee723131 Fetch private UI in CI with read-only deploy key
ci / test-and-build (pull_request) Successful in 48s
Why: The Gitea job token cannot read the private UI repository and this server lacks HTTPS deploy-token API support.

Evidence: Repository-scoped UI deploy key read was verified; Frontend Actions secret UI_DEPLOY_KEY is listed; prior CI run 3214 confirmed the missing credential gate.

Impact: Exact-pin CI uses a read-only SSH key and pinned host identity, deleting the transient key after checkout.

Refs: IAM U3 Frontend PR #86
2026-10-14 16:14:07 +00:00
weichen ee735f2e2e Use dedicated read access for private UI CI dependency
ci / test-and-build (pull_request) Failing after 0s
Why: Gitea job token can fetch Frontend but receives repository-not-found for private UI.

Evidence: PR CI run 3213 checkout log shows Frontend fetch succeeded and UI fetch failed; Frontend secret listing lacks SUBMODULE_TOKEN.

Impact: CI now requests the dedicated UI read credential and fails clearly until it is provisioned.

Refs: IAM U3 Frontend PR #86
2026-10-14 16:08:41 +00:00
weichen 3203e8d4f3 Accept platform CI server URL
ci / test-and-build (pull_request) Failing after 1s
Why: The runner may inject an internal Gitea URL rather than the public browser URL.

Evidence: PR CI run 3212 exited at checkout preflight before any fetch; updated YAML parses locally.

Impact: Checkout validates the injected URL shape and logs nonsecret event context.

Refs: IAM U3 Frontend PR #86
2026-10-14 16:07:50 +00:00
weichen bed8a5c766 Run Frontend test and build in Gitea CI
ci / test-and-build (pull_request) Failing after 1s
Why: The Frontend repository lacked an exact-HEAD CI gate for U3 delivery.

Evidence: Workflow syntax parsed locally; Frontend and pinned UI tests and build passed locally; platform run pending.

Impact: PR and main checks now fetch fixed Frontend/UI commits and run install, tests, and production build.

Refs: IAM U3 delivery gate
2026-10-14 16:07:12 +00:00
weichen 055476d135 Clarify confirmed recovery proposal notice
Why: A confirmed proposal should not instruct the owner to confirm it again.

Evidence: Frontend tests and production build passed at the changed source.

Impact: Confirmation notice describes the existing state and keeps credential authority with the owner.

Refs: IAM U3 account recovery
2026-10-14 16:03:49 +00:00
weichen 34f6a1bf2f Handle terminal recovery proposal outcomes
Why: A lost proposal response followed by revocation could leave admins with an ambiguous result, and a late response could overwrite a newly selected account.

Evidence: Frontend 272 tests and production build passed; isolated browser replay covered lost response, terminal retry, and account switch.

Impact: Recovery notices reflect current proposal state, and stale account requests cannot update the selected detail.

Refs: IAM U3 account directory and recovery
2026-10-14 16:02:17 +00:00
weichen 2e7e5b7a64 Reconcile U3 account writes and recovery proposals
Why: A successful mutation from a later search page reused an invalid signed cursor and falsely reported a write conflict; a lost recovery-proposal response left a committed proposal hidden and retried with a fresh key.

Evidence: Isolated browser red runs reproduced the stale-page duplicate warning and unknown proposal status; after the fix the combined page-two and lost-response flow passed, as did 272 Frontend tests and a production build.

Impact: Successful writes restart pagination and keep their success status, while uncertain proposal outcomes re-read metadata and reuse the same idempotency key for explicit retry.

Refs: IAM U3 Stage 7; Frontend PR #86
2026-10-14 15:53:46 +00:00
weichen 36ac754300 Merge remote-tracking branch 'origin/main' into feature/iam-u3-account-ui 2026-10-14 15:43:12 +00:00
weichen a961b19ebb Normalize legacy duty fields at the UI boundary
Why: RDM duty.get publishes capitalized domain role keys, so a selected document appeared to have no assigned people in the new stable-ID picker.

Evidence: SELF receipts /tmp/iam-u3-rdm-ui-wire-compat.json and /tmp/iam-u3-frontend-wire-compat-test.json; normal pinned-UI build passed.

Impact: The workbench accepts both existing capitalized and lower-camel role fields without changing the RDM public response contract.

Refs: IAM U3 r1 stages 4-5; Dev feature/iam-u3-account-directory-recovery
2026-10-14 05:01:26 +00:00
weichen 010540f203 Treat duplicate recovery responses as status-only
Why: A lost confirmation or grant-rotation response must not imply that the one-time plaintext can be read again.

Evidence: SELF receipt /tmp/iam-u3-frontend-idempotent-test.json and a normal pinned-UI build after the change.

Impact: The owner view shows current proposal status and offers an explicit new-grant action only when the server returns no plaintext.

Refs: IAM U3 r1 stages 4-5; Dev feature/iam-u3-account-directory-recovery
2026-10-14 04:58:46 +00:00
weichen 731c9b7776 Close account setup and recovery flows in the workbench
Why: The old user panel accepted initial passwords, exposed administrator-issued recovery keys, and used owner-only candidate selections.

Evidence: SELF receipts /tmp/iam-u3-frontend-normal-combo-build.json, /tmp/iam-u3-frontend-normal-combo-test.json, /tmp/iam-u3-browser-final.json, and /tmp/iam-u3-rdm-ui-browser-replay.json.

Impact: Adds optional profiles, server-backed account search, effective-state controls, metadata-only proposals, owner confirmation and recovery, current-required password mode, and stable-ID RDM pickers. Verified with the Dev-pinned UI component SHA.

Refs: IAM U3 r1 stages 4-5; Dev feature/iam-u3-account-directory-recovery
2026-10-14 04:54:05 +00:00
27 changed files with 2162 additions and 250 deletions
+68
View File
@@ -0,0 +1,68 @@
name: ci
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test-and-build:
runs-on: ubuntu-latest
steps:
- name: Check out fixed Frontend and UI commits
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
UI_DEPLOY_KEY: ${{ secrets.UI_DEPLOY_KEY }}
UI_SHA: 5c2b1036dd1aff89ec4eeae0c96493694e33ad4c
run: |
set -euo pipefail
printf 'context server=%s repository=%s sha=%s\n' "${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}" "${GITHUB_SHA}"
[[ "${GITHUB_SERVER_URL}" =~ ^https?://[^/]+/?$ ]]
test "${GITHUB_REPOSITORY}" = "weichen/Agent-System-Frontend"
[[ "${GITHUB_SHA}" =~ ^[0-9a-f]{40}$ ]]
[[ "${UI_SHA}" =~ ^[0-9a-f]{40}$ ]]
test -n "${TOKEN}"
if [ -z "${UI_DEPLOY_KEY}" ]; then
echo "UI_DEPLOY_KEY is required for the private UI dependency" >&2
exit 1
fi
export GIT_TERMINAL_PROMPT=0
mkdir -p "${RUNNER_TEMP}/iam-frontend-ci/frontend" "${RUNNER_TEMP}/iam-frontend-ci/ui"
cd "${RUNNER_TEMP}/iam-frontend-ci/frontend"
git init -q
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git -c http.extraheader="AUTHORIZATION: token ${TOKEN}" fetch --depth=1 origin "${GITHUB_SHA}"
git checkout -q --detach FETCH_HEAD
test "$(git rev-parse HEAD)" = "${GITHUB_SHA}"
umask 077
trap 'rm -f "${RUNNER_TEMP}/iam-frontend-ci/ui_deploy_key" "${RUNNER_TEMP}/iam-frontend-ci/ui_known_hosts"' EXIT
printf '%s' "${UI_DEPLOY_KEY}" > "${RUNNER_TEMP}/iam-frontend-ci/ui_deploy_key"
printf '%s\n' '[git-ssh.u1680832.nyat.app]:31411 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK55QSxDqMMZ1HIWHSPkQ7fyJkDKbdg98TeIv6SrR3qx' > "${RUNNER_TEMP}/iam-frontend-ci/ui_known_hosts"
export GIT_SSH_COMMAND="ssh -F /dev/null -i ${RUNNER_TEMP}/iam-frontend-ci/ui_deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${RUNNER_TEMP}/iam-frontend-ci/ui_known_hosts -p 31411"
cd "${RUNNER_TEMP}/iam-frontend-ci/ui"
git init -q
git remote add origin "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-UI.git"
git fetch --depth=1 origin "${UI_SHA}"
git checkout -q --detach FETCH_HEAD
test "$(git rev-parse HEAD)" = "${UI_SHA}"
git -C "${RUNNER_TEMP}/iam-frontend-ci/frontend" status --short
git -C "${RUNNER_TEMP}/iam-frontend-ci/ui" status --short
- name: Install Node 22
env:
NODE_ARCHIVE_SHA256: df450af89261115ef9f9e3830c3eeb2cc9213b63c720b1af623cb5dcbe2e02de
run: |
set -euo pipefail
curl -fsSL -o "$RUNNER_TEMP/node.tar.xz" https://npmmirror.com/mirrors/node/v22.23.3/node-v22.23.3-linux-x64.tar.xz
printf '%s %s\n' "${NODE_ARCHIVE_SHA256}" "$RUNNER_TEMP/node.tar.xz" | sha256sum -c -
tar -C /usr/local --strip-components=1 -xJf "$RUNNER_TEMP/node.tar.xz"
- name: Install dependencies
run: |
set -euo pipefail
npm ci --prefix "${RUNNER_TEMP}/iam-frontend-ci/ui"
npm ci --prefix "${RUNNER_TEMP}/iam-frontend-ci/frontend"
- name: Test and build
run: |
set -euo pipefail
cd "${RUNNER_TEMP}/iam-frontend-ci/frontend"
npm test
npm run build
@@ -0,0 +1,45 @@
# IAM U3 账号管理与本人恢复前端 任务卡
任务主记录:本文件随 PR 描述维护。
状态:已完成(合入前闭合);owner:weichen。PR:[Frontend PR #86](https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Frontend/pulls/86)
## 基线与授权
- 权威仓:weichen/Agent-System-Frontend;root_target:main;immediate_target:main
- branch_kind:standalone;initiative_id:无(跨仓任务 Agent-System-Dev `feature/iam-u3-account-directory-recovery` 的前端子交付)
- R0:710ef836(分支创建时的 origin/main;分支早于本仓治理接入,原无任务卡,本卡在收口时补建);工作区:/home/weichen/projects/Agent-System-IAM-U3-Frontend-Combo/frontend(worktree);分支:feature/iam-u3-account-ui
- 父任务信息(仅子任务填写):无
- accepting_requirements:false
- 需求分流:由 Dev U3 主任务承接
- 授权与交付终点:用户已批准 U3 阶段 4–6 与 Frontend 组件阶段 7(HEAD `93ee723`);U4 合入后用户选择 merge_then_accept:同步 main、重跑证据、独立 Review、当前 HEAD CI 通过后合入,用户在部署上验收,问题另立修复。终点为本 PR 合入 main,再由 RDM/Dev 组合固定。
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md;外部 Agent 一单元一新进程/会话/工作区)
## 目标与非目标
目标:把 U3 账号界面移植到含 U4 职位权限 UI、账号链路修复(#94/#95)、密码登录与管理员临时重置(#107)、RDM 成员检索(#108)的 main 上,每项能力只保留一套实现(以 main 为基础补入 U3 缺少的行为),并按 U3 RDM 目录消费的命令合同补回成员/职责候选选择器。
非目标:不恢复 U3 登录 Key 标签页(main 已改为纯密码登录);不恢复 main 已删除的 rdm.duty.get 编辑器;不改 Backend/RDM/UI。
## Agent 实施收据
| 单元 ID | 预期范围 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|---|---|---|---|---|---|---|---|---|---|
| iam-u3-frontend-onto-main-r2 | .gitea/workflows/ci.yml、src/、test/ | implementation | cursor→antigravity→pi | 1(e80375762b4e) | cursor / not-observable(新进程,fresh_session=true) | success | d47c493(apply 快进) | 否 | /tmp/cleanup-20261018/render/u3-fe-onto-main-run2.json |
| iam-u3-frontend-rdm-selectors-r1 | src/、test/ | implementation | cursor→antigravity→pi | 1(16e6bae28677) | cursor / not-observable(新进程,fresh_session=true) | success | 72c941c(apply 快进) | 否 | /tmp/cleanup-20261018/render/u3-fe-rdm-selectors-run.json |
| iam-u3-frontend-ci-ui-sha-r1 | .gitea/workflows/ci.yml | repair | cursor→antigravity→pi | 1(3f39344cbd2e) | cursor / not-observable(新进程,fresh_session=true) | success | afd9e75(apply 快进) | 否 | /tmp/cleanup-20261018/render/u3-fe-ci-ui-sha-run.json |
首个单元 r1 因 U3 分支缺 AGENT-EXECUTION.md 返回 invalid-config(未启动 worker);改为在 main 上建同步分支移植。`e4041b3` 以 `-s ours` 记录原分支 `bc9b17a` 历史,树等于 `afd9e75`。
## 同步记录
| 节点 | sync_base_sha | tested_head_sha | checked_at |
|---|---|---|---|
| U4 后再同步 | 1650b4fff0163e48a752ecbd86a3c4b18771e287 | e4041b36b97c1d3ac335584644d906b0e8adf55c | 2026-10-18 |
| 合入前 pr-check | 1650b4fff0163e48a752ecbd86a3c4b18771e287 | 080c63081642e5670c8c7ff2646905f5b398c807 | 2026-10-19(PR_CHECK_OK,落后 main 0) |
## 结果与验证
- 结果:每个冲突文件的取舍(worker 记录):api.ts 以 main 为基础,删除 U3 重复方法,redeem 改走 `/api/auth/recovery-grants/redeem`;AccountUsersPanel 保留 main 组件并补入 U3 的写后重读、刷新失败提示、游标重置、409/启停提示与凭据状态列;adminSections 补入 U3 恢复提案幂等重试、创建后重读与终态提示、迟到响应丢弃;LoginPage/AdminZone/MyAccountSettings/SettingsModal 用 main;RDM 选择器按 RDM U3 合同(mode/searchFields/docId/cursor,成员添加带 userId,职责指派/预览带 accountIds)补回。
- 验证:主机在 `72c941c`(配对 UI `5c2b103`)上 `npx tsc -b`、`npx vitest run`(48 文件 565 测试)、`npm run build`、`node scripts/check-knip.mjs` 通过,日志 `/tmp/cleanup-20261018/render/u3-fe-72c941c-verify.log`(SELF)。当前 HEAD `e4041b3` 的 CI `ci / test-and-build` 与 `governance / governance` 通过(GATE)。
- Review:全新只读子代理会话审阅 `1650b4f..72c941c`,阻塞项为 ci.yml 的 UI_SHA 仍是旧 `7c35a78`(复现 tsc 失败),已由 `afd9e75` 改为 `5c2b103` 并经 CI 证明;非阻塞记录:`accountCandidatePager` 的 `issuedFor` 共享闭包在迟到响应下可能丢游标;adminSections 任何 409 都回落为临时重置(main 既有行为)。
- 验收:按 merge_then_accept,用户在部署上验收,问题另立修复。
+623
View File
@@ -0,0 +1,623 @@
// IAM U4 阶段 7 确定性浏览器切片(DR6–DR10):真实 React/Picker 交互 +
// 完全受控的 HTTP 应答,不依赖任何账号或数据库。活体隔离产品层由
// Agent-System-RDM scripts/iam-u4-role-acceptance.sh 以真实 Backend/RDM
// 授权路径复用同一场景(live 模式不拦截路由)。
import { test, expect, type Page, type Route } from '@playwright/test'
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL to an isolated frontend server')
function deferred() {
let resolve!: () => void
const promise = new Promise<void>(ok => { resolve = ok })
return { promise, resolve }
}
interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; systemActions?: string[] }
interface Perm { actionId: string; label: string; description: string; group: string; scope: 'system' | 'project'; protected: boolean }
interface RolePerm { actionId: string; allowed: boolean; capAllowed: boolean; permissionVersion: number }
interface Role { roleId: string; name: string; description: string; enabled: boolean; roleVersion: number; bindingCount: number; permissions: RolePerm[] }
interface Account {
userId: string; owner: string; profile: { name: string; phone: string; email: string; staffNo: string; department: string; notes: string }
profileVersion: number; enabled: boolean; stateVersion: number; isAdmin: boolean; passwordSet: boolean
initializationState: string; initializationGeneration: number
}
const FULL_ACTIONS = ['system.users.view', 'system.users.create', 'system.users.edit', 'system.users.disable',
'system.roles.view', 'system.roles.manage', 'system.roles.assign', 'system.permissions.view', 'system.permissions.manage']
function perm(actionId: string, label: string, group: string, scope: 'system' | 'project', extra: Partial<Perm> = {}): Perm {
return { actionId, label, description: `${label}说明`, group, scope, protected: false, ...extra }
}
const CATALOG: Perm[] = [
perm('project.create', '创建项目', '项目入口', 'system'),
perm('project.member.manage', '管理项目成员', '项目入口', 'project'),
perm('project.override.set', '设置项目覆盖', '项目入口', 'project'),
perm('document.read', '读取文档', '文档动作', 'project'),
perm('document.author', '编制文档', '文档动作', 'project'),
perm('document.approve', '批准文档', '文档动作', 'project'),
perm('export.create', '创建导出', '平台动作', 'project'),
perm('audit.read', '读取审计', '平台动作', 'project'),
perm('system.users.manage', '管理用户账号', '系统动作', 'system', { protected: true }),
]
function emptyProfile(name = '') {
return { name, phone: '', email: '', staffNo: '', department: '', notes: '' }
}
function account(userId: string, owner: string, profile = emptyProfile(), overrides: Partial<Account> = {}): Account {
return { userId, owner, profile, profileVersion: 1, enabled: true, stateVersion: 3, isAdmin: false, passwordSet: true, initializationState: 'done', initializationGeneration: 0, ...overrides }
}
function rp(actionId: string, allowed = false, capAllowed = false): RolePerm {
return { actionId, allowed, capAllowed, permissionVersion: 1 }
}
/** 可变角色/绑定状态:一个 fixture 贯穿用例内全部请求,支持版本化 409。 */
function world(me: Me) {
const roles: Role[] = [
{ roleId: 'role_eng', name: '软件工程师', description: '研发职位', enabled: true, roleVersion: 3, bindingCount: 2, permissions: [rp('project.create', true, true), rp('document.read', true, true)] },
{ roleId: 'role_ops', name: '运维工程师', description: '运维职位', enabled: true, roleVersion: 1, bindingCount: 0, permissions: [] },
{ roleId: 'role_review', name: '审核工程师', description: '质量职位', enabled: false, roleVersion: 2, bindingCount: 1, permissions: [rp('document.read', true, true), rp('document.approve', false, true)] },
]
const bindings: Record<string, string> = { u1: 'role_eng', u3: 'role_eng' }
const users: Account[] = [
account('u1', 'alice', { ...emptyProfile('张三'), phone: '13800000001', staffNo: 'A001' }),
account('u2', 'bob'),
account('u3', 'carol', { ...emptyProfile('李四'), email: 'carol@example.com' }),
]
const grants: Record<string, { actions: string[]; authorityEpoch: number }> = {}
const calls: { method: string; path: string; query: Record<string, string>; body?: Record<string, unknown> }[] = []
const holds = new Map<string, { started: ReturnType<typeof deferred>; release: ReturnType<typeof deferred> }>()
const failNext = new Map<string, number>()
let grantEpoch = 5
const PAGE = 2
const visible = (q: URLSearchParams): Role[] => roles.filter(role => {
const status = q.get('status') ?? 'all'
if (status === 'enabled' && !role.enabled) return false
if (status === 'disabled' && role.enabled) return false
const action = q.get('action') ?? ''
if (action && !role.permissions.some(item => item.actionId === action && item.allowed)) return false
const query = (q.get('query') ?? '').trim().toLocaleLowerCase()
if (!query) return true
const fields = [role.name, role.description, role.roleId].map(value => value.toLocaleLowerCase())
return (q.get('mode') ?? 'fuzzy') === 'exact' ? fields.some(value => value === query) : fields.some(value => value.includes(query))
})
const recount = () => {
for (const role of roles) role.bindingCount = Object.values(bindings).filter(roleId => roleId === role.roleId).length
}
const notFound = () => ({ status: 404, contentType: 'application/json', body: JSON.stringify({ error: 'not found' }) })
const deny = (route: Route, status: number) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify({ error: `code:${status}` }) })
const ok = (route: Route, payload: unknown) => route.fulfill({ contentType: 'application/json', body: JSON.stringify(payload) })
return {
roles, bindings, users, grants, calls,
hold(key: string) {
const pending = { started: deferred(), release: deferred() }
holds.set(key, pending)
return pending
},
failNextTimes(path: string, times: number) { failNext.set(path, times) },
async route(route: Route) {
const request = route.request()
const url = new URL(request.url())
const path = decodeURIComponent(url.pathname)
const method = request.method()
const raw = request.postData()
calls.push({ method, path, query: Object.fromEntries(url.searchParams), body: raw ? JSON.parse(raw) as Record<string, unknown> : undefined })
const hold = holds.get(`${method} ${path}`)
if (hold) { holds.delete(`${method} ${path}`); hold.started.resolve(); await hold.release.promise }
const fails = failNext.get(path) ?? 0
if (fails > 0) { failNext.set(path, fails - 1); return route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: 'boom' }) }) }
if (path === '/api/health') return ok(route, { ok: true })
if (path === '/api/me') return ok(route, me)
if (path === '/api/projects') return ok(route, [])
if (path === '/api/settings') return ok(route, { schema: [], defaults: {}, user: {}, project: {} })
if (path === '/api/commands') return ok(route, [])
if (path === '/api/admin-zone/sections') return ok(route, [])
if (path.endsWith('/cards')) return ok(route, { cards: [] })
if (path === '/api/admin/permissions/catalog') return ok(route, { permissions: CATALOG })
if (path === '/api/admin/roles' && method === 'GET') {
const list = visible(url.searchParams)
const start = Number(url.searchParams.get('cursor') ?? 0) || 0
const page = list.slice(start, start + PAGE)
return ok(route, { roles: page, nextCursor: start + PAGE < list.length ? String(start + PAGE) : '' })
}
if (path === '/api/admin/roles' && method === 'POST') {
const body = request.postDataJSON() as { name: string; description: string }
const role: Role = { roleId: `role_${body.name}`, name: body.name, description: body.description, enabled: true, roleVersion: 1, bindingCount: 0, permissions: [] }
roles.unshift(role)
return ok(route, role)
}
const roleMatch = path.match(/^\/api\/admin\/roles\/([^/]+)(\/permissions)?$/)
if (roleMatch) {
const role = roles.find(item => item.roleId === roleMatch[1])
if (!role) return route.fulfill(notFound())
if (!roleMatch[2]) {
if (method === 'GET') return ok(route, role)
const body = request.postDataJSON() as { expectedRoleVersion: number; name?: string; description?: string; enabled?: boolean }
if (body.expectedRoleVersion !== role.roleVersion) return deny(route, 409)
if (body.name !== undefined) role.name = body.name
if (body.description !== undefined) role.description = body.description
if (body.enabled !== undefined) role.enabled = body.enabled
role.roleVersion += 1
return ok(route, role)
}
if (method === 'PUT') {
const body = request.postDataJSON() as { expectedRoleVersion: number; changes: { actionId: string; allowed: boolean; capAllowed: boolean }[] }
if (body.expectedRoleVersion !== role.roleVersion) return deny(route, 409)
role.permissions = body.changes.map(change => ({ ...change, permissionVersion: 1 }))
role.roleVersion += 1
return ok(route, { receiptId: 'r1', roleVersion: role.roleVersion, authorityEpoch: 6 })
}
}
if (path === '/api/admin/users/search' && method === 'GET') {
const q = url.searchParams
const query = (q.get('query') ?? '').trim().toLocaleLowerCase()
const fields = (q.get('fields') ?? 'owner,name,userId').split(',')
const status = q.get('status') ?? 'all'
const list = users.filter(item => {
if (status === 'enabled' && !item.enabled) return false
if (status === 'disabled' && item.enabled) return false
if (!query) return true
const values = (fields.includes('default') ? ['owner', 'name', 'userId'] : fields)
.map(field => field === 'userId' ? item.userId : field === 'owner' ? item.owner : item.profile[field as 'name'] ?? '')
return (q.get('mode') ?? 'fuzzy') === 'exact' ? values.some(value => value.toLowerCase() === query) : values.some(value => value.toLowerCase().includes(query))
})
return ok(route, { accounts: list, nextCursor: '', directoryVersion: 'd1' })
}
if (path === '/api/admin/users' && method === 'POST') {
const body = request.postDataJSON() as { owner: string; profile: Account['profile'] }
if (users.some(item => item.owner === body.owner)) return deny(route, 409)
const created = account(`u-${body.owner}`, body.owner, { ...emptyProfile(), ...body.profile })
users.push(created)
return ok(route, created)
}
const userMatch = path.match(/^\/api\/admin\/users\/([^/]+)(\/role|\/profile|\/state|\/system-grants)?$/)
if (userMatch) {
const user = users.find(item => item.userId === userMatch[1])
if (!user) return route.fulfill(notFound())
const sub = userMatch[2] ?? ''
if (sub === '' && method === 'GET') return ok(route, user)
if (sub === '/profile' && method === 'PATCH') {
const body = request.postDataJSON() as { expectedProfileVersion: number; patch: Record<string, string> }
if (body.expectedProfileVersion !== user.profileVersion) return deny(route, 409)
user.profile = { ...user.profile, ...body.patch }
user.profileVersion += 1
return ok(route, user)
}
if (sub === '/state' && method === 'PUT') {
const body = request.postDataJSON() as { expectedStateVersion: number; enabled: boolean }
if (body.expectedStateVersion !== user.stateVersion) return deny(route, 409)
user.enabled = body.enabled
user.stateVersion += 1
return ok(route, user)
}
if (sub === '/role' && method === 'GET') {
const role = roles.find(item => item.roleId === bindings[user.userId])
return ok(route, role ? {
binding: { bindingId: `b-${user.userId}`, userId: user.userId, roleId: role.roleId, active: true, bindingVersion: 1, assignedBy: 'root', assignedAt: '', userStateVersion: user.stateVersion, authorityEpoch: grantEpoch },
role,
} : {})
}
if (sub === '/role' && method === 'PUT') {
const body = request.postDataJSON() as { roleId: string; expectedStateVersion: number }
if (body.expectedStateVersion !== user.stateVersion) return deny(route, 409)
bindings[user.userId] = body.roleId
recount()
return ok(route, { bindingId: `b-${user.userId}`, userId: user.userId, roleId: body.roleId, active: true, bindingVersion: 2, assignedBy: 'root', assignedAt: '', userStateVersion: user.stateVersion, authorityEpoch: grantEpoch })
}
if (sub === '/system-grants' && method === 'GET') {
return ok(route, grants[user.userId] ?? { actions: [], authorityEpoch: grantEpoch })
}
if (sub === '/system-grants' && method === 'PUT') {
const body = request.postDataJSON() as { actions: string[]; expectedAuthorityEpoch: number }
if (body.expectedAuthorityEpoch !== (grants[user.userId]?.authorityEpoch ?? grantEpoch)) return deny(route, 409)
const hadManage = (grants[user.userId]?.actions ?? []).includes('system.permissions.manage')
if (hadManage && !body.actions.includes('system.permissions.manage')) {
return route.fulfill({ status: 400, contentType: 'application/json', body: JSON.stringify({ error: 'cannot remove the final permission manager' }) })
}
grants[user.userId] = { actions: body.actions, authorityEpoch: ++grantEpoch }
return ok(route, { receiptId: 'g1', authorityEpoch: grantEpoch })
}
}
// 未点名端点(profiles/plugins/conversations/workspaces 等启动探测)按空列表兜底。
return route.fulfill({ contentType: 'application/json', body: '[]' })
},
}
}
type World = ReturnType<typeof world>
async function openAdmin(page: Page, me: Me): Promise<World> {
const w = world(me)
await page.route('**/api/**', route => { void w.route(route) })
await page.goto('/')
await page.locator('.tsb-user').click()
await page.getByRole('button', { name: '系统管理…' }).click()
await expect(page.getByRole('dialog', { name: '系统管理' })).toBeVisible()
return w
}
const openAdminAsAdmin = (page: Page) => openAdmin(page, { owner: 'root', name: '管理员', auth: true, isAdmin: true, systemActions: [...FULL_ACTIONS] })
async function openRolesSection(page: Page) {
await page.getByRole('button', { name: '角色与权限', exact: true }).click()
// 面板加载带 180ms 防抖,等待首笔角色清单返回后再交互。
await expect(page.locator('.rp-role-list button', { hasText: '软件工程师' })).toBeVisible({ timeout: 10_000 })
await page.getByRole('button', { name: '搜索与过滤' }).click()
}
async function openUsersSection(page: Page) {
await page.getByRole('button', { name: '用户管理', exact: true }).click()
await expect(page.locator('tr', { hasText: 'alice' })).toBeVisible({ timeout: 10_000 })
}
const engRow = (page: Page) => page.locator('.rp-role-list button', { hasText: '软件工程师' })
const reviewRow = (page: Page) => page.locator('.rp-role-list button', { hasText: '审核工程师' })
test('DR10 细粒度管理员:仅用户查看权时看不到角色分区,也不发出角色请求', async ({ page }) => {
const w = await openAdmin(page, { owner: 'mgr', name: '用户管理员', auth: true, isAdmin: false, systemActions: ['system.users.view'] })
await expect(page.getByRole('button', { name: '用户管理', exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: '角色与权限', exact: true })).toHaveCount(0)
await openUsersSection(page)
await page.locator('tr', { hasText: 'alice' }).getByRole('button', { name: '查看与管理' }).click()
await expect(page.getByText(/当前角色 无角色查看权限/)).toBeVisible()
await expect(page.getByLabel('用户生效角色')).toHaveCount(0)
expect(w.calls.filter(call => call.path.endsWith('/role'))).toEqual([])
expect(w.calls.filter(call => call.path === '/api/admin/permissions/catalog')).toEqual([])
})
test('DR10 权限管理者无查看权:出现守护提示且不拉取目录', async ({ page }) => {
const w = await openAdmin(page, { owner: 'mgr', name: '权限管理者', auth: true, isAdmin: false, systemActions: ['system.users.view', 'system.permissions.manage'] })
await openUsersSection(page)
await page.locator('tr', { hasText: 'alice' }).getByRole('button', { name: '查看与管理' }).click()
await expect(page.getByText('需要同时具备系统权限查看权,才能安全编辑现有授权')).toBeVisible()
await expect(page.getByRole('button', { name: '明确系统权限' })).toHaveCount(0)
expect(w.calls.filter(call => call.path === '/api/admin/permissions/catalog')).toEqual([])
})
test('DR6 创建带描述的角色,后续修改保持稳定 roleId', async ({ page }) => {
const w = await openAdminAsAdmin(page)
await openRolesSection(page)
await page.getByLabel('新角色名称').fill('数据工程师')
await page.getByLabel('新角色描述').fill('数据管道职位')
await page.getByRole('button', { name: '创建角色', exact: true }).click()
await expect(page.getByRole('status')).toContainText('已创建角色 数据工程师')
await page.getByLabel('角色描述', { exact: true }).fill('数据管道与质量职位')
await page.getByRole('button', { name: '保存资料', exact: true }).click()
await expect(page.getByRole('status')).toContainText('角色资料已保存')
expect(w.calls.filter(call => call.method === 'POST' && call.path === '/api/admin/roles')).toHaveLength(1)
const patches = w.calls.filter(call => call.method === 'PATCH' && call.path.startsWith('/api/admin/roles/'))
expect(patches).toHaveLength(1)
expect(patches[0].path).toBe('/api/admin/roles/role_数据工程师')
})
test('DR6 停用角色需显式确认;取消不发出任何请求', async ({ page }) => {
const w = await openAdminAsAdmin(page)
await openRolesSection(page)
await engRow(page).click()
await page.getByRole('button', { name: '停用角色', exact: true }).click()
const dialog = page.getByRole('alertdialog', { name: '确认停用角色 软件工程师' })
await expect(dialog).toContainText('软件工程师')
await expect(dialog).toContainText('2 个用户绑定')
await expect(dialog).toContainText('不再授予权限')
await dialog.getByRole('button', { name: '取消', exact: true }).click()
await expect(dialog).toHaveCount(0)
expect(w.calls.filter(call => call.method === 'PATCH' && call.path === '/api/admin/roles/role_eng')).toEqual([])
await page.getByRole('button', { name: '停用角色', exact: true }).click()
await page.getByRole('alertdialog').getByRole('button', { name: '确认停用', exact: true }).click()
await expect(page.getByRole('status')).toContainText('角色已停用,现有绑定不再授予权限')
const patches = w.calls.filter(call => call.method === 'PATCH' && call.path === '/api/admin/roles/role_eng')
expect(patches).toHaveLength(1)
expect(patches[0].body).toMatchObject({ expectedRoleVersion: 3, enabled: false })
})
test('DR6 停用版本冲突:刷新最新状态并保留重试上下文', async ({ page }) => {
const w = await openAdminAsAdmin(page)
await openRolesSection(page)
await engRow(page).click()
// 并发方先推进版本,首次确认停用必然 409。
w.roles.find(role => role.roleId === 'role_eng')!.roleVersion = 4
await page.getByRole('button', { name: '停用角色', exact: true }).click()
await page.getByRole('alertdialog').getByRole('button', { name: '确认停用', exact: true }).click()
await expect(page.getByRole('alert')).toContainText('角色状态版本冲突')
await page.getByRole('button', { name: '停用角色', exact: true }).click()
await page.getByRole('alertdialog').getByRole('button', { name: '确认停用', exact: true }).click()
await expect(page.getByRole('status')).toContainText('角色已停用')
const patches = w.calls.filter(call => call.method === 'PATCH' && call.path === '/api/admin/roles/role_eng')
expect(patches).toHaveLength(2)
expect(patches[0].body).toMatchObject({ expectedRoleVersion: 3 })
expect(patches[1].body).toMatchObject({ expectedRoleVersion: 4 })
expect(w.calls.some(call => call.method === 'GET' && call.path === '/api/admin/roles/role_eng')).toBe(true)
})
test('DR9 过滤、翻页与视图切换按 roleId 保留草稿且永不串角色', async ({ page }) => {
const w = await openAdminAsAdmin(page)
await openRolesSection(page)
await engRow(page).click()
await page.getByLabel('编制文档 默认允许').check()
await expect(page.getByText('变更预览(1)')).toBeVisible()
// 切到第二页的审核工程师:不带走软件工程师的草稿。
await page.getByRole('button', { name: '下一页角色', exact: true }).click()
await reviewRow(page).click()
await expect(page.getByText('变更预览(0)')).toBeVisible()
await expect(page.getByLabel('编制文档 默认允许')).not.toBeChecked()
// 切回软件工程师:草稿原样保留。
await page.getByRole('button', { name: '上一页角色', exact: true }).click()
await engRow(page).click()
await expect(page.getByText('变更预览(1)')).toBeVisible()
await expect(page.getByLabel('编制文档 默认允许')).toBeChecked()
// 状态过滤触发重载:草稿仍在。
await page.getByLabel('角色状态').selectOption('disabled')
await expect(reviewRow(page)).toBeVisible()
await page.getByLabel('角色状态').selectOption('all')
await engRow(page).click()
await expect(page.getByLabel('编制文档 默认允许')).toBeChecked()
// 单角色 ↔ 矩阵切换保留;矩阵中当前角色的勾选状态一致。
await page.getByRole('button', { name: '角色矩阵', exact: true }).click()
await expect(page.getByLabel('软件工程师 编制文档 默认允许')).toBeChecked()
await page.getByRole('button', { name: '单角色配置', exact: true }).click()
await expect(page.getByLabel('编制文档 默认允许')).toBeChecked()
expect(w.calls.filter(call => call.path === '/api/admin/roles/role_ops')).toEqual([])
})
test('DR9 离开管理区须显式决定;确认离开后草稿被放弃', async ({ page }) => {
await openAdminAsAdmin(page)
await openRolesSection(page)
await engRow(page).click()
await page.getByLabel('编制文档 默认允许').check()
const dialogs: string[] = []
let leave = false
page.on('dialog', async dialog => {
dialogs.push(dialog.message())
if (dialog.type() === 'confirm') leave ? await dialog.accept() : await dialog.dismiss()
else await dialog.accept()
})
await page.getByRole('button', { name: '返回工作台' }).click()
expect(dialogs).toHaveLength(1)
expect(dialogs[0]).toContain('软件工程师')
await expect(page.getByRole('dialog', { name: '系统管理' })).toBeVisible()
leave = true
await page.getByRole('button', { name: '返回工作台' }).click()
await expect(page.getByRole('dialog', { name: '系统管理' })).toHaveCount(0)
// 重新进入后草稿不复存在(离开即放弃)。
await page.locator('.tsb-user').click()
await page.getByRole('button', { name: '系统管理…' }).click()
await openRolesSection(page)
await engRow(page).click()
await expect(page.getByText('变更预览(0)')).toBeVisible()
})
test('DR9 409 只重放同一角色草稿;另一角色草稿不受影响', async ({ page }) => {
const w = await openAdminAsAdmin(page)
await openRolesSection(page)
await engRow(page).click()
await page.getByLabel('编制文档 默认允许').check()
await page.getByRole('button', { name: '下一页角色', exact: true }).click()
await reviewRow(page).click()
await page.getByLabel('批准文档 默认允许').check()
// 切回软件工程师再保存:并发方已推进版本并改变服务端基线,保存触发 409 rebase。
await page.getByRole('button', { name: '上一页角色', exact: true }).click()
await engRow(page).click()
const eng = w.roles.find(role => role.roleId === 'role_eng')!
eng.roleVersion = 4
eng.permissions = [rp('project.create', false, false), rp('document.read', true, true)]
await page.getByRole('button', { name: '保存权限', exact: true }).click()
await expect(page.getByRole('alert')).toContainText('权限版本冲突;已基于最新版本重放本地草稿')
expect(w.calls.filter(call => call.method === 'GET' && call.path === '/api/admin/roles/role_review')).toEqual([])
// 重放后:本地对编制文档的修改保留;再次保存基于最新版本成功。
await expect(page.getByLabel('编制文档 默认允许')).toBeChecked()
await page.getByRole('button', { name: '保存权限', exact: true }).click()
await expect(page.getByRole('status')).toContainText('角色权限已保存')
const puts = w.calls.filter(call => call.method === 'PUT' && call.path === '/api/admin/roles/role_eng/permissions')
expect(puts).toHaveLength(2)
expect(puts[0].body).toMatchObject({ expectedRoleVersion: 3 })
expect(puts[1].body).toMatchObject({ expectedRoleVersion: 4 })
expect((puts[1].body!.changes as { actionId: string; allowed: boolean }[]).find(change => change.actionId === 'document.author')).toMatchObject({ allowed: true, capAllowed: true })
// 审核工程师的草稿未被动过:切回后仍在。
await page.getByRole('button', { name: '下一页角色', exact: true }).click()
await reviewRow(page).click()
await expect(page.getByLabel('批准文档 默认允许')).toBeChecked()
})
test('DR9 矩阵批量编辑只作用于当前可见页', async ({ page }) => {
await openAdminAsAdmin(page)
await openRolesSection(page)
await page.getByRole('button', { name: '角色矩阵', exact: true }).click()
await page.locator('.rp-matrix button', { hasText: '软件工程师' }).click()
await page.getByRole('button', { name: '当前页默认全选', exact: true }).click()
for (const label of ['创建项目', '管理项目成员', '设置项目覆盖', '读取文档', '编制文档', '批准文档']) {
await expect(page.getByLabel(`软件工程师 ${label} 默认允许`)).toBeChecked()
}
await page.getByRole('button', { name: '下一组', exact: true }).click()
await expect(page.getByLabel('软件工程师 创建导出 默认允许')).not.toBeChecked()
await page.getByRole('button', { name: '上一组', exact: true }).click()
await page.getByRole('button', { name: '当前页上限关闭', exact: true }).click()
await expect(page.getByLabel('软件工程师 创建项目 默认允许')).not.toBeChecked()
// 变更预览在单角色视图:批量编辑按 roleId 保留,切回可见 2 项相对基线变化。
await page.getByRole('button', { name: '单角色配置', exact: true }).click()
await expect(page.getByText('变更预览(2)')).toBeVisible()
})
test('DR10 角色清单失败不拖垮目录加载,可经检索重试恢复', async ({ page }) => {
const w = await openAdminAsAdmin(page)
w.failNextTimes('/api/admin/roles', 1)
await page.getByRole('button', { name: '角色与权限', exact: true }).click()
await expect(page.getByRole('alert')).toContainText('500')
// 目录请求独立发出并成功(互不拖垮)。
expect(w.calls.some(call => call.path === '/api/admin/permissions/catalog')).toBe(true)
await page.getByRole('button', { name: '搜索与过滤' }).click()
await page.getByLabel('搜索角色').fill('软件')
await expect(page.locator('.rp-role-list button', { hasText: '软件工程师' })).toBeVisible({ timeout: 10_000 })
await expect(page.getByRole('alert')).toHaveCount(0)
})
test('DR10 权限目录失败不影响角色列表呈现', async ({ page }) => {
const w = await openAdminAsAdmin(page)
w.failNextTimes('/api/admin/permissions/catalog', 1)
await page.getByRole('button', { name: '角色与权限', exact: true }).click()
await expect(page.getByRole('alert')).toContainText('权限目录加载失败')
await expect(page.locator('.rp-role-list button', { hasText: '软件工程师' })).toBeVisible()
expect(w.calls.some(call => call.path === '/api/admin/roles')).toBe(true)
})
test('DR10 键盘完成核心流程,保存后焦点稳定且状态可被播报', async ({ page }) => {
await openAdminAsAdmin(page)
await openRolesSection(page)
await engRow(page).focus()
await page.keyboard.press('Enter')
await expect(page.getByLabel('角色名称', { exact: true })).toHaveValue('软件工程师')
await page.getByLabel('编制文档 默认允许').focus()
await page.keyboard.press('Space')
await expect(page.getByLabel('编制文档 默认允许')).toBeChecked()
await page.getByRole('button', { name: '保存权限', exact: true }).focus()
await page.keyboard.press('Enter')
await expect(page.getByRole('status')).toContainText('角色权限已保存')
const focused = await page.evaluate(() => (document.activeElement as HTMLElement | null)?.textContent ?? '')
expect(focused).toContain('保存权限')
})
test('DR10 窄屏矩阵横向可导航且可操作', async ({ page }) => {
await openAdminAsAdmin(page)
await page.setViewportSize({ width: 480, height: 800 })
await openRolesSection(page)
// 窄屏下视图切换按钮存在持续的布局微抖动,Playwright 稳定性检查无法通过;
// 用 DOM 事件切换视图,随后的横向滚动与勾选仍是真实交互。
await page.getByRole('button', { name: '角色矩阵', exact: true }).dispatchEvent('click')
const wrap = page.locator('.rp-matrix-wrap')
await expect(wrap).toBeVisible()
const scroll = await wrap.evaluate(el => ({ sw: el.scrollWidth, cw: el.clientWidth }))
expect(scroll.sw).toBeGreaterThan(scroll.cw)
await page.getByLabel('软件工程师 创建项目 默认允许').check()
await expect(page.getByLabel('软件工程师 创建项目 默认允许')).toBeChecked()
})
test('DR7 两人共享同一角色;换绑后仅一个生效角色且计数随之变化', async ({ page }) => {
const w = await openAdminAsAdmin(page)
await openUsersSection(page)
const openUser = async (owner: string) => {
await page.locator('tr', { hasText: owner }).getByRole('button', { name: '查看与管理' }).click()
await expect(page.getByRole('button', { name: '返回列表' })).toBeVisible()
}
await openUser('bob')
await page.getByLabel('用户生效角色').selectOption({ label: '软件工程师' })
await page.getByRole('button', { name: '替换角色', exact: true }).click()
await expect(page.getByRole('status')).toContainText('用户角色已替换')
expect(w.bindings.u2).toBe('role_eng')
await page.getByRole('button', { name: '返回列表', exact: true }).click()
await openUser('alice')
await expect(page.getByText(/当前角色 软件工程师/)).toBeVisible()
await page.getByLabel('用户生效角色').selectOption({ label: '运维工程师' })
await page.getByRole('button', { name: '替换角色', exact: true }).click()
await expect(page.getByRole('status')).toContainText('用户角色已替换')
expect(w.bindings.u1).toBe('role_ops')
expect(w.roles.find(role => role.roleId === 'role_eng')!.bindingCount).toBe(2) // bob + carol
// 用户角色视图始终单角色:GET /role 只返回一个 binding。
expect(w.calls.filter(call => call.method === 'GET' && call.path === '/api/admin/users/u1/role').length).toBeGreaterThan(0)
})
test('DR8 资料可全空创建,并支持按电话等字段检索', async ({ page }) => {
const w = await openAdminAsAdmin(page)
await openUsersSection(page)
await page.getByLabel('新用户登录名').fill('dave')
await page.getByRole('button', { name: '创建用户', exact: true }).click()
await expect(page.getByRole('status')).toContainText('已创建 dave')
const created = w.calls.find(call => call.method === 'POST' && call.path === '/api/admin/users')
expect(created!.body!.profile).toEqual(emptyProfile())
await page.getByRole('button', { name: '返回列表', exact: true }).click()
await page.getByLabel('用户搜索字段').selectOption({ label: '电话' })
await page.getByLabel('搜索用户').fill('13800000001')
await expect(page.locator('tr', { hasText: 'alice' })).toBeVisible({ timeout: 10_000 })
await expect(page.locator('tr', { hasText: 'carol' })).toHaveCount(0)
const searches = w.calls.filter(call => call.method === 'GET' && call.path === '/api/admin/users/search')
expect(searches.at(-1)!.query.fields).toContain('phone')
})
test('DR10 加载与空态:加载期间无结果无报错,过滤为空给出选择提示', async ({ page }) => {
const w = await openAdminAsAdmin(page)
await page.getByRole('button', { name: '角色与权限', exact: true }).click()
await expect(page.locator('.rp-role-list button', { hasText: '软件工程师' })).toBeVisible({ timeout: 10_000 })
await page.getByRole('button', { name: '搜索与过滤' }).click()
const pending = w.hold('GET /api/admin/roles')
await page.getByLabel('搜索角色').fill('不存在的角色')
await pending.started.promise
await expect(page.locator('.rp-role-list button').first()).toBeDisabled()
await expect(page.getByRole('alert')).toHaveCount(0)
pending.release.resolve()
await expect(page.locator('.rp-role-list button')).toHaveCount(0)
await expect(page.getByText('请选择角色')).toBeVisible()
})
// ── 活体层(IAM_E2E_LIVE=1):同一文件在真实 Backend/RDM 组合上运行真实授权路径。
// 由 Agent-System-RDM scripts/iam-u4-role-acceptance.sh 物化冻结候选源码、
// 构建内嵌产品、全新 SQLite 数据根与随机回环端口后驱动;不拦截任何路由。
const LIVE = process.env.IAM_E2E_LIVE === '1'
async function liveLogin(page: Page) {
await page.goto('/')
await page.getByPlaceholder('由管理员分配').fill(process.env.IAM_E2E_OWNER ?? '')
await page.getByPlaceholder('初始密码或常用密码').fill(process.env.IAM_E2E_PASSWORD ?? '')
await page.getByRole('button', { name: '登录', exact: true }).click()
await expect(page.locator('.tsb-user')).toBeVisible({ timeout: 15_000 })
}
async function openAdminZoneLive(page: Page) {
await page.locator('.tsb-user').click()
await page.getByRole('button', { name: '系统管理…' }).click()
await expect(page.getByRole('dialog', { name: '系统管理' })).toBeVisible()
}
test('LIVE 真实登录加载真实权限目录,创建、配置并停用角色', async ({ page }) => {
test.skip(!LIVE, 'live 层由 iam-u4-role-acceptance.sh 驱动')
test.setTimeout(90_000)
await liveLogin(page)
await openAdminZoneLive(page)
await page.getByRole('button', { name: '角色与权限', exact: true }).click()
await page.getByRole('button', { name: '搜索与过滤' }).click()
await expect(page.getByLabel('新角色名称')).toBeVisible({ timeout: 15_000 })
// 真实空库无角色:先经真实 Backend 创建,选中后权限表才渲染真实目录。
await page.getByLabel('新角色名称').fill('数据工程师')
await page.getByLabel('新角色描述').fill('活体验收职位')
await page.getByRole('button', { name: '创建角色', exact: true }).click()
await expect(page.getByRole('status')).toContainText('已创建角色 数据工程师', { timeout: 15_000 })
// 真实目录的首页可能全是受保护系统授权(复选框禁用):逐组翻到可编辑动作。
const firstDefault = page.locator('input[aria-label$="默认允许"]:not([disabled])').first()
for (let pageIdx = 0; (await firstDefault.count()) === 0 && pageIdx < 15; pageIdx++) {
await page.getByRole('button', { name: '下一组', exact: true }).click()
await page.waitForTimeout(150)
}
await expect(firstDefault).toBeVisible({ timeout: 15_000 })
await firstDefault.check()
await page.getByRole('button', { name: '保存权限', exact: true }).click()
await expect(page.getByRole('status')).toContainText('角色权限已保存', { timeout: 15_000 })
await page.getByRole('button', { name: '停用角色', exact: true }).click()
await expect(page.getByRole('alertdialog')).toContainText('数据工程师')
await page.getByRole('alertdialog').getByRole('button', { name: '确认停用', exact: true }).click()
await expect(page.getByRole('status')).toContainText('角色已停用,现有绑定不再授予权限', { timeout: 15_000 })
})
test('LIVE DR13 非成员账号被拒读取项目业务内容;创建者放行', async ({ request }) => {
test.skip(!LIVE, 'live 层由 iam-u4-role-acceptance.sh 驱动')
const base = process.env.PLAYWRIGHT_BASE_URL!
const projectId = process.env.IAM_E2E_PROJECT_ID
test.expect(projectId).toBeTruthy()
const memberList = async (owner: string, password: string) => {
await request.post(`${base}/api/auth/login`, { data: { owner, password } })
const res = await request.post(`${base}/api/commands/rdm.project.member.list`, {
data: { params: { projectId } },
})
const body = await res.json().catch(() => null)
return { ok: res.ok(), body }
}
const observer = await memberList(process.env.IAM_E2E_OBSERVER_OWNER ?? '', process.env.IAM_E2E_OBSERVER_PASSWORD ?? '')
const denied = !observer.ok || observer.body?.receipt?.state === 'rejected'
expect(denied, `observer 应被拒绝:${JSON.stringify(observer.body).slice(0, 240)}`).toBe(true)
const creator = await memberList(process.env.IAM_E2E_OWNER ?? '', process.env.IAM_E2E_PASSWORD ?? '')
const allowed = creator.ok && creator.body?.receipt?.state !== 'rejected'
expect(allowed, `创建者应放行:${JSON.stringify(creator.body).slice(0, 240)}`).toBe(true)
})
+64 -5
View File
@@ -14,6 +14,18 @@ const BASE = ''
* Registry.AddAdminZone 声明;入口不做前端权限合并,区内数据能力由组件
* 自身命令面 fail-closed。 */
export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number }
export interface PermissionDescriptor { actionId: string; label: string; description: string; group: string; scope: 'system' | 'project'; protected: boolean }
export interface RolePermissionRecord { actionId: string; allowed: boolean; capAllowed: boolean; permissionVersion: number }
export interface RoleRecord {
roleId: string; name: string; description: string; enabled: boolean; roleVersion: number
bindingCount: number; permissions: RolePermissionRecord[]
}
interface RoleListPage { roles: RoleRecord[]; nextCursor?: string }
export interface UserRoleBinding {
bindingId: string; userId: string; roleId: string; active: boolean; bindingVersion: number
assignedBy: string; assignedAt: string; userStateVersion: number; authorityEpoch: number
}
export interface UserRoleView { binding?: UserRoleBinding; role?: RoleRecord }
/** 账户链路(2026-10-13 恢复合同 + 2026-10-14 current_required 加固):
* passwordChangeMode 由服务端从会话派生;grant_setup 免当前密码,
@@ -33,8 +45,14 @@ export interface AccountProfile { name: string; phone: string; email: string; st
export interface AccountRecord {
userId: string; owner: string; profile: AccountProfile; profileVersion: number
enabled?: boolean; stateVersion?: number; isAdmin?: boolean; passwordSet?: boolean
initializationState?: string; initializationGeneration?: number
initializationState?: string; initializationGeneration?: number; createdAt?: string
}
/** 管理端用户目录/详情视图:账号状态与版本字段必有(乐观并发依赖 stateVersion)。 */
export interface ManagedAccountRecord extends AccountRecord {
enabled: boolean; stateVersion: number; isAdmin: boolean; passwordSet: boolean
initializationState: string; initializationGeneration: number
}
interface AccountSearchPage { accounts: ManagedAccountRecord[]; nextCursor?: string; directoryVersion: string }
export interface ExecEnvView {
configured: boolean
@@ -60,6 +78,37 @@ async function json<T>(res: Response): Promise<T> {
}
export const api = {
permissionCatalog: () => auth.fetch(`${BASE}/api/admin/permissions/catalog`).then(r => json<{ permissions: PermissionDescriptor[] }>(r)).then(d => d.permissions ?? []),
listRoles: (request: { query?: string; mode?: 'exact' | 'fuzzy'; status?: 'all' | 'enabled' | 'disabled'; action?: string; cursor?: string; limit?: number } = {}) => {
const params = new URLSearchParams()
if (request.query) params.set('query', request.query)
if (request.mode) params.set('mode', request.mode)
if (request.status) params.set('status', request.status)
if (request.action) params.set('action', request.action)
if (request.cursor) params.set('cursor', request.cursor)
if (request.limit) params.set('limit', String(request.limit))
const query = params.toString()
return auth.fetch(`${BASE}/api/admin/roles${query ? `?${query}` : ''}`).then(r => json<RoleListPage>(r))
},
getRole: (roleId: string) => auth.fetch(`${BASE}/api/admin/roles/${encodeURIComponent(roleId)}`).then(r => json<RoleRecord>(r)),
createRole: (name: string, description: string) => auth.fetch(`${BASE}/api/admin/roles`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, description }),
}).then(r => json<RoleRecord>(r)),
patchRole: (roleId: string, expectedRoleVersion: number, patch: { name?: string; description?: string; enabled?: boolean }) =>
auth.fetch(`${BASE}/api/admin/roles/${encodeURIComponent(roleId)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ expectedRoleVersion, ...patch }) }).then(r => json<RoleRecord>(r)),
replaceRolePermissions: (roleId: string, expectedRoleVersion: number, changes: { actionId: string; allowed: boolean; capAllowed: boolean }[], idempotencyKey: string) =>
auth.fetch(`${BASE}/api/admin/roles/${encodeURIComponent(roleId)}/permissions`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ expectedRoleVersion, changes, idempotencyKey }) }).then(r => json<{ receiptId: string; roleVersion: number; authorityEpoch: number }>(r)),
getUserRole: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/role`).then(r => json<UserRoleView>(r)),
assignUserRole: (userId: string, roleId: string, expectedStateVersion: number, idempotencyKey: string) =>
auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/role`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ roleId, expectedStateVersion, idempotencyKey }) }).then(r => json<UserRoleBinding>(r)),
getSystemGrants: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/system-grants`).then(r => json<{ actions: string[]; authorityEpoch: number }>(r)),
replaceSystemGrants: (userId: string, actions: string[], expectedAuthorityEpoch: number, idempotencyKey: string) =>
auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/system-grants`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ actions, expectedAuthorityEpoch, idempotencyKey }) }).then(r => json<{ receiptId: string; authorityEpoch: number }>(r)),
explainSystemAction: (action: string) => auth.fetch(`${BASE}/api/authz/explain?action=${encodeURIComponent(action)}`).then(r => json<{ allowed: boolean; actionId: string; source?: string; reason?: string; authorityEpoch: number }>(r)),
listProjects: () => auth.fetch(`${BASE}/api/projects`).then(r => json<Project[]>(r)),
createProject: (name: string) => auth.fetch(`${BASE}/api/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(r)),
renameProject: (id: string, name: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(r)),
@@ -79,19 +128,19 @@ export const api = {
projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json<SurfaceMeta[]>(r)),
/** 单页权威快照(含 identity + spec),供列表水合与预览渲染。 */
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<SurfaceSnapshot>(r)),
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; sessionScope?: string; passwordChangeMode?: PasswordChangeMode; isAdmin?: boolean; mustChangePassword?: boolean; passwordSet?: boolean }>(r)),
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; sessionScope?: string; passwordChangeMode?: PasswordChangeMode; isAdmin?: boolean; mustChangePassword?: boolean; passwordSet?: boolean; systemActions?: string[] }>(r)),
// ---- 认证与会话(管理员发放制,R2)----
login: (owner: string, password: string) => auth.fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, password }) }).then(async r => {
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string }
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/login-key/redeem`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }) }).then(async r => {
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 }
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string; passwordChangeMode?: PasswordChangeMode }
auth.beginSession()
return me
}),
@@ -108,6 +157,16 @@ export const api = {
// 管理员建号:可提供初始密码;省略密码时由服务端生成并仅返回一次。
createAdminUser: (owner: string, name: string, password?: string) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile: { name }, ...(password ? { password } : {}) }) }).then(r => json<{ userId: string; owner: string; passwordSet: boolean; mustChangePassword: boolean; initializationState: string; initialPassword?: string }>(r)),
adminResetUserPassword: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }).then(r => json<{ owner: string; temporaryPassword: string; mustChangePassword: boolean }>(r)),
searchAdminUsers: (request: { query: string; mode: 'exact' | 'fuzzy'; status: 'all' | 'enabled' | 'disabled'; fields: string[]; cursor?: string; limit?: number }) => {
const params = new URLSearchParams({ query: request.query, mode: request.mode, status: request.status,
fields: request.fields.join(','), limit: String(request.limit ?? 20) })
if (request.cursor) params.set('cursor', request.cursor)
return auth.fetch(`${BASE}/api/admin/users/search?${params}`).then(r => json<AccountSearchPage>(r))
},
createManagedUser: (owner: string, profile: Partial<AccountProfile>) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile }) }).then(r => json<ManagedAccountRecord>(r)),
adminGetUser: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}`).then(r => json<ManagedAccountRecord>(r)),
adminPatchProfile: (userId: string, expectedProfileVersion: number, patch: Partial<AccountProfile>) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/profile`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedProfileVersion, patch }) }).then(r => json<ManagedAccountRecord>(r)),
adminSetUserEnabled: (userId: string, expectedStateVersion: number, enabled: boolean) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/state`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedStateVersion, enabled }) }).then(r => json<ManagedAccountRecord>(r)),
// ---- 管理员:一次性登录 Key(HTTP 签发端点已被恢复提案契约替换为 410,客户端方法随之移除;
// list/revoke 仍有效,供恢复提案视图复用;兑换入口不变)----
adminListLoginKeys: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`).then(r => json<{ keys: { id: string; owner: string; purpose: string; status: string; expiresAt: string; createdAt: string; createdBy?: string }[] }>(r)).then(d => d.keys ?? []),
+245
View File
@@ -0,0 +1,245 @@
import { useEffect, useRef, useState } from 'react'
import {
api, type AccountProfile, type ManagedAccountRecord, type PermissionDescriptor, type RoleRecord, type UserRoleView,
} from '../api'
import { useStore } from '../store'
import { Collapsible, Icon } from '@ui'
import { hasSystemAction } from './RolePermissionsPanel'
const emptyProfile = (): AccountProfile => ({ name: '', phone: '', email: '', staffNo: '', department: '', notes: '' })
const fields: { key: keyof AccountProfile; label: string }[] = [
{ key: 'name', label: '姓名' }, { key: 'phone', label: '电话' }, { key: 'email', label: '邮箱' },
{ key: 'staffNo', label: '工号' }, { key: 'department', label: '部门' }, { key: 'notes', label: '备注' },
]
export const credentialLabel = (account: Pick<ManagedAccountRecord, 'initializationState' | 'passwordSet'>) =>
account.initializationState === 'pending' ? '待可信 owner 初始化' : account.passwordSet ? '已设置密码' : '未设置密码'
function ProfileInputs({ profile, onChange, prefix }: { profile: AccountProfile; onChange: (value: AccountProfile) => void; prefix: string }) {
return <div className="ad-create">{fields.map(field => <div className="ad-row" key={field.key}>
<label className="lg-label" htmlFor={`${prefix}-${field.key}`}>{field.label}(可空)</label>
<input id={`${prefix}-${field.key}`} className="lg-input" value={profile[field.key]}
onChange={event => onChange({ ...profile, [field.key]: event.target.value })} />
</div>)}</div>
}
export function AccountUsersPanel() {
const me = useStore(state => state.me)
const allowed = (action: string) => !!me?.isAdmin || hasSystemAction(me?.systemActions, action)
const canViewRoles = allowed('system.roles.view')
const canAssignRoles = canViewRoles && allowed('system.roles.assign')
const canViewGrants = allowed('system.permissions.view')
const canManageGrants = canViewGrants && allowed('system.permissions.manage')
const [accounts, setAccounts] = useState<ManagedAccountRecord[] | null>(null)
const [selected, setSelected] = useState<ManagedAccountRecord | null>(null)
const [roleView, setRoleView] = useState<UserRoleView | null>(null)
const [roles, setRoles] = useState<RoleRecord[]>([])
const [roleQuery, setRoleQuery] = useState('')
const [roleCursor, setRoleCursor] = useState('')
const [roleTrail, setRoleTrail] = useState<string[]>([])
const [nextRoleCursor, setNextRoleCursor] = useState('')
const [roleLoading, setRoleLoading] = useState(false)
const [catalog, setCatalog] = useState<PermissionDescriptor[]>([])
const [grants, setGrants] = useState<string[]>([])
const [authorityEpoch, setAuthorityEpoch] = useState(0)
const [draft, setDraft] = useState<AccountProfile>(emptyProfile)
const [newOwner, setNewOwner] = useState('')
const [newProfile, setNewProfile] = useState<AccountProfile>(emptyProfile)
const [query, setQuery] = useState('')
const [mode, setMode] = useState<'exact' | 'fuzzy'>('fuzzy')
const [field, setField] = useState('default')
const [status, setStatus] = useState<'all' | 'enabled' | 'disabled'>('all')
const [cursor, setCursor] = useState('')
const [trail, setTrail] = useState<string[]>([])
const [nextCursor, setNextCursor] = useState('')
const [selectedRoleId, setSelectedRoleId] = useState('')
const [busy, setBusy] = useState(false)
const [notice, setNotice] = useState('')
const [err, setErr] = useState('')
const sequence = useRef(0)
const roleSequence = useRef(0)
const search = async (pageCursor = cursor, afterWrite = false) => {
const request = ++sequence.current
try {
const page = await api.searchAdminUsers({ query: query.trim(), mode, status,
fields: field === 'default' ? ['owner', 'name', 'userId'] : [field], cursor: pageCursor, limit: 20 })
if (request !== sequence.current) return
setAccounts(page.accounts ?? []); setNextCursor(page.nextCursor ?? '')
} catch (error) {
if (request === sequence.current) setErr(afterWrite ? `操作已完成,但用户列表暂未刷新:${String(error)}` : String(error))
}
}
const resetPaging = () => { setCursor(''); setTrail([]) }
useEffect(() => {
const timer = window.setTimeout(() => { void search() }, 180)
return () => { window.clearTimeout(timer); sequence.current++ }
}, [query, mode, field, status, cursor])
const open = async (account: ManagedAccountRecord) => {
const request = ++sequence.current
roleSequence.current++
setRoleQuery(''); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(false)
setSelected(null); setRoleView(null); setErr(''); setNotice('')
const detailResult = await Promise.allSettled([api.adminGetUser(account.userId)])
if (request !== sequence.current) return
if (detailResult[0].status === 'rejected') { setErr(String(detailResult[0].reason)); return }
const detail = detailResult[0].value
setSelected(detail); setDraft(detail.profile)
const [roleResult, catalogResult, grantsResult] = await Promise.allSettled([
canViewRoles ? api.getUserRole(account.userId) : Promise.resolve(null),
canViewGrants ? api.permissionCatalog() : Promise.resolve(null),
canViewGrants ? api.getSystemGrants(account.userId) : Promise.resolve(null),
])
if (request !== sequence.current) return
if (roleResult.status === 'fulfilled' && roleResult.value) {
const currentRole = roleResult.value as UserRoleView
setRoleView(currentRole); setSelectedRoleId(currentRole.role?.roleId ?? '')
} else { setRoleView(null); setSelectedRoleId('') }
if (catalogResult.status === 'fulfilled' && catalogResult.value) setCatalog(catalogResult.value)
else setCatalog([])
if (grantsResult.status === 'fulfilled' && grantsResult.value) {
setGrants(grantsResult.value.actions ?? []); setAuthorityEpoch(grantsResult.value.authorityEpoch)
} else { setGrants([]); setAuthorityEpoch(0) }
}
useEffect(() => {
if (!selected || !canViewRoles) { setRoles([]); setRoleLoading(false); return }
const request = ++roleSequence.current
setRoleLoading(true); setNextRoleCursor('')
const timer = window.setTimeout(() => {
void api.listRoles({ query: roleQuery.trim(), mode: 'fuzzy', status: 'enabled', cursor: roleCursor, limit: 50 })
.then(page => {
if (request !== roleSequence.current) return
setRoles(page.roles ?? []); setNextRoleCursor(page.nextCursor ?? ''); setRoleLoading(false)
})
.catch(error => { if (request === roleSequence.current) { setErr(String(error)); setRoleLoading(false) } })
}, 180)
return () => { window.clearTimeout(timer) }
}, [selected?.userId, roleQuery, roleCursor, canViewRoles])
// 写入成功会推进目录版本,旧页游标随之失效:从第一页重读,刷新失败不改写成功结果。
const refreshFirst = async () => {
resetPaging(); setNextCursor('')
await search('', true)
}
const create = async () => {
if (!newOwner.trim()) return
setBusy(true); setErr(''); setNotice('')
try {
const account = await api.createManagedUser(newOwner.trim(), newProfile)
setNewOwner(''); setNewProfile(emptyProfile())
await open(account)
setNotice(`已创建 ${account.owner};账号资料可空,角色另行分配`)
await refreshFirst()
} catch (error) { setErr(String(error).includes('409') ? '登录名已存在' : String(error)) }
finally { setBusy(false) }
}
const saveProfile = async () => {
if (!selected) return
const patch: Partial<AccountProfile> = {}
for (const item of fields) if (draft[item.key] !== selected.profile[item.key]) patch[item.key] = draft[item.key]
if (!Object.keys(patch).length) { setNotice('资料没有变化'); return }
setBusy(true); setErr(''); setNotice('')
try {
const updated = await api.adminPatchProfile(selected.userId, selected.profileVersion, patch)
setSelected(updated); setDraft(updated.profile); await refreshFirst(); setNotice('用户资料已保存')
} catch (error) { setErr(String(error).includes('409') ? '资料版本冲突,请重新打开用户' : String(error)) }
finally { setBusy(false) }
}
const assignRole = async () => {
if (!selected || !selectedRoleId) return
setBusy(true); setErr(''); setNotice('')
try {
await api.assignUserRole(selected.userId, selectedRoleId, selected.stateVersion, crypto.randomUUID())
await open(selected); await refreshFirst(); setNotice('用户角色已替换')
} catch (error) { setErr(String(error).includes('409') ? '用户状态已变化,请重新打开用户' : String(error)) }
finally { setBusy(false) }
}
const saveGrants = async () => {
if (!selected) return
setBusy(true); setErr(''); setNotice('')
try {
const result = await api.replaceSystemGrants(selected.userId, grants, authorityEpoch, crypto.randomUUID())
setAuthorityEpoch(result.authorityEpoch); setNotice('系统权限已保存')
} catch (error) { setErr(String(error).includes('final permission manager') ? '不能撤销最后一个有效权限管理者' : String(error)) }
finally { setBusy(false) }
}
const toggleState = async () => {
if (!selected) return
setBusy(true); setErr(''); setNotice('')
try {
const updated = await api.adminSetUserEnabled(selected.userId, selected.stateVersion, !selected.enabled)
setSelected(updated); setNotice(updated.enabled ? '账号已启用;旧会话和 Key 不会恢复' : '账号已停用,旧会话、Key 与进行中的恢复提案已撤销')
await refreshFirst()
} catch (error) { setErr(String(error).includes('409') ? '状态版本或最后管理员保护发生冲突,请重新打开用户' : String(error)) }
finally { setBusy(false) }
}
if (!allowed('system.users.view')) return <p className="ad-hint">当前账号没有查看用户的系统权限</p>
return <div>
{allowed('system.users.create') && <div className="ad-create">
<div className="ad-row"><input className="lg-input" aria-label="新用户登录名" placeholder="登录名" value={newOwner} onChange={event => setNewOwner(event.target.value)} />
<button className="lg-submit ad-create-btn" disabled={busy || !newOwner.trim()} onClick={create}>创建用户</button></div>
<Collapsible id="new-user-profile" trigger="可选资料:姓名、电话、邮箱、工号、部门、备注">
<ProfileInputs profile={newProfile} onChange={setNewProfile} prefix="new-user" />
</Collapsible>
</div>}
{notice && <div className="ad-notice" role="status"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
{selected ? <div className="rp-editor">
<div className="ad-row"><button className="ad-mini" onClick={() => { sequence.current++; setSelected(null) }}>返回列表</button>
<b>{selected.owner}</b><span className="ad-dim ad-mono">{selected.userId}</span></div>
<p className="ad-hint">{selected.enabled ? '有效账号' : '停用账号'} · {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="编辑可选资料">
<ProfileInputs profile={draft} onChange={setDraft} prefix="user-profile" />
<button className="lg-submit ad-create-btn" disabled={busy} onClick={saveProfile}>保存资料</button>
</Collapsible>}
<div className="ad-row">
{canAssignRoles && <><select className="lg-input" aria-label="用户生效角色" value={selectedRoleId} onChange={event => setSelectedRoleId(event.target.value)}>
<option value="">请选择角色</option>{roles.map(role => <option key={role.roleId} value={role.roleId}>{role.name}</option>)}
</select><button className="ad-mini" disabled={busy || !selectedRoleId || selectedRoleId === roleView?.role?.roleId} onClick={assignRole}>替换角色</button></>}
{allowed('system.users.disable') && <button className="ad-mini" disabled={busy} onClick={toggleState}>{selected.enabled ? '停用账号' : '启用账号'}</button>}
</div>
{canAssignRoles && <div className="ad-row">
<input className="lg-input" aria-label="搜索可分配角色" placeholder="搜索有效角色" value={roleQuery}
onChange={event => { setRoleQuery(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }} />
<button className="ad-mini" disabled={roleLoading || !roleTrail.length} onClick={() => { setRoleCursor(roleTrail[roleTrail.length - 1]); setRoleTrail(roleTrail.slice(0, -1)) }}>上一页角色</button>
<button className="ad-mini" disabled={roleLoading || !nextRoleCursor} onClick={() => { setRoleTrail([...roleTrail, roleCursor]); setRoleCursor(nextRoleCursor) }}>下一页角色</button>
</div>}
{canViewGrants && <Collapsible id={`system-grants-${selected.userId}`} trigger="明确系统权限">
<div className="rp-grants">{catalog.filter(item => item.scope === 'system').map(item => <label className="ad-check" key={item.actionId}>
<input type="checkbox" checked={grants.includes(item.actionId)} disabled={!canManageGrants || busy} onChange={event => setGrants(current => event.target.checked
? [...new Set([...current, item.actionId])] : current.filter(value => value !== item.actionId))} />{item.label}<span className="ad-dim ad-mono">{item.actionId}</span>
</label>)}</div>
{canManageGrants && <button className="lg-submit ad-create-btn" disabled={busy} onClick={saveGrants}>保存系统权限</button>}
</Collapsible>}
{!canViewGrants && allowed('system.permissions.manage') && <p className="ad-hint">需要同时具备系统权限查看权,才能安全编辑现有授权</p>}
</div> : <>
<div className="ad-row rp-filters">
<input className="lg-input" aria-label="搜索用户" placeholder="搜索用户资料" value={query} onChange={event => { setQuery(event.target.value); resetPaging() }} />
<select className="lg-input" aria-label="用户匹配方式" value={mode} onChange={event => { setMode(event.target.value as typeof mode); resetPaging() }}><option value="fuzzy">模糊匹配</option><option value="exact">精确匹配</option></select>
<select className="lg-input" aria-label="用户搜索字段" value={field} onChange={event => { setField(event.target.value); resetPaging() }}>
<option value="default">账号/姓名/ID</option><option value="owner">登录名</option><option value="name">姓名</option><option value="userId">userId</option>
<option value="phone">电话</option><option value="email">邮箱</option><option value="staffNo">工号</option><option value="department">部门</option><option value="notes">备注</option>
</select>
<select className="lg-input" aria-label="用户状态" value={status} onChange={event => { setStatus(event.target.value as typeof status); resetPaging() }}><option value="all">全部状态</option><option value="enabled">有效</option><option value="disabled">停用</option></select>
</div>
<table className="ad-table"><thead><tr><th>登录名</th><th>姓名</th><th>工号</th><th>系统身份</th><th>状态</th><th>首次凭据</th><th></th></tr></thead>
<tbody>{(accounts ?? []).map(account => <tr key={account.userId}><td className="ad-mono">{account.owner}</td><td>{account.profile.name || '—'}</td>
<td>{account.profile.staffNo || '—'}</td><td>{account.isAdmin ? '系统管理员' : '普通账号'}</td><td>{account.enabled ? '有效' : '停用'}</td>
<td>{credentialLabel(account)}</td><td><button className="ad-mini" onClick={() => open(account)}>查看与管理</button></td></tr>)}
{accounts !== null && accounts.length === 0 && <tr><td colSpan={7} className="ad-dim">没有匹配用户</td></tr>}
{accounts === null && <tr><td colSpan={7} className="ad-dim">加载中…</td></tr>}</tbody></table>
<div className="ad-row"><button className="ad-mini" disabled={!trail.length} onClick={() => { setCursor(trail[trail.length - 1]); setTrail(trail.slice(0, -1)) }}>上一页</button>
<button className="ad-mini" disabled={!nextCursor} onClick={() => { setTrail([...trail, cursor]); setCursor(nextCursor) }}>下一页</button></div>
</>}
</div>
}
+36 -14
View File
@@ -10,7 +10,8 @@ import { EmptyState, Icon, type IconName } from '@ui'
import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel, SystemSettingsPanel } from './adminSections'
import { ExecEnvPanel } from './ExecEnvPanel'
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
import { RdmRolesPanel } from './adminRdmSections'
import { AccountUsersPanel } from './AccountUsersPanel'
import { RolePermissionsPanel, roleDraftLeaveGuard, hasSystemAction } from './RolePermissionsPanel'
import type { AdminZoneSection } from '../api'
interface AdminSectionDef {
@@ -18,6 +19,8 @@ interface AdminSectionDef {
label: string
icon: IconName
desc: string
requiredAction?: string
rootOnly?: boolean
/** 懒渲染:进入分区才挂载(每个面板自带数据加载)。 */
render: () => React.ReactNode
}
@@ -27,16 +30,30 @@ function ProfilesPanelWrapper() {
return <ProfilesSection profiles={profiles} />
}
/** 账号目录(按系统权限开放)+ 登录凭据与恢复提案(根管理员专属,旧 /api/admin/users 列表与密码端点)。 */
function UsersSection() {
const isAdmin = useStore(s => !!s.me?.isAdmin)
return <>
<AccountUsersPanel />
{isAdmin && <>
<div className="az-section-head" style={{ marginTop: 18 }}>
<h3>登录凭据与恢复</h3>
<p>建号并发放初始密码、重置临时密码、发起恢复提案(仅根管理员)</p>
</div>
<UsersPanel />
</>}
</>
}
const SECTIONS: AdminSectionDef[] = [
{ key: 'users', label: '用户管理', icon: 'users', desc: '建号、发放初始密码、重置密码(管理员发放制)', render: () => <UsersPanel /> },
// U6/KTD5:RDM 专业角色管理——命令面齐备(rdmRolesAvailable)才显示;普通基座零暴露。
{ key: 'rdm-roles', label: 'RDM 角色权限', icon: 'shield', desc: '专业角色:动作默认/上限、账号绑定、停用(仅管理员)', render: () => <RdmRolesPanel /> },
{ key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', render: () => <ProfilesPanelWrapper /> },
{ key: 'users', label: '用户管理', icon: 'users', desc: '可选资料、账号状态、唯一生效角色与明确系统权限;根管理员另可发放与恢复登录凭据', requiredAction: 'system.users.view', render: () => <UsersSection /> },
{ key: 'roles', label: '角色与权限', icon: 'shield', desc: '职位角色、默认权限与上限;支持单角色和角色矩阵配置', requiredAction: 'system.roles.view', render: () => <RolePermissionsPanel /> },
{ key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', rootOnly: true, render: () => <ProfilesPanelWrapper /> },
// P2 受管:模型/密钥/Tavily/遥测/运行基础设施的唯一写入面(Backend PR 188 系统设置 API)。
{ key: 'system-settings', label: '模型与服务配置', icon: 'settings', desc: '系统受管项:模型、密钥、Tavily、遥测与运行基础设施(仅管理员)', render: () => <SystemSettingsPanel /> },
{ key: 'plugins', label: '插件管理', icon: 'puzzle', desc: '已装载插件清单与热重载(装卸不影响核心)', render: () => <PluginsSection /> },
{ key: 'python-env', label: 'Python 环境', icon: 'package', desc: '基础层、扩展申请、回收和 wheel 上传(仅管理员)', render: () => <ExecEnvPanel /> },
{ key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', render: () => <PluginWorkspacesPanel /> },
{ key: 'system-settings', label: '模型与服务配置', icon: 'settings', desc: '系统受管项:模型、密钥、Tavily、遥测与运行基础设施(仅管理员)', rootOnly: true, render: () => <SystemSettingsPanel /> },
{ key: 'plugins', label: '插件管理', icon: 'puzzle', desc: '已装载插件清单与热重载(装卸不影响核心)', rootOnly: true, render: () => <PluginsSection /> },
{ key: 'python-env', label: 'Python 环境', icon: 'package', desc: '基础层、扩展申请、回收和 wheel 上传(仅管理员)', rootOnly: true, render: () => <ExecEnvPanel /> },
{ key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', rootOnly: true, render: () => <PluginWorkspacesPanel /> },
]
/** 声明分区 → 内置分区组件(按声明 ID 映射;AdminZone 不硬编码领域逻辑)。 */
@@ -64,14 +81,15 @@ function DeclaredSection({ section }: { section: AdminZoneSection }) {
export function AdminZone() {
const me = useStore(s => s.me)
const declared = useStore(s => s.adminSections)
const rdmRolesAvailable = useStore(s => s.rdmRolesAvailable)
const [sectionKey, setSectionKey] = useState('users')
const isAdmin = !!me?.isAdmin
const systemActions = me?.systemActions ?? []
const canOpenSystem = isAdmin || systemActions.length > 0
// 门控:基座系统分区仅 isAdmin(纵深防御与菜单层一致);声明分区对全部
// 认证主体开放(区内能力由组件命令面判定)。两者皆无时不渲染。
if (!isAdmin && declared.length === 0) return null
if (!canOpenSystem && declared.length === 0) return null
const sections: AdminSectionDef[] = [
...(isAdmin ? SECTIONS.filter(s => s.key !== 'rdm-roles' || rdmRolesAvailable) : []),
...SECTIONS.filter(section => section.rootOnly ? isAdmin : isAdmin || hasSystemAction(systemActions, section.requiredAction ?? '')),
...declared.map(s => ({
key: `decl:${s.id}`, label: s.title, icon: declaredIcon(s.icon),
desc: s.desc || '插件声明的管理分区(区内数据能力由插件命令面判定)',
@@ -84,11 +102,15 @@ export function AdminZone() {
return (
<div className="az" role="dialog" aria-label="系统管理">
<div className="az-head">
<button className="btn ghost" onClick={() => store.setUI({ adminZoneOpen: false })}>
<button className="btn ghost" onClick={() => {
// DR9:带未保存权限草稿离开须显式决定;守卫返回 false 表示用户取消。
if (roleDraftLeaveGuard.current && !roleDraftLeaveGuard.current()) return
store.setUI({ adminZoneOpen: false })
}}>
<Icon name="chevron-down" size={14} /> 返回工作台
</button>
<span className="az-title">系统管理</span>
<span className="az-sub">{isAdmin ? '仅管理员可见' : '按插件声明开放'} · 变更即时生效</span>
<span className="az-sub">{isAdmin ? '根管理员' : '按系统权限开放'} · 角色与权限由服务端判定</span>
<span style={{ flex: 1 }} />
<span className="az-tenant">{me?.owner ?? ''}</span>
</div>
+2 -2
View File
@@ -166,9 +166,9 @@ export function humanLoginError(raw: string): string {
/** 一次性登录 Key 兑换文案:401/409/429 语义不同,逐条映射防漂移。 */
export function humanRedeemError(raw: string): string {
if (raw.includes('409')) return '该 Key 已被使用过,请联系管理员重新签发'
if (raw.includes('409')) return '该能力已被使用过;请由本人核对提案状态,或联系可信 owner 续办初始化'
if (raw.includes('429')) return '尝试次数过多,请稍后再试'
if (raw.includes('401')) return 'Key 无效或已过期,请联系管理员重新签发'
if (raw.includes('401')) return '能力无效或已过期;请核对本人提案状态或联系可信 owner 续办初始化'
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
return raw.replace(/^\d{3}:\s*/, '')
}
+386
View File
@@ -0,0 +1,386 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { api, type PermissionDescriptor, type RoleRecord } from '../api'
import {
createRolePermissionDraft, rebaseRoleDraft, updateRolePermission, validateRolePermissionDraft, visiblePatch,
type RolePermissionDraft, type RolePermissionValue,
} from '../rolePermissions'
import { Collapsible, Icon } from '@ui'
import { useStore } from '../store'
/** DR9 离开管理区守卫:面板有未保存权限草稿时注册;AdminZone「返回工作台」消费。 */
export const roleDraftLeaveGuard: { current: (() => boolean) | null } = { current: null }
export function filterRoles(
roles: RoleRecord[], query: string, mode: 'exact' | 'fuzzy', status: 'all' | 'enabled' | 'disabled',
): RoleRecord[] {
const normalized = query.trim().toLocaleLowerCase()
return roles.filter(role => {
if (status === 'enabled' && !role.enabled) return false
if (status === 'disabled' && role.enabled) return false
if (!normalized) return true
const fields = [role.name, role.description, role.roleId].map(value => value.toLocaleLowerCase())
return mode === 'exact' ? fields.some(value => value === normalized) : fields.some(value => value.includes(normalized))
})
}
export function groupPermissionPage(catalog: PermissionDescriptor[], group: string, page: number, size: number): PermissionDescriptor[] {
const grouped = catalog.filter(item => !group || item.group === group)
return grouped.slice(page * size, page * size + size)
}
export function shouldAcceptRoleResponse(activeSequence: number, responseSequence: number): boolean {
return activeSequence === responseSequence
}
export function hasSystemAction(actions: string[] | undefined, action: string): boolean {
return actions?.includes(action) ?? false
}
function roleValues(role: RoleRecord, catalog: PermissionDescriptor[]): Record<string, RolePermissionValue> {
const existing = new Map(role.permissions.map(item => [item.actionId, item]))
return Object.fromEntries(catalog.filter(item => !item.protected).map(item => {
const value = existing.get(item.actionId)
return [item.actionId, { allowed: value?.allowed ?? false, capAllowed: value?.capAllowed ?? false }]
}))
}
function roleValue(role: RoleRecord, actionId: string): RolePermissionValue {
const value = role.permissions.find(item => item.actionId === actionId)
return { allowed: value?.allowed ?? false, capAllowed: value?.capAllowed ?? false }
}
export function RolePermissionsPanel() {
const me = useStore(state => state.me)
const allowed = (action: string) => !!me?.isAdmin || hasSystemAction(me?.systemActions, action)
const canManageRoles = allowed('system.roles.manage')
const canViewPermissions = allowed('system.permissions.view')
const canManagePermissions = canViewPermissions && allowed('system.permissions.manage')
const [roles, setRoles] = useState<RoleRecord[] | null>(null)
const [catalog, setCatalog] = useState<PermissionDescriptor[]>([])
const [selectedId, setSelectedId] = useState('')
const [drafts, setDrafts] = useState<Record<string, RolePermissionDraft>>({})
const [confirmDisable, setConfirmDisable] = useState(false)
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [query, setQuery] = useState('')
const [mode, setMode] = useState<'exact' | 'fuzzy'>('fuzzy')
const [status, setStatus] = useState<'all' | 'enabled' | 'disabled'>('all')
const [group, setGroup] = useState('')
const [actionFilter, setActionFilter] = useState('')
const [roleCursor, setRoleCursor] = useState('')
const [roleTrail, setRoleTrail] = useState<string[]>([])
const [nextRoleCursor, setNextRoleCursor] = useState('')
const [page, setPage] = useState(0)
const [view, setView] = useState<'single' | 'matrix'>('single')
const [newName, setNewName] = useState('')
const [newDescription, setNewDescription] = useState('')
const [busy, setBusy] = useState(false)
const [roleLoading, setRoleLoading] = useState(false)
const [notice, setNotice] = useState('')
const [err, setErr] = useState('')
const requestSequence = useRef(0)
const selectedIdRef = useRef('')
const savePermsRef = useRef<HTMLButtonElement>(null)
selectedIdRef.current = selectedId
const selected = roles?.find(role => role.roleId === selectedId) ?? null
const draft = selected ? drafts[selected.roleId] ?? null : null
const allEditableIds = useMemo(() => catalog.filter(item => !item.protected).map(item => item.actionId), [catalog])
const dirty = !!draft && visiblePatch(draft, allEditableIds).length > 0
const groups = useMemo(() => [...new Set(catalog.map(item => item.group))], [catalog])
const filtered = roles ?? []
const pageItems = useMemo(() => groupPermissionPage(catalog, group, page, 6), [catalog, group, page])
const editablePageItems = pageItems.filter(item => !item.protected)
const changes = draft ? visiblePatch(draft, allEditableIds) : []
const load = async (pageCursor = roleCursor, refreshCatalog = false, preferredId = selectedId,
filters = { query, mode, status, action: actionFilter }) => {
const sequence = ++requestSequence.current
setRoleLoading(true)
setErr('')
const [roleResult, catalogResult] = await Promise.allSettled([
api.listRoles({ query: filters.query.trim(), mode: filters.mode, status: filters.status,
action: filters.action || undefined, cursor: pageCursor, limit: 50 }),
canViewPermissions && (refreshCatalog || catalog.length === 0) ? api.permissionCatalog() : Promise.resolve(catalog),
])
if (!shouldAcceptRoleResponse(requestSequence.current, sequence)) return
setRoleLoading(false)
const descriptors = catalogResult.status === 'fulfilled' ? catalogResult.value : catalog
if (catalogResult.status === 'fulfilled') setCatalog(descriptors)
if (roleResult.status === 'rejected') {
setErr(String(roleResult.reason))
return
}
const rolePage = roleResult.value
setRoles(rolePage.roles ?? [])
setNextRoleCursor(rolePage.nextCursor ?? '')
const nextSelected = rolePage.roles.find(role => role.roleId === preferredId) ?? rolePage.roles[0] ?? null
if (nextSelected) selectRole(nextSelected, descriptors, sequence)
else setSelectedId('')
// DR10:目录失败是部分失败态,需在角色选中(selectRole 会清空提示)之后仍可见。
if (catalogResult.status === 'rejected') setErr(prev => prev || `权限目录加载失败:${catalogResult.reason}`)
}
const selectRole = (role: RoleRecord, descriptors = catalog, sequence?: number) => {
const nextSequence = sequence ?? ++requestSequence.current
if (!shouldAcceptRoleResponse(requestSequence.current, nextSequence)) return
setSelectedId(role.roleId)
setName(role.name)
setDescription(role.description)
// DR9:草稿按 roleId 隔离保存;翻页、过滤、视图切换回到该角色时保留,
// 切换角色永不转移他人草稿。仅首次选中时以服务端值为基线。
setDrafts(prev => prev[role.roleId] ? prev
: { ...prev, [role.roleId]: createRolePermissionDraft(roleValues(role, descriptors), nextSequence) })
setNotice('')
setErr('')
}
// DR9:离开管理区须显式决定;带未保存草稿时刷新走浏览器原生确认。
useEffect(() => {
if (!dirty || !selected) { roleDraftLeaveGuard.current = null; return }
const message = `角色「${selected.name}」有未保存的权限修改,离开将放弃这些修改。确定离开吗?`
roleDraftLeaveGuard.current = () => window.confirm(message)
return () => { roleDraftLeaveGuard.current = null }
}, [dirty, selected?.roleId, selected?.name])
useEffect(() => {
if (!dirty) return
const handler = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = '' }
window.addEventListener('beforeunload', handler)
return () => window.removeEventListener('beforeunload', handler)
}, [dirty])
useEffect(() => {
setNextRoleCursor(''); setRoleLoading(true)
const timer = window.setTimeout(() => { void load(roleCursor) }, 180)
return () => { window.clearTimeout(timer); requestSequence.current++ }
}, [query, mode, status, actionFilter, roleCursor, canViewPermissions])
const create = async () => {
if (!newName.trim()) return
requestSequence.current++; setRoleLoading(false)
setBusy(true); setErr(''); setNotice('')
try {
const role = await api.createRole(newName.trim(), newDescription.trim())
setNewName(''); setNewDescription('')
setQuery(''); setStatus('all'); setActionFilter(''); setRoleCursor(''); setRoleTrail([])
setRoles(current => [role, ...(current ?? []).filter(item => item.roleId !== role.roleId)])
selectRole(role, catalog, ++requestSequence.current)
setNotice(`已创建角色 ${role.name}`)
await load('', true, role.roleId, { query: '', mode, status: 'all', action: '' })
setNotice(`已创建角色 ${role.name}`)
} catch (error) { setErr(String(error)) } finally { setBusy(false) }
}
const saveMetadata = async () => {
if (!selected) return
const roleId = selected.roleId
const operationSequence = ++requestSequence.current
setRoleLoading(false)
setBusy(true); setErr(''); setNotice('')
try {
await api.patchRole(selected.roleId, selected.roleVersion, { name: name.trim(), description: description.trim() })
await load(); setNotice('角色资料已保存')
} catch (error) {
if (String(error).includes('409')) {
try {
const fresh = await api.getRole(roleId)
if (requestSequence.current !== operationSequence || selectedIdRef.current !== roleId) return
setRoles(current => current?.some(role => role.roleId === fresh.roleId)
? current.map(role => role.roleId === fresh.roleId ? fresh : role) : [fresh, ...(current ?? [])])
setErr('角色资料版本冲突;已读取最新版本并保留本地名称与描述,请核对后重试')
} catch (refreshError) { setErr(String(refreshError)) }
} else setErr(String(error))
}
finally { setBusy(false) }
}
const setEnabled = async () => {
if (!selected) return
const roleId = selected.roleId
const operationSequence = ++requestSequence.current
setRoleLoading(false)
setBusy(true); setErr(''); setNotice('')
try {
await api.patchRole(selected.roleId, selected.roleVersion, { enabled: !selected.enabled })
await load(); setNotice(selected.enabled ? '角色已停用,现有绑定不再授予权限' : '角色已启用')
} catch (error) {
if (String(error).includes('409')) {
try {
const fresh = await api.getRole(roleId)
if (requestSequence.current !== operationSequence || selectedIdRef.current !== roleId) return
setRoles(current => current?.some(role => role.roleId === fresh.roleId)
? current.map(role => role.roleId === fresh.roleId ? fresh : role) : [fresh, ...(current ?? [])])
setErr('角色状态版本冲突;已刷新当前状态,请重新确认')
} catch (refreshError) { setErr(String(refreshError)) }
} else setErr(String(error))
} finally { setBusy(false) }
}
const rebasePermissionConflict = async (roleId: string, currentDraft: RolePermissionDraft, operationSequence: number) => {
const fresh = await api.getRole(roleId)
if (requestSequence.current !== operationSequence || selectedIdRef.current !== roleId) return
setRoles(current => current?.some(role => role.roleId === roleId)
? current.map(role => role.roleId === roleId ? fresh : role) : [fresh, ...(current ?? [])])
// DR9:409 只重放同一角色的草稿;基于最新服务端值回放本地相对基线的变化。
setDrafts(prev => ({ ...prev, [roleId]: rebaseRoleDraft(currentDraft, roleValues(fresh, catalog)) }))
setErr('权限版本冲突;已基于最新版本重放本地草稿,请核对后再次保存')
}
const savePermissions = async () => {
if (!selected || !draft) return
const roleId = selected.roleId
const operationSequence = ++requestSequence.current
setRoleLoading(false)
const invalid = validateRolePermissionDraft(draft)
if (invalid) { setErr(`${invalid.actionId} 的默认权限不能超过角色上限`); return }
const full = Object.entries(draft.values).map(([actionId, value]) => ({ actionId, ...value }))
setBusy(true); setErr(''); setNotice('')
try {
await api.replaceRolePermissions(selected.roleId, selected.roleVersion, full, crypto.randomUUID())
setDrafts(current => {
if (!(roleId in current)) return current
const next = { ...current }; delete next[roleId]; return next
})
await load(roleCursor, true, selected.roleId); setNotice('角色权限已保存')
} catch (error) {
if (String(error).includes('409')) {
try { await rebasePermissionConflict(roleId, draft, operationSequence) } catch (refreshError) { setErr(String(refreshError)) }
} else setErr(String(error))
}
finally {
setBusy(false)
// DR10:保存/冲突/失败后焦点稳定——若焦点因按钮短暂 disabled 掉回 body,则归还。
requestAnimationFrame(() => {
if (savePermsRef.current && !savePermsRef.current.disabled && document.activeElement === document.body) savePermsRef.current.focus()
})
}
}
const editValue = (actionId: string, field: 'allowed' | 'capAllowed', checked: boolean) => {
const current = drafts[selectedId]
if (!current) return
const value0 = current.values[actionId] ?? { allowed: false, capAllowed: false }
const value = field === 'allowed'
? { allowed: checked, capAllowed: checked ? true : value0.capAllowed }
: { allowed: checked ? value0.allowed : false, capAllowed: checked }
setDrafts(prev => prev[selectedId] ? { ...prev, [selectedId]: updateRolePermission(prev[selectedId], actionId, value) } : prev)
}
const editVisible = (field: 'allowed' | 'capAllowed', checked: boolean) => {
const start = drafts[selectedId]
if (!start) return
let next = start
for (const item of editablePageItems) {
const current = next.values[item.actionId] ?? { allowed: false, capAllowed: false }
const value = field === 'allowed'
? { allowed: checked, capAllowed: checked ? true : current.capAllowed }
: { allowed: checked ? current.allowed : false, capAllowed: checked }
next = updateRolePermission(next, item.actionId, value)
}
setDrafts(prev => prev[selectedId] ? { ...prev, [selectedId]: next } : prev)
}
const matrix = () => <div className="rp-matrix-wrap">
<table className="ad-table rp-matrix">
<thead><tr><th>角色</th>{pageItems.map(item => <th key={item.actionId}>{item.label}</th>)}</tr></thead>
<tbody>{filtered.map(role => <tr key={role.roleId} className={role.roleId === selectedId ? 'rp-selected' : ''}>
<td><button className="ad-mini" disabled={busy || roleLoading} onClick={() => selectRole(role)}>{role.name}</button><span className="ad-dim"> · {role.bindingCount} 人</span></td>
{pageItems.map(item => {
const value = role.roleId === selectedId && draft ? draft.values[item.actionId] : roleValue(role, item.actionId)
return <td key={item.actionId}>
{item.protected ? <span className="ad-dim">系统授权</span> : <>
<label className="ad-check"><input type="checkbox" aria-label={`${role.name} ${item.label} 默认允许`} checked={value?.allowed ?? false}
disabled={busy || roleLoading || !canManagePermissions || role.roleId !== selectedId} onChange={event => editValue(item.actionId, 'allowed', event.target.checked)} />默认</label>
<label className="ad-check"><input type="checkbox" aria-label={`${role.name} ${item.label} 角色上限`} checked={value?.capAllowed ?? false}
disabled={busy || roleLoading || !canManagePermissions || role.roleId !== selectedId} onChange={event => editValue(item.actionId, 'capAllowed', event.target.checked)} />上限</label>
</>}
</td>
})}
</tr>)}</tbody>
</table>
</div>
return <div className="rp-panel">
{canManageRoles && <div className="ad-create">
<div className="ad-row">
<input className="lg-input" aria-label="新角色名称" placeholder="角色名称" value={newName} onChange={event => setNewName(event.target.value)} />
<input className="lg-input" aria-label="新角色描述" placeholder="描述(可空)" value={newDescription} onChange={event => setNewDescription(event.target.value)} />
<button className="lg-submit ad-create-btn" disabled={busy || roleLoading || !newName.trim()} onClick={create}>创建角色</button>
</div>
</div>}
<Collapsible id="role-permission-filters" trigger="搜索与过滤">
<div className="ad-row rp-filters">
<input className="lg-input" aria-label="搜索角色" placeholder="角色名称、描述或 ID" value={query} disabled={busy} onChange={event => { setQuery(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }} />
<select className="lg-input" aria-label="角色匹配方式" value={mode} disabled={busy} onChange={event => { setMode(event.target.value as typeof mode); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }}>
<option value="fuzzy">模糊匹配</option><option value="exact">精确匹配</option>
</select>
<select className="lg-input" aria-label="角色状态" value={status} disabled={busy} onChange={event => { setStatus(event.target.value as typeof status); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }}>
<option value="all">全部状态</option><option value="enabled">启用</option><option value="disabled">停用</option>
</select>
<select className="lg-input" aria-label="权限分组" value={group} onChange={event => { setGroup(event.target.value); setPage(0) }}>
<option value="">全部权限</option>{groups.map(value => <option key={value}>{value}</option>)}
</select>
{canViewPermissions && <select className="lg-input" aria-label="按权限过滤角色" value={actionFilter} disabled={busy}
onChange={event => { setActionFilter(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }}>
<option value="">全部角色权限</option>{catalog.filter(item => !item.protected).map(item => <option key={item.actionId} value={item.actionId}>{item.label}</option>)}
</select>}
</div>
</Collapsible>
<div className="ad-row rp-view-toggle">
<button className={`ad-mini ${view === 'single' ? 'on' : ''}`} onClick={() => setView('single')}>单角色配置</button>
{canViewPermissions && <button className={`ad-mini ${view === 'matrix' ? 'on' : ''}`} onClick={() => setView('matrix')}>角色矩阵</button>}
<button className="ad-mini" disabled={page === 0} onClick={() => setPage(value => value - 1)}>上一组</button>
<button className="ad-mini" disabled={(page + 1) * 6 >= catalog.filter(item => !group || item.group === group).length} onClick={() => setPage(value => value + 1)}>下一组</button>
<button className="ad-mini" disabled={busy || roleLoading || !roleTrail.length} onClick={() => { setRoleCursor(roleTrail[roleTrail.length - 1]); setRoleTrail(roleTrail.slice(0, -1)) }}>上一页角色</button>
<button className="ad-mini" disabled={busy || roleLoading || !nextRoleCursor} onClick={() => { setRoleTrail([...roleTrail, roleCursor]); setRoleCursor(nextRoleCursor) }}>下一页角色</button>
{view === 'matrix' && canManagePermissions && <>
<button className="ad-mini" disabled={busy || roleLoading || !editablePageItems.length} onClick={() => editVisible('allowed', true)}>当前页默认全选</button>
<button className="ad-mini" disabled={busy || roleLoading || !editablePageItems.length} onClick={() => editVisible('allowed', false)}>当前页默认清除</button>
<button className="ad-mini" disabled={busy || roleLoading || !editablePageItems.length} onClick={() => editVisible('capAllowed', true)}>当前页上限全开</button>
<button className="ad-mini" disabled={busy || roleLoading || !editablePageItems.length} onClick={() => editVisible('capAllowed', false)}>当前页上限关闭</button>
</>}
</div>
{notice && <div className="ad-notice" role="status"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
{view === 'matrix' ? matrix() : <div className="rp-single">
<aside className="rp-role-list">
{(filtered).map(role => <button key={role.roleId} disabled={busy || roleLoading} className={`az-nav-item ${role.roleId === selectedId ? 'on' : ''}`} onClick={() => selectRole(role)}>
<span>{role.name}</span><span className="ad-dim">{role.bindingCount} 人</span>
</button>)}
</aside>
<section className="rp-editor">
{selected && draft ? <>
<div className="ad-row">
<input className="lg-input" aria-label="角色名称" value={name} disabled={!canManageRoles || busy || roleLoading} onChange={event => setName(event.target.value)} />
<input className="lg-input" aria-label="角色描述" value={description} disabled={!canManageRoles || busy || roleLoading} onChange={event => setDescription(event.target.value)} />
{canManageRoles && <button className="ad-mini" disabled={busy || roleLoading} onClick={saveMetadata}>保存资料</button>}
{canManageRoles && (selected.enabled
? <button className="ad-mini" disabled={busy || roleLoading} onClick={() => setConfirmDisable(true)}>停用角色</button>
: <button className="ad-mini" disabled={busy || roleLoading} onClick={setEnabled}>启用角色</button>)}
</div>
{confirmDisable && selected.enabled && <div className="rp-disable-confirm" role="alertdialog" aria-label={`确认停用角色 ${selected.name}`}>
<span>停用角色「{selected.name}」?该角色现有 {selected.bindingCount} 个用户绑定,停用后这些绑定将不再授予权限。</span>
<button className="ad-mini" disabled={busy || roleLoading} onClick={() => { setConfirmDisable(false); void setEnabled() }}>确认停用</button>
<button className="ad-mini" disabled={busy} onClick={() => setConfirmDisable(false)}>取消</button>
</div>}
{canViewPermissions ? <><table className="ad-table"><thead><tr><th>权限</th><th>默认允许</th><th>角色上限</th><th>说明</th></tr></thead>
<tbody>{pageItems.map(item => {
const value = draft.values[item.actionId] ?? { allowed: false, capAllowed: false }
return <tr key={item.actionId}><td>{item.label}<div className="ad-dim ad-mono">{item.actionId}</div></td>
<td><input type="checkbox" aria-label={`${item.label} 默认允许`} checked={value.allowed} disabled={busy || roleLoading || !canManagePermissions || item.protected}
onChange={event => editValue(item.actionId, 'allowed', event.target.checked)} /></td>
<td><input type="checkbox" aria-label={`${item.label} 角色上限`} checked={value.capAllowed} disabled={busy || roleLoading || !canManagePermissions || item.protected}
onChange={event => editValue(item.actionId, 'capAllowed', event.target.checked)} /></td>
<td className="ad-dim">{item.protected ? '受保护系统授权,不能由普通角色授予' : item.description}</td></tr>
})}</tbody></table>
<Collapsible id={`role-diff-${selected.roleId}`} trigger={`变更预览(${changes.length})`}>
{changes.length === 0 ? <p className="ad-hint">没有权限变化</p> : <ul className="rp-diff">{changes.map(change =>
<li key={change.actionId}><span className="ad-mono">{change.actionId}</span>:默认 {change.allowed ? '允许' : '拒绝'},上限 {change.capAllowed ? '允许' : '拒绝'}</li>)}</ul>}
</Collapsible>
{canManagePermissions && <button ref={savePermsRef} className="lg-submit ad-create-btn" disabled={busy || roleLoading || changes.length === 0 || editablePageItems.length === 0} onClick={savePermissions}>保存权限</button>}
</> : <p className="ad-hint">当前账号没有查看角色权限目录的系统权限</p>}
</> : <p className="ad-hint">请选择角色</p>}
</section>
</div>}
</div>
}
+1 -1
View File
@@ -280,7 +280,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
<MenuItem icon="sun" onClick={() => { store.setUI({ theme: 'light' }); setMenu(null) }}>浅色主题{theme === 'light' ? ' · 当前' : ''}</MenuItem>
<MenuItem onClick={() => { store.setUI({ theme: 'molv' }); setMenu(null) }}>墨绿主题{theme === 'molv' ? ' · 当前' : ''}</MenuItem>
<MenuItem onClick={() => { store.setUI({ theme: 'system' }); setMenu(null) }}>跟随系统{theme === 'system' ? ' · 当前' : ''}</MenuItem>
{(me?.isAdmin || adminSections.length > 0) && <MenuItem icon="shield" onClick={() => { store.setUI({ adminZoneOpen: true }); setMenu(null) }}>系统管理…</MenuItem>}
{(me?.isAdmin || (me?.systemActions?.length ?? 0) > 0 || adminSections.length > 0) && <MenuItem icon="shield" onClick={() => { store.setUI({ adminZoneOpen: true }); setMenu(null) }}>系统管理…</MenuItem>}
{me?.auth && <>
<div className="tsb-menu-div" />
<MenuItem danger icon="logout" onClick={() => { api.logout().catch(() => undefined); auth.logout() }}>退出登录</MenuItem>
+35 -154
View File
@@ -1,155 +1,24 @@
// U6/KTD5:RDM 管理与项目页面组件。系统管理「RDM 角色权限」+ 项目设置「成员 /
// 文档职责」。全部数据经 api.rdmCommand(命令总线),载荷构造与能力
// 门控集中在 src/rdmManagement.ts;分区可见性由 AdminZone/ProjectSettingsModal
// 按 store 的 rdm*Available 门控(命令面不齐不渲染,普通基座零暴露)。样式只复用
// RDM 项目设置组件:成员 / 文档职责。全部数据经 api.rdmCommand(命令总线),载荷构造与能力
// 门控集中在 src/rdmManagement.ts;分区可见性由 ProjectSettingsModal 按 store 的
// rdm*Available 门控(命令面不齐不渲染,普通基座零暴露)。样式只复用
// 既有 ad-*/lg-*/settings-* 类(design-guard 棘轮:不新增裸色值/字号)。
import { useEffect, useState, useRef, useCallback } from 'react'
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
import { api } from '../api'
import type { Project } from '../contracts'
import { projectWorkError } from '../projectmeta'
import { useStore } from '../store'
import { Btn, Collapsible, EmptyState, Icon, Picker } from '@ui'
import { Btn, Collapsible, Icon, Picker } from '@ui'
import {
ROLE_ACTIONS,
roleCreatePayload, roleActionPayload, roleAssignPayload, roleDisablePayload,
memberAddPayload, memberRemovePayload, memberSetRolePayload,
permCheckPayload, describeSource,
rolesListPlaceholder, describeRoleListError,
} from '../rdmManagement'
// ---- 系统管理:RDM 角色权限(仅管理员 + rdmRolesAvailable) ----
interface RoleView {
roleId: string
name: string
disabled: boolean
version: number
actions: Record<string, [boolean, boolean]> | null
accounts: string[] | null
}
export function RdmRolesPanel() {
const me = useStore(s => s.me)
const [roles, setRoles] = useState<RoleView[] | null>(null)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const [newName, setNewName] = useState('')
// 动作编辑器
const [editRole, setEditRole] = useState('')
const [editAction, setEditAction] = useState<string>(ROLE_ACTIONS[0])
const [editDef, setEditDef] = useState(false)
const [editCap, setEditCap] = useState(true)
// 绑定
const [assignOwner, setAssignOwner] = useState('')
const refresh = async () => {
try {
const out = await api.rdmCommand<{ roles: RoleView[] }>('rdm.role.list')
setErr(''); setRoles(out.roles ?? [])
} catch (e) { setRoles(null); setErr(describeRoleListError(String(e))) }
}
useEffect(() => { refresh() }, [])
const run = async (label: string, fn: () => Promise<unknown>) => {
setErr(''); setNotice(''); setBusy(true)
try { await fn(); setNotice(label); await refresh() }
catch (e) { setErr(describeRoleListError(String(e))) } finally { setBusy(false) }
}
if (!me?.isAdmin) return <EmptyState icon="shield">仅平台管理员可管理专业角色</EmptyState>
// 占位行单一出口:加载中仅在无错误时成立(拒绝后永远转圈是误导——见
// rolesListPlaceholder 注释)。
const placeholder = rolesListPlaceholder(roles, err)
const sel = roles?.find(r => r.roleId === editRole)
const editCurrent = sel?.actions?.[editAction]
return (
<div>
<div className="ad-create">
<div className="ad-row">
<input className="lg-input" placeholder="新角色名(如:编制工程师)" value={newName}
onChange={e => setNewName(e.target.value)} />
<button className="lg-submit ad-create-btn" disabled={busy || !newName.trim()}
onClick={() => run(`已创建角色 ${newName.trim()}`, async () => {
await api.rdmCommand('rdm.role.create', roleCreatePayload(newName.trim()))
setNewName('')
})}>创建角色</button>
</div>
<p className="ad-hint">新角色动作未配置=全部拒绝(fail-closed),创建后经下方编辑器逐动作配置默认与上限。</p>
</div>
<table className="ad-table">
<thead><tr><th>角色</th><th>状态</th><th>版本</th><th>动作配置</th><th>绑定账号</th><th></th></tr></thead>
<tbody>
{(roles ?? []).map(r => (
<tr key={r.roleId}>
<td><span className="ad-mono">{r.name}</span><br /><span className="ad-dim">{r.roleId}</span></td>
<td>{r.disabled ? <span className="ad-chip ad-chip-warn">已停用</span> : <span className="ad-chip">生效</span>}</td>
<td className="ad-dim">v{r.version}</td>
<td>{Object.keys(r.actions ?? {}).length} 项</td>
<td className="ad-mono">{(r.accounts ?? []).join('、') || '—'}</td>
<td><button className="ad-mini" disabled={busy}
onClick={() => run(r.disabled ? `已启用 ${r.name}` : `已停用 ${r.name}(绑定账号回落内置默认)`,
() => api.rdmCommand('rdm.role.disable', roleDisablePayload(r.roleId, !r.disabled)))}>
{r.disabled ? '启用' : '停用'}</button></td>
</tr>
))}
{roles !== null && roles.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无专业角色</td></tr>}
{placeholder && <tr><td colSpan={6} className="ad-dim">{placeholder}</td></tr>}
</tbody>
</table>
<h4 className="settings-group-title" style={{ marginTop: 18 }}>动作配置(默认 ∩ 上限)</h4>
<div className="ad-row">
<select className="lg-input" value={editRole} onChange={e => setEditRole(e.target.value)}>
<option value="">选择角色…</option>
{(roles ?? []).map(r => <option key={r.roleId} value={r.roleId}>{r.name}</option>)}
</select>
<select className="lg-input" value={editAction}
onChange={e => {
setEditAction(e.target.value)
const cur = sel?.actions?.[e.target.value]
setEditDef(!!cur?.[0]); setEditCap(cur ? cur[1] : true)
}}>
{ROLE_ACTIONS.map(a => <option key={a} value={a}>{a}</option>)}
</select>
<label className="ad-check"><input type="checkbox" checked={editDef}
onChange={e => setEditDef(e.target.checked)} />默认允许</label>
<label className="ad-check"><input type="checkbox" checked={editCap}
onChange={e => setEditCap(e.target.checked)} />上限允许</label>
<button className="lg-submit ad-create-btn" disabled={busy || !editRole}
onClick={() => run(`已配置 ${editRole} · ${editAction}`,
() => api.rdmCommand('rdm.role.action', roleActionPayload(editRole, editAction, editDef, editCap)))}>保存动作</button>
</div>
{sel && editCurrent && <p className="ad-hint">当前值:默认={String(editCurrent[0])},上限={String(editCurrent[1])}</p>}
<h4 className="settings-group-title" style={{ marginTop: 18 }}>绑定账号(每账号一个生效角色)</h4>
<div className="ad-row">
<input className="lg-input lg-mono" placeholder="账号(owner)" value={assignOwner}
onChange={e => setAssignOwner(e.target.value)} />
<select className="lg-input" value={editRole} onChange={e => setEditRole(e.target.value)}>
<option value="">选择角色…</option>
{(roles ?? []).filter(r => !r.disabled).map(r => <option key={r.roleId} value={r.roleId}>{r.name}</option>)}
</select>
<button className="lg-submit ad-create-btn" disabled={busy || !assignOwner.trim() || !editRole}
onClick={() => run(`已把 ${assignOwner.trim()} 绑定到 ${editRole}`,
() => api.rdmCommand('rdm.role.assign', roleAssignPayload(assignOwner.trim(), editRole)))}>绑定</button>
</div>
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
</div>
)
}
// ---- 项目设置:成员(P7:可搜索人员选择器 + 专业角色/项目身份两列 + 显式身份调整)----
import {
type MemberMatchMode, type PersonPickItem, type RdmAccount, type RdmMemberPage, type RdmProfRole,
MATCH_MODE_LABELS, accountListParams, accountsToPickItems, appendMemberPage, memberListParams,
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'
@@ -178,6 +47,7 @@ export function RdmMembersSection({ project }: { project: Project }) {
const [pickedItems, setPickedItems] = useState<PersonPickItem[]>([])
const [candRoleId, setCandRoleId] = useState('')
const [candMatch, setCandMatch] = useState<MemberMatchMode>('fuzzy')
const [candFields, setCandFields] = useState<SearchFieldsChoice>('both')
const [role, setRole] = useState<'member' | 'owner'>('member')
// 成员表经 ref 参与取数(已加入标记),保持 fetchPage 身份稳定(Picker 依赖)。
const rowsRef = useRef<MemberRow[] | null>(null)
@@ -188,7 +58,7 @@ export function RdmMembersSection({ project }: { project: Project }) {
useEffect(() => {
setPicked([]); setPickedItems([]); knownPicks.current.clear()
setCandRoleId(''); setCandMatch('fuzzy'); setMemberQuery(''); setMemberRoleId('')
setCandRoleId(''); setCandMatch('fuzzy'); setCandFields('both'); setMemberQuery(''); setMemberRoleId('')
setRoleOptions(null)
let live = true
cmd<{ roles?: RdmProfRole[] }>('rdm.role.options', roleOptionsPayload(projectId))
@@ -197,14 +67,19 @@ export function RdmMembersSection({ project }: { project: Project }) {
return () => { live = false }
}, [project])
/** 人员选择器取数适配:rdm.account.list 服务端按 query/roleId/match 先过滤后分页。 */
const fetchPage = useCallback(async (query: string, cursor: string, limit: number) => {
const out = await cmd<{ accounts?: RdmAccount[]; next?: string }>('rdm.account.list',
accountListParams(projectId, { query, cursor, limit, roleId: candRoleId, match: candMatch }))
const items = accountsToPickItems(out.accounts, (rowsRef.current ?? []).map(m => m.owner))
for (const it of items) knownPicks.current.set(it.id, it)
return { items, nextCursor: out.next || undefined }
}, [project, candRoleId, candMatch])
const candOptions = useMemo(() => ({ mode: candMatch, searchFields: SEARCH_FIELDS[candFields].fields, roleId: candRoleId }),
[candMatch, candFields, candRoleId])
/** 人员选择器取数适配:rdm.account.list 服务端按 query/mode/searchFields/roleId 先过滤后分页。 */
const fetchPage = useMemo(() => {
const page = accountCandidatePager(params => cmd<RdmAccountPage>('rdm.account.list', params), projectId,
{ ...candOptions, joinedOwners: () => (rowsRef.current ?? []).map(m => m.owner) })
return async (query: string, cursor: string, limit: number) => {
const out = await page(query, cursor, limit)
for (const it of out.items) knownPicks.current.set(it.id, it)
return out
}
}, [project, candOptions])
/** cursor 空=重载首页(clear 时先清空表格);非空=追加下一页。 */
const loadMembers = useCallback(async (cursor: string, clear: boolean) => {
@@ -252,7 +127,7 @@ export function RdmMembersSection({ project }: { project: Project }) {
const pickedId = picked[0] ?? ''
const pickedItem = pickedItems.find(it => it.id === pickedId)
const pickedValid = !!pickedId && !pickedItem?.disabled && !(rows ?? []).some(m => m.owner === pickedId)
const pickedValid = !!pickedItem && !pickedItem.disabled && !(rows ?? []).some(m => m.owner === pickedItem.owner)
const pickerDisabled = busy || !!writeError
const filtering = memberQuery.trim() !== '' || memberRoleId !== ''
@@ -275,6 +150,11 @@ export function RdmMembersSection({ project }: { project: Project }) {
{(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
@@ -283,21 +163,22 @@ export function RdmMembersSection({ project }: { project: Project }) {
onSelect={onPick}
selectedItems={pickedItems}
fetchPage={fetchPage}
reloadKey={`${projectId}|${candRoleId}|${candMatch}`}
reloadKey={accountSearchKey(projectId, candOptions)}
disabled={pickerDisabled}
placeholder="输入账号或姓名,或浏览候选…"
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={() => run(`已添加 ${pickedItem ? personLabel({ owner: pickedId, name: pickedItem.title }) : pickedId}`, async () => {
await cmd('rdm.project.member.add', memberAddPayload(projectId, pickedId, role))
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>
<p className="ad-hint">候选来自系统账号目录(输入后检索,姓名为空时显示账号);无效账号与已加入项目的账号显示为不可选——调整身份请在成员表中操作。最后一位负责人不可移除/降级。</p>
</div>
<div className="ad-row">
<input className="lg-input" aria-label="搜索成员" placeholder="搜索成员账号或姓名" value={memberQuery}
+54 -11
View File
@@ -1,7 +1,7 @@
// 系统管理分区面板(重设计方案 阶段②):普通用户界面不再暴露的系统能力
// 全部收进这里,由 AdminZone 以分区注册表呈现。数据入口与原弹窗完全一致
//(api.* 不变),只换呈现位置;插件面板/声明工作区经 UIRenderer 内联渲染。
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { api, type RecoveryProposal } from '../api'
import { store, useStore } from '../store'
import type { AgentProfile } from '../contracts'
@@ -9,7 +9,10 @@ import { Btn, EmptyState, Icon, SettingRow, SettingsGroup, UIRenderer, type DocB
import { SettingInput, flatten, unflatten } from './SettingsModal'
import type { SettingDesc } from '../contracts'
import { docBridge } from './Workbench'
import { recoveryProposalErrorMessage, recoveryProposalHint, recoveryStatusLabel } from '../recovery'
import {
recoveryAttemptKey, recoveryProposalErrorMessage, recoveryProposalHint, recoveryProposalNotice, recoveryProposalRejected,
recoveryStatusLabel, type RecoveryAttempt,
} from '../recovery'
// ---- 用户管理:页面建号可设置/生成初始密码,管理员重置生成临时密码;
// 保留恢复提案与 Key 管理作为既有非登录页恢复流程。 ----
@@ -29,11 +32,20 @@ export function UsersPanel() {
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) => {
try { setProposals(await api.listAdminRecoveryProposals(userId)) } catch (e) { setErr(String(e)) }
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() }, [])
@@ -60,32 +72,63 @@ export function UsersPanel() {
}
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 {
await api.createAdminRecoveryProposal(propFor.userId, propFor.stateVersion, crypto.randomUUID())
setNotice(`已为 ${propFor.owner} 建立恢复提案:请该用户在「设置 → 账号」由本人确认;确认后一次性 Key 只在其会话内出现`)
await refreshProposals(propFor.userId)
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) {
if (String(e).includes('409')) {
await resetPassword(propFor)
const message = String(e)
if (message.includes('409')) {
recoveryAttempt.current = null
await resetPassword(target)
return
}
setErr(recoveryProposalErrorMessage(String(e)))
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(''); setBusy(true)
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) }
}
@@ -114,7 +157,7 @@ export function UsersPanel() {
<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={() => { setPropFor(null); setProposals(null) }}>关闭</button>
<button type="button" className="ad-mini" onClick={closeProposals}>关闭</button>
</div>
<p className="ad-hint">前提:该用户当前在任一设备保持完整密码登录(会话证明)。已锁死且无会话的账号无法在线恢复,请走服务器本地处置。</p>
{proposals !== null && proposals.length > 0 && (
+32 -5
View File
@@ -1,14 +1,17 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { api } from '../../api'
import type { Project } from '../../contracts'
import { projectWorkError } from '../../projectmeta'
import { Btn, Collapsible, EmptyState, Icon } from '@ui'
import { dutyMatrixPayload, dutyPreviewPayload, dutyLegacyPayload } from '../../rdmManagement'
import {
SEARCH_FIELDS, accountCandidatePager, accountSearchKey, dutyMatrixPayload, dutyPreviewPayload, dutyLegacyPayload,
type DutyAccountIds, type MemberMatchMode, type PersonPickItem, type RdmAccountPage, type SearchFieldsChoice,
} from '../../rdmManagement'
import {
absorbServerDocs, dirtyDocs, editCell, savePayload,
type CellDuty, type DutyDocDraft, type DutyMember,
} from '../../duties/draft'
import { DutyByDocumentView, DutyByMemberView, DutyChangePreview, DutyMatrixView } from '../../duties/views'
import { DutyByDocumentView, DutyByMemberView, DutyCandidateSearch, DutyChangePreview, DutyMatrixView } from '../../duties/views'
type ViewName = 'member' | 'document' | 'matrix'
@@ -68,6 +71,9 @@ export function RdmDutiesSection({ project }: { project: Project }) {
const [confirm, setConfirm] = useState(false)
const [legacy, setLegacy] = useState<LegacyOut['findings']>([])
const [legacyOpen, setLegacyOpen] = useState(false)
const [candMode, setCandMode] = useState<MemberMatchMode>('fuzzy')
const [candFields, setCandFields] = useState<SearchFieldsChoice>('both')
const [accountIds, setAccountIds] = useState<DutyAccountIds>({})
const seq = useRef(0)
const op = useRef(0)
const docsRef = useRef(docs)
@@ -117,12 +123,25 @@ export function RdmDutiesSection({ project }: { project: Project }) {
return () => { seq.current += 1 }
}, [project, query, stage, typeKey, assignment, docCursor])
useEffect(() => { setAccountIds({}) }, [project])
const candOptions = useMemo(() => ({ docId, mode: candMode, searchFields: SEARCH_FIELDS[candFields].fields }),
[docId, candMode, candFields])
const fetchCandidates = useMemo(() => accountCandidatePager(
params => api.rdmProjectCommand<RdmAccountPage>(project, 'rdm.account.list', params), projectId, candOptions,
), [project, candOptions])
const onCell = (id: string, owner: string, duty: CellDuty) => {
setPreview([])
setConfirm(false)
setDocs(current => editCell(current, id, owner, duty))
}
const onCandidate = (id: string, candidate: PersonPickItem, duty: Exclude<CellDuty, 'none'>) => {
setAccountIds(current => ({ ...current, [candidate.owner]: candidate.id }))
onCell(id, candidate.owner, duty)
}
const visibleDocs = docs.filter(doc => {
const text = `${doc.title} ${doc.code} ${doc.docId}`.toLowerCase()
if (query && !text.includes(query.trim().toLowerCase())) return false
@@ -132,7 +151,7 @@ export function RdmDutiesSection({ project }: { project: Project }) {
})
const save = async () => {
const payload = savePayload(docsRef.current, confirm)
const payload = savePayload(docsRef.current, confirm, accountIds)
if (payload.issues.length > 0) {
setErr(payload.issues.join(';'))
return
@@ -154,7 +173,7 @@ export function RdmDutiesSection({ project }: { project: Project }) {
return
}
sent = true
await api.rdmProjectCommand(project, 'rdm.review.assign.doc.batch', dutyPreviewPayload(projectId, savePayload(docsRef.current, confirm).docs))
await api.rdmProjectCommand(project, 'rdm.review.assign.doc.batch', dutyPreviewPayload(projectId, savePayload(docsRef.current, confirm, accountIds).docs))
if (request !== seq.current) return
setNotice('已保存文档职责')
setPreview([])
@@ -228,6 +247,14 @@ export function RdmDutiesSection({ project }: { project: Project }) {
</div>
{view === 'member' && <DutyByMemberView member={member} members={members} docs={visibleDocs} disabled={disabled} onMember={setMember} onCell={onCell} />}
{view === 'document' && <DutyByDocumentView docId={docId} docs={visibleDocs} members={members} disabled={disabled} onDoc={setDocId} onCell={onCell} />}
{view === 'document' && (() => {
const doc = docs.find(item => item.docId === docId)
return doc ? (
<DutyCandidateSearch doc={doc} mode={candMode} fields={candFields} fetchPage={fetchCandidates}
reloadKey={accountSearchKey(projectId, candOptions)} disabled={disabled}
onMode={setCandMode} onFields={setCandFields} onAssign={(candidate, duty) => onCandidate(doc.docId, candidate, duty)} />
) : null
})()}
{view === 'matrix' && <DutyMatrixView docs={visibleDocs} members={members} disabled={disabled} narrow={narrow} onCell={onCell} />}
<div className="ad-row">
{docCursor && <button className="ad-mini" disabled={busy} onClick={() => setDocCursor('')}>文档首页</button>}
+19 -9
View File
@@ -1,4 +1,6 @@
// 文档职责草稿的唯一存储。三个视图只投影这份数据;单元格值由三权反推。
import { dutyAccountIds, type DutyAccountIds } from '../rdmManagement'
export type CellDuty = 'none' | 'author' | 'reviewer' | 'approver'
interface DutyRoles {
@@ -35,6 +37,7 @@ interface DutySaveItem {
approver: string
expectedDutyVersion: number
confirmInvalidation: boolean
accountIds?: DutyAccountIds
}
export function cellDuty(roles: DutyRoles, owner: string): CellDuty {
@@ -174,7 +177,10 @@ export function dirtyDocs(docs: DutyDocDraft[]): DutyDocDraft[] {
return docs.filter(doc => doc.dirty)
}
export function savePayload(docs: DutyDocDraft[], confirmInvalidation: boolean): { docs: DutySaveItem[]; issues: string[] } {
/** accountIds:候选检索选中的 owner → userId;每份文档只携带其职责涉及的人。 */
export function savePayload(
docs: DutyDocDraft[], confirmInvalidation: boolean, accountIds?: DutyAccountIds,
): { docs: DutySaveItem[]; issues: string[] } {
const dirty = dirtyDocs(docs)
const issues: string[] = []
if (dirty.length === 0) issues.push('没有未保存的职责')
@@ -183,14 +189,18 @@ export function savePayload(docs: DutyDocDraft[], confirmInvalidation: boolean):
for (const issue of saveIssues(doc)) issues.push(`${doc.title}:${issue}`)
}
return {
docs: dirty.slice(0, 20).map(doc => ({
docId: doc.docId,
author: doc.author,
reviewers: doc.reviewers,
approver: doc.approver,
expectedDutyVersion: doc.dutyVersion,
confirmInvalidation: confirmInvalidation && doc.activeRound > 0,
})),
docs: dirty.slice(0, 20).map(doc => {
const ids = dutyAccountIds(doc, accountIds)
return {
docId: doc.docId,
author: doc.author,
reviewers: doc.reviewers,
approver: doc.approver,
expectedDutyVersion: doc.dutyVersion,
confirmInvalidation: confirmInvalidation && doc.activeRound > 0,
...(ids ? { accountIds: ids } : {}),
}
}),
issues,
}
}
+67
View File
@@ -1,5 +1,11 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Picker } from '@ui'
import type { CellDuty, DutyDocDraft, DutyMember } from './draft'
import { cellDuty, completeness, replacementNote, saveIssues } from './draft'
import {
MATCH_MODE_LABELS, SEARCH_FIELDS,
type MemberMatchMode, type PersonPickItem, type SearchFieldsChoice,
} from '../rdmManagement'
const DUTY_OPTIONS: { value: CellDuty; label: string }[] = [
{ value: 'none', label: '无职责' },
@@ -120,6 +126,67 @@ export function DutyByDocumentView({
)
}
type AssignedDuty = Exclude<CellDuty, 'none'>
/** 文档职责候选检索:rdm.account.list 携带 docId,只有当前有效成员可选;选中后按 userId 指派。 */
export function DutyCandidateSearch({
doc, mode, fields, fetchPage, reloadKey, disabled, onMode, onFields, onAssign,
}: {
doc: DutyDocDraft
mode: MemberMatchMode
fields: SearchFieldsChoice
fetchPage: (query: string, cursor: string, limit: number) => Promise<{ items: PersonPickItem[]; nextCursor?: string }>
reloadKey: string
disabled: boolean
onMode: (mode: MemberMatchMode) => void
onFields: (fields: SearchFieldsChoice) => void
onAssign: (candidate: PersonPickItem, duty: AssignedDuty) => void
}) {
const [picked, setPicked] = useState<PersonPickItem | null>(null)
const [duty, setDuty] = useState<AssignedDuty>('reviewer')
const known = useRef(new Map<string, PersonPickItem>())
useEffect(() => { known.current.clear(); setPicked(null) }, [reloadKey])
const fetchKnown = useCallback(async (query: string, cursor: string, limit: number) => {
const page = await fetchPage(query, cursor, limit)
for (const item of page.items) known.current.set(item.id, item)
return page
}, [fetchPage])
return (
<div className="settings-group">
<h4>搜索候选人</h4>
<div className="ad-row">
<select className="lg-input" aria-label="候选匹配方式" value={mode} onChange={event => onMode(event.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={fields} onChange={event => onFields(event.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 ? [picked.id] : []}
selectedItems={picked ? [picked] : []}
onSelect={ids => setPicked(known.current.get(ids[0] ?? '') ?? null)}
fetchPage={fetchKnown}
reloadKey={reloadKey}
disabled={disabled}
placeholder={mode === 'fuzzy' ? '输入至少两个字符搜索账号或姓名' : '输入完整账号或姓名精确搜索'}
emptyText="输入后显示当前有效成员"
/>
<select className="lg-input" aria-label="候选人职责" value={duty} onChange={event => setDuty(event.target.value as AssignedDuty)}>
{DUTY_OPTIONS.filter(option => option.value !== 'none').map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
<button className="ad-mini" disabled={disabled || !picked || picked.disabled}
onClick={() => { if (picked) { onAssign(picked, duty); setPicked(null) } }}>
{picked ? `指派到 ${doc.title}` : '指派'}
</button>
</div>
{picked && <p className="ad-hint">{picked.title} · {picked.owner}{replacementNote(doc, picked.owner, duty) ? `(${replacementNote(doc, picked.owner, duty)})` : ''}</p>}
</div>
)
}
export function DutyMatrixView({
docs, members, disabled, narrow, onCell,
}: {
+104 -22
View File
@@ -102,19 +102,39 @@ export const roleActionPayload = (roleId: string, action: string, def: boolean,
export const roleAssignPayload = (owner: string, roleId: string) => ({ owner, roleId })
export const roleDisablePayload = (roleId: string, disabled: boolean) => ({ roleId, disabled })
export const memberAddPayload = (projectId: string, member: string, role: 'owner' | 'member' = 'member') =>
({ projectId, member, role })
/** userId 为候选检索选中的稳定账号;服务端按 member 精确查找后比对,不一致即拒。 */
export const memberAddPayload = (projectId: string, member: string, role: 'owner' | 'member' = 'member', userId?: string) =>
({ projectId, member, role, ...(userId ? { userId } : {}) })
export const memberRemovePayload = (projectId: string, member: string) => ({ projectId, member })
export const memberSetRolePayload = (projectId: string, member: string, role: 'owner' | 'member') => ({ projectId, member, role })
/** 职责账号身份:owner → 候选检索选中的 userId。 */
export type DutyAccountIds = Record<string, string>
/** 只保留本条职责实际涉及的人;为空不携带。 */
export function dutyAccountIds(
roles: { author: string; reviewers: string[]; approver: string }, known: DutyAccountIds | undefined,
): DutyAccountIds | undefined {
if (!known) return undefined
const out: DutyAccountIds = {}
for (const owner of [roles.author, ...roles.reviewers, roles.approver]) {
if (owner && known[owner]) out[owner] = known[owner]
}
return Object.keys(out).length > 0 ? out : undefined
}
export const assignDocDutiesPayload = (
projectId: string, docId: string, author: string, reviewers: string[], approver: string,
extra?: { expectedDutyVersion?: number; confirmInvalidation?: boolean },
) => ({
projectId, docId, author, reviewers, approver,
...(extra?.expectedDutyVersion !== undefined ? { expectedDutyVersion: extra.expectedDutyVersion } : {}),
...(extra?.confirmInvalidation !== undefined ? { confirmInvalidation: extra.confirmInvalidation } : {}),
})
extra?: { expectedDutyVersion?: number; confirmInvalidation?: boolean; accountIds?: DutyAccountIds },
) => {
const accountIds = dutyAccountIds({ author, reviewers, approver }, extra?.accountIds)
return {
projectId, docId, author, reviewers, approver,
...(extra?.expectedDutyVersion !== undefined ? { expectedDutyVersion: extra.expectedDutyVersion } : {}),
...(extra?.confirmInvalidation !== undefined ? { confirmInvalidation: extra.confirmInvalidation } : {}),
...(accountIds ? { accountIds } : {}),
}
}
export const dutyMatrixPayload = (
projectId: string,
@@ -136,7 +156,10 @@ export const dutyMatrixPayload = (
export const dutyPreviewPayload = (
projectId: string,
docs: { docId: string; author: string; reviewers: string[]; approver: string; expectedDutyVersion: number; confirmInvalidation: boolean }[],
docs: {
docId: string; author: string; reviewers: string[]; approver: string
expectedDutyVersion: number; confirmInvalidation: boolean; accountIds?: DutyAccountIds
}[],
) => ({ projectId, docs })
export const dutyLegacyPayload = (projectId: string) => ({ projectId })
@@ -172,12 +195,16 @@ export const commandRequestBody = (params: Record<string, unknown>, source?: Com
// ---- 项目成员页:候选检索 + 成员表检索(R2 契约:服务端先过滤后分页) ----
export type MemberMatchMode = 'fuzzy' | 'exact'
type AccountSearchField = 'owner' | 'name'
export interface RdmProfRole { roleId: string; name: string }
/** rdm.account.list 单条候选。 */
export interface RdmAccount {
owner: string; name?: string; valid: boolean; joined?: boolean
userId: string; owner: string; name?: string; valid: boolean; joined?: boolean
projectRole?: string; profRole?: RdmProfRole | null
}
export interface RdmAccountPage {
accounts?: RdmAccount[]; next?: string; directoryVersion?: string; count?: number
}
/** rdm.project.member.list 单条成员。 */
export interface RdmMemberRow {
owner: string; name?: string; role: string; addedBy: string; addedAt: string
@@ -186,32 +213,78 @@ export interface RdmMemberRow {
export interface RdmMemberPage {
members: RdmMemberRow[]; next?: string; total?: number; accountDirectoryAvailable?: boolean
}
/** 与 @ui PickItem 结构兼容(纯逻辑层不依赖组件库)。 */
/** 与 @ui PickItem 结构兼容(纯逻辑层不依赖组件库);id 为稳定 userId,owner 为提交用账号。 */
export interface PersonPickItem {
id: string; title: string; subtitle?: string; disabled?: boolean; disabledReason?: string
id: string; owner: string; title: string; subtitle?: string; disabled?: boolean; disabledReason?: string
}
export const MEMBER_PAGE_LIMIT = 50
const ACCOUNT_PAGE_LIMIT = 50
export const JOINED_REASON = '已加入项目'
export const INVALID_REASON = '账号无效'
export const NOT_MEMBER_REASON = '非当前项目成员'
export const MATCH_MODE_LABELS: Record<MemberMatchMode, string> = {
fuzzy: '模糊匹配',
exact: '精确账号或姓名',
}
export type SearchFieldsChoice = 'both' | 'owner' | 'name'
export const SEARCH_FIELDS: Record<SearchFieldsChoice, { label: string; fields: AccountSearchField[] }> = {
both: { label: '账号和姓名', fields: ['owner', 'name'] },
owner: { label: '仅账号', fields: ['owner'] },
name: { label: '仅姓名', fields: ['name'] },
}
export const roleOptionsPayload = (projectId: string) => ({ projectId, purpose: 'members' as const })
/** 空串筛选不携带(缺省=不过滤);match 恒携带,服务端据此选择匹配语义。 */
export function accountListParams(projectId: string, opts: {
query?: string; cursor?: string; limit: number; roleId?: string; match?: MemberMatchMode
interface AccountSearchOptions {
mode?: MemberMatchMode
searchFields?: AccountSearchField[]
roleId?: string
/** 携带即按该文档的职责候选检索(仅当前有效成员)。 */
docId?: string
}
/** 只用 mode(match 是服务端旧别名,两者并存且不一致会被拒);空串筛选不携带。 */
export function accountListParams(projectId: string, opts: AccountSearchOptions & {
query?: string; cursor?: string; limit: number
}) {
const query = opts.query?.trim() ?? ''
return {
projectId,
limit: opts.limit,
match: opts.match ?? 'fuzzy',
limit: Math.min(Math.max(1, opts.limit), ACCOUNT_PAGE_LIMIT),
mode: opts.mode ?? 'fuzzy',
...(opts.searchFields && opts.searchFields.length > 0 ? { searchFields: [...opts.searchFields] } : {}),
...(query ? { query } : {}),
...(opts.cursor ? { cursor: opts.cursor } : {}),
...(opts.roleId ? { roleId: opts.roleId } : {}),
...(opts.docId ? { docId: opts.docId } : {}),
}
}
/** Picker reloadKey:项目/文档/匹配方式/检索字段/角色任一变化都清空查询与游标。 */
export function accountSearchKey(projectId: string, opts: AccountSearchOptions): string {
return JSON.stringify([projectId, opts.docId ?? '', opts.mode ?? 'fuzzy', opts.searchFields ?? [], opts.roleId ?? ''])
}
/**
* Picker 取数适配。空 query 不发请求(服务端空查询不枚举);cursor 只在签发它的
* 同一检索条件(query + 选项)下沿用,条件变化即回到首页。
*/
export function accountCandidatePager(
run: (params: ReturnType<typeof accountListParams>) => Promise<RdmAccountPage>,
projectId: string,
opts: AccountSearchOptions & { joinedOwners?: () => Iterable<string> },
) {
const base = accountSearchKey(projectId, opts)
let issuedFor = ''
return async (query: string, cursor: string, limit: number): Promise<{ items: PersonPickItem[]; nextCursor?: string }> => {
const q = query.trim()
if (!q) { issuedFor = ''; return { items: [] } }
const key = `${base}|${q}`
const out = await run(accountListParams(projectId, { ...opts, query: q, cursor: key === issuedFor ? cursor : '', limit }))
issuedFor = out.next ? key : ''
const items = accountsToPickItems(out.accounts, opts.joinedOwners?.() ?? [], opts.docId ? 'duty' : 'member')
return { items, nextCursor: out.next || undefined }
}
}
@@ -237,16 +310,25 @@ export function personLabel(p: { owner: string; name?: string }): string {
return name && name !== p.owner ? `${name} · ${p.owner}` : p.owner
}
/** 候选映射:无效账号不提供;已加入(服务端 joined 或已在成员表)可见但不可选。 */
export function accountsToPickItems(accounts: RdmAccount[] | undefined, joinedOwners: Iterable<string> = []): PersonPickItem[] {
/**
* 候选映射(id=userId;无 userId 的行无法稳定提交,不提供)。无效账号可见但不可选;
* member:已加入(服务端 joined 或已在成员表)不可选;duty:只有当前成员可选。
*/
export function accountsToPickItems(
accounts: RdmAccount[] | undefined, joinedOwners: Iterable<string> = [], purpose: 'member' | 'duty' = 'member',
): PersonPickItem[] {
const joined = new Set(joinedOwners)
return (accounts ?? []).filter(a => a.valid).map(a => {
return (accounts ?? []).filter(a => a.userId && a.owner).map(a => {
const isJoined = a.joined === true || joined.has(a.owner)
const reason = !a.valid ? INVALID_REASON
: purpose === 'member' && isJoined ? JOINED_REASON
: purpose === 'duty' && !isJoined ? NOT_MEMBER_REASON : ''
return {
id: a.owner,
id: a.userId,
owner: a.owner,
title: a.name?.trim() || a.owner,
subtitle: a.owner,
...(isJoined ? { disabled: true, disabledReason: JOINED_REASON } : {}),
...(reason ? { disabled: true, disabledReason: reason } : {}),
}
})
}
+39 -1
View File
@@ -1,5 +1,7 @@
import { describe, expect, it } from 'vitest'
import { recoveryProposalErrorMessage, recoveryProposalHint, recoveryStatusLabel } from './recovery'
import {
recoveryAttemptKey, recoveryProposalErrorMessage, recoveryProposalHint, recoveryProposalNotice, recoveryProposalRejected, recoveryStatusLabel,
} from './recovery'
import { humanChangePasswordError, humanRedeemError, requiresCurrentPassword } from './components/LoginPage'
// 账户链路文案与模式判定(2026-10-14 current_required 加固后前后端契约对齐的
@@ -43,6 +45,42 @@ describe('recoveryProposalHint', () => {
})
})
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('会话证明')
+21
View File
@@ -18,6 +18,27 @@ export function recoveryProposalHint(status: RecoveryProposal['status']): string
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 '无法发起:该账号没有可用的合格会话证明(需该用户当前在任一设备保持完整密码登录)。历史来源或已锁死账号请走服务器本地处置'
+70
View File
@@ -0,0 +1,70 @@
export type RolePermissionValue = { allowed: boolean; capAllowed: boolean }
type RolePermissionChange = { actionId: string; allowed: boolean; capAllowed: boolean }
export interface RolePermissionDraft {
baseline: Record<string, RolePermissionValue>
values: Record<string, RolePermissionValue>
generation: number
}
function copy(values: Record<string, RolePermissionValue>): Record<string, RolePermissionValue> {
return Object.fromEntries(Object.entries(values).map(([actionId, value]) => [actionId, { ...value }]))
}
export function createRolePermissionDraft(values: Record<string, RolePermissionValue>, generation = 0): RolePermissionDraft {
return { baseline: copy(values), values: copy(values), generation }
}
export function updateRolePermission(
draft: RolePermissionDraft,
actionId: string,
value: RolePermissionValue,
): RolePermissionDraft {
return { ...draft, values: { ...draft.values, [actionId]: { ...value } }, generation: draft.generation + 1 }
}
export function mergeRoleDraft(
current: RolePermissionDraft,
serverValues: Record<string, RolePermissionValue>,
responseGeneration: number,
): RolePermissionDraft {
if (responseGeneration < current.generation) return current
return createRolePermissionDraft(serverValues, responseGeneration)
}
// Rebase local edits on a newer server baseline after an optimistic conflict.
// Only fields changed from the old baseline are replayed.
export function rebaseRoleDraft(
current: RolePermissionDraft,
serverValues: Record<string, RolePermissionValue>,
): RolePermissionDraft {
const values = copy(serverValues)
for (const [actionId, local] of Object.entries(current.values)) {
const old = current.baseline[actionId]
if (!old || old.allowed !== local.allowed || old.capAllowed !== local.capAllowed) values[actionId] = { ...local }
}
return { baseline: copy(serverValues), values, generation: current.generation + 1 }
}
export function visiblePatch(draft: RolePermissionDraft, visibleActionIds: string[]): RolePermissionChange[] {
const seen = new Set<string>()
const changes: RolePermissionChange[] = []
for (const actionId of visibleActionIds) {
if (seen.has(actionId)) continue
seen.add(actionId)
const value = draft.values[actionId]
if (!value) continue
const baseline = draft.baseline[actionId]
if (baseline && baseline.allowed === value.allowed && baseline.capAllowed === value.capAllowed) continue
changes.push({ actionId, ...value })
}
return changes
}
export function validateRolePermissionDraft(draft: RolePermissionDraft): { actionId: string; reason: string } | null {
for (const actionId of Object.keys(draft.values).sort()) {
const value = draft.values[actionId]
if (value.allowed && !value.capAllowed) return { actionId, reason: 'default-exceeds-cap' }
}
return null
}
+1 -1
View File
@@ -90,7 +90,7 @@ export interface UIState {
projectSettingsId: string | null
}
export interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; mustChangePassword?: boolean }
export interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; mustChangePassword?: boolean; systemActions?: string[] }
interface AppState {
contextObs: Record<string, ContextObs>
+22
View File
@@ -517,6 +517,28 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.ad-table th { text-align: left; font-weight: 600; color: var(--text-dim); font-size: var(--fs-ui-sm); padding: 8px; border-bottom: 1px solid var(--border); }
.ad-table td { padding: 9px 8px; border-bottom: 1px solid var(--border-soft); }
.ad-table tr:last-child td { border-bottom: none; }
.rp-panel { display: flex; flex-direction: column; gap: 12px; }
.rp-filters .lg-input { min-width: 140px; }
.rp-view-toggle { justify-content: flex-end; }
.rp-view-toggle .ad-mini.on { background: var(--bg-active); color: var(--text); }
.rp-single { display: grid; grid-template-columns: minmax(180px, 240px) minmax(0, 1fr); gap: 16px; align-items: start; }
.rp-disable-confirm { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-top: 8px; font-size: var(--fs-ui-sm); color: var(--warn-text); background: var(--warn-soft); border: 1px solid color-mix(in srgb, var(--warn) 35%, transparent); border-radius: var(--radius); padding: 8px 11px; }
.rp-role-list { display: flex; flex-direction: column; gap: 2px; padding: 8px; border: 1px solid var(--border-soft); border-radius: var(--radius-lg); background: var(--bg-panel); }
.rp-role-list .az-nav-item { justify-content: space-between; width: 100%; }
.rp-editor { min-width: 0; display: flex; flex-direction: column; gap: 12px; }
.rp-matrix-wrap { overflow-x: auto; border: 1px solid var(--border-soft); border-radius: var(--radius-lg); }
.rp-matrix { min-width: 760px; }
.rp-matrix th:first-child, .rp-matrix td:first-child { position: sticky; left: 0; background: var(--bg-panel); z-index: 1; }
.rp-matrix tr.rp-selected td { background: var(--bg-active); }
.rp-matrix tr.rp-selected td:first-child { background: var(--bg-active); }
.rp-diff { margin: 0; padding-left: 20px; color: var(--text-dim); font-size: var(--fs-ui-sm); }
.rp-grants { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 8px; margin-bottom: 12px; }
.rp-grants .ad-check { align-items: flex-start; padding: 8px; border: 1px solid var(--border-soft); border-radius: var(--radius); background: var(--bg-raised); }
.rp-grants .ad-mono { margin-left: auto; }
@media (max-width: 820px) {
.rp-single { grid-template-columns: 1fr; }
.rp-role-list { max-height: 220px; overflow-y: auto; }
}
.ad-mono { font-family: var(--mono); font-size: var(--fs-ui-sm); }
.ad-dim { color: var(--text-dim); }
.ad-chip { font-size: var(--fs-ui-xs); border-radius: var(--radius-xs); padding: 2px 7px; font-weight: 700; letter-spacing: .5px; background: var(--bg-hover); color: var(--text-dim); }
+10
View File
@@ -0,0 +1,10 @@
import { describe, expect, it } from 'vitest'
import { credentialLabel } from '../src/components/AccountUsersPanel'
describe('account directory credential state', () => {
it('shows pending trusted-owner initialization before password state', () => {
expect(credentialLabel({ initializationState: 'pending', passwordSet: false })).toBe('待可信 owner 初始化')
expect(credentialLabel({ initializationState: 'initialized', passwordSet: true })).toBe('已设置密码')
expect(credentialLabel({ initializationState: 'legacy', passwordSet: false })).toBe('未设置密码')
})
})
+11
View File
@@ -69,5 +69,16 @@ describe('文档职责草稿', () => {
docId: 'd1', author: 'alice', reviewers: ['bob'], approver: 'carol',
expectedDutyVersion: 3, confirmInvalidation: true,
})
expect(one.docs[0]).not.toHaveProperty('accountIds')
})
it('候选检索选中的 userId 随预览/批量保存以 accountIds{owner:userId} 携带,只含本文档职责人', () => {
const known = { bob: 'u-bob', erin: 'u-erin' }
const out = savePayload([
doc({ dirty: true }),
doc({ docId: 'd2', dirty: true, author: 'dave', reviewers: ['frank'], approver: 'gina' }),
], false, known)
expect(out.docs[0].accountIds).toEqual({ bob: 'u-bob' })
expect(out.docs[1]).not.toHaveProperty('accountIds')
})
})
+3 -3
View File
@@ -31,10 +31,10 @@ describe('humanChangePasswordError(改密门)', () => {
})
})
describe('humanRedeemError(一次性登录 Key 兑换)', () => {
describe('humanRedeemError(可信激活与本人恢复能力兑换)', () => {
it('409 已使用 / 401 无效过期 / 429 退避各有文案', () => {
expect(humanRedeemError('409: login key already used')).toBe('该 Key 已被使用过,请联系管理员重新签发')
expect(humanRedeemError('401: invalid or expired login key')).toBe('Key 无效或已过期,请联系管理员重新签发')
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('未知错误剥状态码前缀透传', () => {
+15
View File
@@ -59,6 +59,21 @@ describe('命令载荷构造(身份只经认证会话——载荷不含任何
expect(memberAddPayload('p1', 'carol', 'owner')).toEqual({ projectId: 'p1', member: 'carol', role: 'owner' })
expect(memberRemovePayload('p1', 'carol')).toEqual({ projectId: 'p1', member: 'carol' })
})
it('成员添加携带候选检索选中的 userId(缺省不携带)', () => {
expect(memberAddPayload('p1', 'carol', 'member', 'u-carol'))
.toEqual({ projectId: 'p1', member: 'carol', role: 'member', userId: 'u-carol' })
expect(memberAddPayload('p1', 'carol', 'owner', '')).not.toHaveProperty('userId')
})
it('文档职责分配/预览携带 accountIds{owner:userId},只含本条职责涉及的人', () => {
const known = { alice: 'u-a', bob: 'u-b', erin: 'u-e' }
expect(assignDocDutiesPayload('p1', 'd1', 'alice', ['bob'], 'dave', { expectedDutyVersion: 2, accountIds: known }))
.toEqual({ projectId: 'p1', docId: 'd1', author: 'alice', reviewers: ['bob'], approver: 'dave',
expectedDutyVersion: 2, accountIds: { alice: 'u-a', bob: 'u-b' } })
expect(assignDocDutiesPayload('p1', 'd1', 'dave', ['carol'], 'frank', { accountIds: known })).not.toHaveProperty('accountIds')
const doc = { docId: 'd1', author: 'alice', reviewers: ['bob'], approver: 'dave', expectedDutyVersion: 2,
confirmInvalidation: false, accountIds: { alice: 'u-a' } }
expect(dutyPreviewPayload('p1', [doc])).toEqual({ projectId: 'p1', docs: [doc] })
})
it('文档职责与整文审签载荷', () => {
expect(assignDocDutiesPayload('p1', 'd1', 'alice', ['bob', 'carol'], 'dave'))
.toEqual({ projectId: 'p1', docId: 'd1', author: 'alice', reviewers: ['bob', 'carol'], approver: 'dave' })
+103 -22
View File
@@ -3,28 +3,104 @@
import { describe, expect, it } from 'vitest'
import type { Project } from '../src/contracts'
import {
JOINED_REASON, MATCH_MODE_LABELS, MEMBER_PAGE_LIMIT, RDM_MEMBER_COMMANDS,
accountListParams, accountsToPickItems, appendMemberPage, memberListParams,
personLabel, rdmMembersReady, rdmProjectCommandError, roleOptionsPayload,
type RdmMemberRow,
INVALID_REASON, JOINED_REASON, MATCH_MODE_LABELS, MEMBER_PAGE_LIMIT, NOT_MEMBER_REASON, RDM_MEMBER_COMMANDS,
SEARCH_FIELDS, accountCandidatePager, accountListParams, accountSearchKey, accountsToPickItems, appendMemberPage,
memberListParams, personLabel, rdmMembersReady, rdmProjectCommandError, roleOptionsPayload,
type RdmAccountPage, type RdmMemberRow,
} from '../src/rdmManagement'
const row = (owner: string, name?: string): RdmMemberRow =>
({ owner, name, role: 'member', addedBy: 'admin', addedAt: '' })
describe('accountListParams', () => {
it('passes roleId, match and cursor to rdm.account.list', () => {
expect(accountListParams('p1', { query: ' zhang ', cursor: 'c2', limit: 20, roleId: 'r-eng', match: 'exact' }))
.toEqual({ projectId: 'p1', query: 'zhang', cursor: 'c2', limit: 20, roleId: 'r-eng', match: 'exact' })
it('passes mode, searchFields, roleId and cursor to rdm.account.list', () => {
expect(accountListParams('p1', {
query: ' zhang ', cursor: 'c2', limit: 20, roleId: 'r-eng', mode: 'exact', searchFields: ['name'],
})).toEqual({ projectId: 'p1', query: 'zhang', cursor: 'c2', limit: 20, roleId: 'r-eng', mode: 'exact', searchFields: ['name'] })
})
it('omits empty filters and defaults to fuzzy matching', () => {
expect(accountListParams('p1', { query: ' ', cursor: '', limit: 20, roleId: '' }))
.toEqual({ projectId: 'p1', limit: 20, match: 'fuzzy' })
it('passes docId for document duty candidates', () => {
expect(accountListParams('p1', { query: 'li', limit: 20, docId: 'd1', searchFields: ['owner', 'name'] }))
.toEqual({ projectId: 'p1', query: 'li', limit: 20, mode: 'fuzzy', searchFields: ['owner', 'name'], docId: 'd1' })
})
it('labels both match modes in Chinese', () => {
it('never sends the legacy match alias next to mode', () => {
const params = accountListParams('p1', { query: 'li', limit: 20, mode: 'exact' })
expect(params).not.toHaveProperty('match')
expect(params.mode).toBe('exact')
})
it('omits empty filters, defaults to fuzzy and caps the page at 50', () => {
expect(accountListParams('p1', { query: ' ', cursor: '', limit: 200, roleId: '', docId: '', searchFields: [] }))
.toEqual({ projectId: 'p1', limit: 50, mode: 'fuzzy' })
})
it('labels both match modes and the search field choices in Chinese', () => {
expect(MATCH_MODE_LABELS).toEqual({ fuzzy: '模糊匹配', exact: '精确账号或姓名' })
expect(SEARCH_FIELDS.both.fields).toEqual(['owner', 'name'])
expect(SEARCH_FIELDS.owner.fields).toEqual(['owner'])
expect(SEARCH_FIELDS.name.fields).toEqual(['name'])
})
})
describe('accountCandidatePager', () => {
const recorder = (pages: RdmAccountPage[]) => {
const calls: Record<string, unknown>[] = []
const run = async (params: Record<string, unknown>) => { calls.push(params); return pages.shift() ?? {} }
return { calls, run }
}
const acct = (userId: string, owner: string) => ({ userId, owner, valid: true, joined: false })
it('does not query the directory for an empty search', async () => {
const { calls, run } = recorder([])
const page = accountCandidatePager(run, 'p1', { mode: 'fuzzy' })
expect(await page(' ', '', 20)).toEqual({ items: [] })
expect(calls).toEqual([])
})
it('pages with the opaque next cursor under the same query and options', async () => {
const { calls, run } = recorder([{ accounts: [acct('u1', 'a1')], next: 'n1' }, { accounts: [acct('u2', 'a2')] }])
const page = accountCandidatePager(run, 'p1', { mode: 'exact', searchFields: ['owner'], docId: 'd1' })
const first = await page('zh', '', 20)
expect(first.nextCursor).toBe('n1')
await page('zh', 'n1', 20)
expect(calls).toEqual([
{ projectId: 'p1', query: 'zh', limit: 20, mode: 'exact', searchFields: ['owner'], docId: 'd1' },
{ projectId: 'p1', query: 'zh', limit: 20, mode: 'exact', searchFields: ['owner'], docId: 'd1', cursor: 'n1' },
])
})
it('resets the cursor when the query changes', async () => {
const { calls, run } = recorder([{ accounts: [], next: 'n1' }, { accounts: [] }])
const page = accountCandidatePager(run, 'p1', { mode: 'fuzzy' })
await page('zh', '', 20)
await page('zhang', 'n1', 20)
expect(calls[1]).not.toHaveProperty('cursor')
})
it('resets the cursor when mode or search fields change (new pager, new reload key)', async () => {
const { calls, run } = recorder([{ accounts: [], next: 'n1' }, { accounts: [] }, { accounts: [] }])
await accountCandidatePager(run, 'p1', { mode: 'fuzzy', searchFields: ['owner', 'name'] })('zh', '', 20)
await accountCandidatePager(run, 'p1', { mode: 'exact', searchFields: ['owner', 'name'] })('zh', 'n1', 20)
await accountCandidatePager(run, 'p1', { mode: 'fuzzy', searchFields: ['name'] })('zh', 'n1', 20)
expect(calls[1]).not.toHaveProperty('cursor')
expect(calls[2]).not.toHaveProperty('cursor')
const key = (mode: 'fuzzy' | 'exact', fields: ('owner' | 'name')[], docId?: string) =>
accountSearchKey('p1', { mode, searchFields: fields, docId })
expect(new Set([key('fuzzy', ['owner', 'name']), key('exact', ['owner', 'name']), key('fuzzy', ['name']),
key('fuzzy', ['owner', 'name'], 'd1')]).size).toBe(4)
})
it('maps duty candidates so only current members are selectable', async () => {
const { run } = recorder([{ accounts: [
{ userId: 'u1', owner: 'm1', name: '成员', valid: true, joined: true },
{ userId: 'u2', owner: 'x1', valid: true, joined: false },
] }])
const { items } = await accountCandidatePager(run, 'p1', { docId: 'd1' })('m', '', 20)
expect(items).toEqual([
{ id: 'u1', owner: 'm1', title: '成员', subtitle: 'm1' },
{ id: 'u2', owner: 'x1', title: 'x1', subtitle: 'x1', disabled: true, disabledReason: NOT_MEMBER_REASON },
])
})
})
@@ -52,30 +128,35 @@ describe('roleOptionsPayload', () => {
})
describe('accountsToPickItems', () => {
it('drops invalid accounts', () => {
it('keys candidates by stable userId and drops rows without one', () => {
const items = accountsToPickItems([
{ owner: 'a', name: '甲', valid: true, joined: false },
{ owner: 'gone', name: '离职', valid: false, joined: false },
{ userId: 'u-a', owner: 'a', name: '甲', valid: true, joined: false },
{ userId: '', owner: 'anon', valid: true, joined: false },
])
expect(items.map(it => it.id)).toEqual(['a'])
expect(items).toEqual([{ id: 'u-a', owner: 'a', title: '甲', subtitle: 'a' }])
})
it('shows invalid accounts as not selectable', () => {
const [it] = accountsToPickItems([{ userId: 'u-g', owner: 'gone', name: '离职', valid: false, joined: false }])
expect(it).toMatchObject({ id: 'u-g', disabled: true, disabledReason: INVALID_REASON })
})
it('disables joined candidates with the 已加入项目 reason instead of a badge', () => {
const items = accountsToPickItems([
{ owner: 'a', name: '甲', valid: true, joined: true },
{ owner: 'b', valid: true, joined: false },
{ owner: 'c', name: '丙', valid: true },
{ userId: 'u-a', owner: 'a', name: '甲', valid: true, joined: true },
{ userId: 'u-b', owner: 'b', valid: true, joined: false },
{ userId: 'u-c', owner: 'c', name: '丙', valid: true },
], ['b'])
expect(items).toEqual([
{ id: 'a', title: '甲', subtitle: 'a', disabled: true, disabledReason: JOINED_REASON },
{ id: 'b', title: 'b', subtitle: 'b', disabled: true, disabledReason: '已加入项目' },
{ id: 'c', title: '丙', subtitle: 'c' },
{ id: 'u-a', owner: 'a', title: '甲', subtitle: 'a', disabled: true, disabledReason: JOINED_REASON },
{ id: 'u-b', owner: 'b', title: 'b', subtitle: 'b', disabled: true, disabledReason: '已加入项目' },
{ id: 'u-c', owner: 'c', title: '丙', subtitle: 'c' },
])
expect(items.some(it => 'badge' in it)).toBe(false)
})
it('keeps name as title and account as subtitle so the selection shows both', () => {
const [it] = accountsToPickItems([{ owner: 'zhangsan', name: '张三', valid: true, joined: false }])
const [it] = accountsToPickItems([{ userId: 'u-z', owner: 'zhangsan', name: '张三', valid: true, joined: false }])
expect(it.title).toBe('张三')
expect(it.subtitle).toBe('zhangsan')
})
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import type { PermissionDescriptor, RoleRecord } from '../src/api'
import {
filterRoles, groupPermissionPage, hasSystemAction, shouldAcceptRoleResponse,
} from '../src/components/RolePermissionsPanel'
const roles: RoleRecord[] = [
{ roleId: 'role_engineer', name: '软件工程师', description: '研发', enabled: true, roleVersion: 1, bindingCount: 2, permissions: [] },
{ roleId: 'role_reviewer', name: '审核工程师', description: '质量', enabled: false, roleVersion: 1, bindingCount: 1, permissions: [] },
]
const catalog: PermissionDescriptor[] = [
{ actionId: 'project.create', label: '创建项目', description: '', group: '项目入口', scope: 'system', protected: false },
{ actionId: 'document.read', label: '读取文档', description: '', group: '文档动作', scope: 'project', protected: false },
{ actionId: 'document.author', label: '编制文档', description: '', group: '文档动作', scope: 'project', protected: false },
]
describe('role permission UI helpers', () => {
it('combines exact or fuzzy search with the role status filter', () => {
expect(filterRoles(roles, '软件工程师', 'exact', 'enabled').map(role => role.roleId)).toEqual(['role_engineer'])
expect(filterRoles(roles, '工程师', 'fuzzy', 'all').map(role => role.roleId)).toEqual(['role_engineer', 'role_reviewer'])
expect(filterRoles(roles, '', 'fuzzy', 'disabled').map(role => role.roleId)).toEqual(['role_reviewer'])
})
it('groups and pages matrix columns without changing their stable action IDs', () => {
expect(groupPermissionPage(catalog, '文档动作', 0, 1).map(item => item.actionId)).toEqual(['document.read'])
expect(groupPermissionPage(catalog, '文档动作', 1, 1).map(item => item.actionId)).toEqual(['document.author'])
})
it('drops late results and checks explicit system actions', () => {
expect(shouldAcceptRoleResponse(4, 3)).toBe(false)
expect(shouldAcceptRoleResponse(4, 4)).toBe(true)
expect(hasSystemAction(['system.roles.view'], 'system.roles.view')).toBe(true)
expect(hasSystemAction(['system.roles.view'], 'system.permissions.manage')).toBe(false)
})
})
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import {
createRolePermissionDraft, mergeRoleDraft, rebaseRoleDraft, updateRolePermission, validateRolePermissionDraft, visiblePatch,
type RolePermissionValue,
} from '../src/rolePermissions'
const initial: Record<string, RolePermissionValue> = {
'project.create': { allowed: false, capAllowed: true },
'document.read': { allowed: true, capAllowed: true },
'document.author': { allowed: false, capAllowed: true },
}
describe('role permission draft', () => {
it('retains hidden edits and patches only explicit visible actions', () => {
let draft = createRolePermissionDraft(initial)
draft = updateRolePermission(draft, 'project.create', { allowed: true, capAllowed: true })
draft = updateRolePermission(draft, 'document.author', { allowed: true, capAllowed: true })
expect(visiblePatch(draft, ['project.create'])).toEqual([
{ actionId: 'project.create', allowed: true, capAllowed: true },
])
expect(draft.values['document.author']).toEqual({ allowed: true, capAllowed: true })
})
it('produces the same patch for single-role and matrix edits', () => {
const single = updateRolePermission(createRolePermissionDraft(initial), 'document.read', { allowed: false, capAllowed: true })
const matrix = updateRolePermission(createRolePermissionDraft(initial), 'document.read', { allowed: false, capAllowed: true })
expect(visiblePatch(single, Object.keys(initial))).toEqual(visiblePatch(matrix, Object.keys(initial)))
})
it('discards a stale server refresh without replacing newer edits', () => {
const edited = updateRolePermission(createRolePermissionDraft(initial), 'project.create', { allowed: true, capAllowed: true })
const merged = mergeRoleDraft(edited, initial, edited.generation - 1)
expect(merged).toBe(edited)
})
it('rejects defaults that exceed the role cap', () => {
const invalid = updateRolePermission(createRolePermissionDraft(initial), 'document.read', { allowed: true, capAllowed: false })
expect(validateRolePermissionDraft(invalid)).toEqual({ actionId: 'document.read', reason: 'default-exceeds-cap' })
})
it('rebases local changes on a newer role version without losing the draft', () => {
const edited = updateRolePermission(createRolePermissionDraft(initial), 'project.create', { allowed: true, capAllowed: true })
const server = { ...initial, 'document.read': { allowed: false, capAllowed: true } }
const rebased = rebaseRoleDraft(edited, server)
expect(rebased.baseline['document.read']).toEqual({ allowed: false, capAllowed: true })
expect(rebased.values['project.create']).toEqual({ allowed: true, capAllowed: true })
expect(rebased.values['document.read']).toEqual({ allowed: false, capAllowed: true })
})
})