Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9df5517e9c | ||
|
|
23877af960 | ||
|
|
e6bb387f71 | ||
|
|
6db528df10 | ||
|
|
22ef3c4b67 | ||
|
|
080c630816 | ||
|
|
e4041b36b9 | ||
|
|
afd9e7504b | ||
|
|
72c941c772 | ||
|
|
d47c49350b | ||
|
|
1650b4fff0 | ||
|
|
bc9b17a665 | ||
|
|
a1bd3e2983 | ||
|
|
93ee723131 | ||
|
|
ee735f2e2e | ||
|
|
3203e8d4f3 | ||
|
|
bed8a5c766 | ||
|
|
055476d135 | ||
|
|
34f6a1bf2f | ||
|
|
2e7e5b7a64 | ||
|
|
36ac754300 | ||
|
|
a961b19ebb | ||
|
|
010540f203 | ||
|
|
731c9b7776 |
@@ -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,用户在部署上验收,问题另立修复。
|
||||
@@ -0,0 +1,38 @@
|
||||
// 账号管理动作呈现门控(超级管理员 R3/R4/R8):只决定 UI 暴露哪些入口,
|
||||
// 服务端授权仍是唯一权威——隐藏的入口经直连 API 调用同样会被拒绝。
|
||||
interface AccountIdentity { isAdmin?: boolean; isSuperAdmin?: boolean }
|
||||
|
||||
type AccountIdentityKind = 'super' | 'admin' | 'user'
|
||||
|
||||
export const accountIdentityKind = (account: AccountIdentity): AccountIdentityKind =>
|
||||
account.isSuperAdmin ? 'super' : account.isAdmin ? 'admin' : 'user'
|
||||
|
||||
/** 创建管理员账号与任命/撤销管理员仅对超级管理员开放。 */
|
||||
export const canAppointAdmins = (me: AccountIdentity | null | undefined) => !!me?.isSuperAdmin
|
||||
|
||||
interface AccountActionPolicy {
|
||||
/** 资料、角色与系统权限维护。 */
|
||||
maintain: boolean
|
||||
toggleEnabled: boolean
|
||||
/** 管理员重置临时密码与恢复提案。 */
|
||||
resetPassword: boolean
|
||||
adminChange: 'promote' | 'demote' | null
|
||||
protectedReason: string
|
||||
}
|
||||
|
||||
export function accountActionPolicy(me: AccountIdentity | null | undefined, target: AccountIdentity): AccountActionPolicy {
|
||||
if (target.isSuperAdmin) {
|
||||
const self = !!me?.isSuperAdmin
|
||||
return {
|
||||
maintain: self, toggleEnabled: false, resetPassword: false, adminChange: null,
|
||||
protectedReason: self
|
||||
? '超级管理员账号不能停用、降级或由管理员重置密码;本人密码请在「设置 → 账号」修改'
|
||||
: '超级管理员账号受保护:普通管理员不能编辑、停用、降级或重置其密码',
|
||||
}
|
||||
}
|
||||
return {
|
||||
maintain: true, toggleEnabled: true, resetPassword: true,
|
||||
adminChange: canAppointAdmins(me) ? (target.isAdmin ? 'demote' : 'promote') : null,
|
||||
protectedReason: '',
|
||||
}
|
||||
}
|
||||
+13
-10
@@ -44,12 +44,12 @@ export interface RecoveryProposal {
|
||||
export interface AccountProfile { name: string; phone: string; email: string; staffNo: string; department: string; notes: string }
|
||||
export interface AccountRecord {
|
||||
userId: string; owner: string; profile: AccountProfile; profileVersion: number
|
||||
enabled?: boolean; stateVersion?: number; isAdmin?: boolean; passwordSet?: boolean
|
||||
enabled?: boolean; stateVersion?: number; isAdmin?: boolean; isSuperAdmin?: boolean; passwordSet?: boolean
|
||||
initializationState?: string; initializationGeneration?: number; createdAt?: string
|
||||
}
|
||||
/** 管理端用户目录/详情视图:账号状态与版本字段必有(乐观并发依赖 stateVersion)。 */
|
||||
export interface ManagedAccountRecord extends AccountRecord {
|
||||
enabled: boolean; stateVersion: number; isAdmin: boolean; passwordSet: boolean
|
||||
enabled: boolean; stateVersion: number; isAdmin: boolean; isSuperAdmin: boolean; passwordSet: boolean
|
||||
initializationState: string; initializationGeneration: number
|
||||
}
|
||||
interface AccountSearchPage { accounts: ManagedAccountRecord[]; nextCursor?: string; directoryVersion: string }
|
||||
@@ -128,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; systemActions?: string[] }>(r)),
|
||||
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; sessionScope?: string; passwordChangeMode?: PasswordChangeMode; isAdmin?: boolean; isSuperAdmin?: boolean; mustChangePassword?: boolean; passwordSet?: boolean; systemActions?: string[] }>(r)),
|
||||
// ---- 认证与会话(管理员发放制,R2)----
|
||||
login: (owner: string, password: string) => auth.fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, password }) }).then(async r => {
|
||||
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
|
||||
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string }
|
||||
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string; passwordChangeMode?: PasswordChangeMode }
|
||||
auth.beginSession()
|
||||
return me
|
||||
}),
|
||||
// 一次性登录 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; isSuperAdmin?: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string; passwordChangeMode?: PasswordChangeMode }
|
||||
auth.beginSession()
|
||||
return me
|
||||
}),
|
||||
@@ -151,11 +151,12 @@ export const api = {
|
||||
},
|
||||
changePassword: (current: string, next: string) => auth.fetch(`${BASE}/api/auth/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ current, next }) }).then(async r => {
|
||||
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
|
||||
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; reloginRequired: boolean }>
|
||||
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; reloginRequired: boolean }>
|
||||
}),
|
||||
listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ userId: string; owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number; profileVersion: number; initializationState: string }[]>(r)),
|
||||
listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ userId: string; owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number; profileVersion: number; initializationState: string }[]>(r)),
|
||||
// 管理员建号:可提供初始密码;省略密码时由服务端生成并仅返回一次。
|
||||
createAdminUser: (owner: string, name: string, password?: string) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile: { name }, ...(password ? { password } : {}) }) }).then(r => json<{ userId: string; owner: string; passwordSet: boolean; mustChangePassword: boolean; initializationState: string; initialPassword?: string }>(r)),
|
||||
// isAdmin 仅超级管理员可用(R3/R4),普通管理员携带时由服务端拒绝。
|
||||
createAdminUser: (owner: string, name: string, password?: string, isAdmin?: boolean) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile: { name }, ...(password ? { password } : {}), ...(isAdmin ? { isAdmin: true } : {}) }) }).then(r => json<{ userId: string; owner: string; passwordSet: boolean; mustChangePassword: boolean; initializationState: string; initialPassword?: string }>(r)),
|
||||
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,
|
||||
@@ -163,10 +164,12 @@ export const api = {
|
||||
if (request.cursor) params.set('cursor', request.cursor)
|
||||
return auth.fetch(`${BASE}/api/admin/users/search?${params}`).then(r => json<AccountSearchPage>(r))
|
||||
},
|
||||
createManagedUser: (owner: string, profile: Partial<AccountProfile>) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile }) }).then(r => json<ManagedAccountRecord>(r)),
|
||||
createManagedUser: (owner: string, profile: Partial<AccountProfile>, isAdmin?: boolean) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile, ...(isAdmin ? { isAdmin: true } : {}) }) }).then(r => json<ManagedAccountRecord>(r)),
|
||||
adminGetUser: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}`).then(r => json<ManagedAccountRecord>(r)),
|
||||
adminPatchProfile: (userId: string, expectedProfileVersion: number, patch: Partial<AccountProfile>) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/profile`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedProfileVersion, patch }) }).then(r => json<ManagedAccountRecord>(r)),
|
||||
adminSetUserEnabled: (userId: string, expectedStateVersion: number, enabled: boolean) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/state`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedStateVersion, enabled }) }).then(r => json<ManagedAccountRecord>(r)),
|
||||
// 任命/撤销管理员:仅超级管理员(R3);超级管理员本身不可降级(R5)。
|
||||
adminSetUserAdmin: (userId: string, expectedStateVersion: number, isAdmin: boolean) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/admin`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedStateVersion, isAdmin }) }).then(r => json<ManagedAccountRecord>(r)),
|
||||
// ---- 管理员:一次性登录 Key(HTTP 签发端点已被恢复提案契约替换为 410,客户端方法随之移除;
|
||||
// list/revoke 仍有效,供恢复提案视图复用;兑换入口不变)----
|
||||
adminListLoginKeys: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`).then(r => json<{ keys: { id: string; owner: string; purpose: string; status: string; expiresAt: string; createdAt: string; createdBy?: string }[] }>(r)).then(d => d.keys ?? []),
|
||||
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
import { useStore } from '../store'
|
||||
import { Collapsible, Icon } from '@ui'
|
||||
import { hasSystemAction } from './RolePermissionsPanel'
|
||||
import { accountActionPolicy, accountIdentityKind, canAppointAdmins } from '../accountAuthority'
|
||||
|
||||
type IdentityFlags = { isAdmin?: boolean; isSuperAdmin?: boolean }
|
||||
|
||||
const emptyProfile = (): AccountProfile => ({ name: '', phone: '', email: '', staffNo: '', department: '', notes: '' })
|
||||
const fields: { key: keyof AccountProfile; label: string }[] = [
|
||||
@@ -12,6 +15,9 @@ const fields: { key: keyof AccountProfile; label: string }[] = [
|
||||
{ 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>
|
||||
@@ -20,6 +26,25 @@ function ProfileInputs({ profile, onChange, prefix }: { profile: AccountProfile;
|
||||
</div>)}</div>
|
||||
}
|
||||
|
||||
export function IdentityChip({ account }: { account: IdentityFlags }) {
|
||||
const kind = accountIdentityKind(account)
|
||||
if (kind === 'super') return <span className="ad-chip ad-chip-super">超级管理员</span>
|
||||
if (kind === 'admin') return <span className="ad-chip ad-chip-admin">管理员</span>
|
||||
return <span className="ad-chip">普通用户</span>
|
||||
}
|
||||
|
||||
export function CreateAdminOption({ me, checked, disabled, onChange }: { me: IdentityFlags | null; checked: boolean; disabled: boolean; onChange: (value: boolean) => void }) {
|
||||
if (!canAppointAdmins(me)) return <p className="ad-hint">仅超级管理员可创建或任命管理员;此处创建普通账号</p>
|
||||
return <label className="ad-check"><input type="checkbox" checked={checked} disabled={disabled}
|
||||
onChange={event => onChange(event.target.checked)} />创建为管理员账号</label>
|
||||
}
|
||||
|
||||
export function AdminAppointmentButton({ me, account, busy, onChange }: { me: IdentityFlags | null; account: IdentityFlags; busy: boolean; onChange: (isAdmin: boolean) => void }) {
|
||||
const change = accountActionPolicy(me, account).adminChange
|
||||
if (!change) return null
|
||||
return <button className="ad-mini" disabled={busy} onClick={() => onChange(change === 'promote')}>{change === 'promote' ? '设为管理员' : '撤销管理员'}</button>
|
||||
}
|
||||
|
||||
export function AccountUsersPanel() {
|
||||
const me = useStore(state => state.me)
|
||||
const allowed = (action: string) => !!me?.isAdmin || hasSystemAction(me?.systemActions, action)
|
||||
@@ -42,6 +67,7 @@ export function AccountUsersPanel() {
|
||||
const [draft, setDraft] = useState<AccountProfile>(emptyProfile)
|
||||
const [newOwner, setNewOwner] = useState('')
|
||||
const [newProfile, setNewProfile] = useState<AccountProfile>(emptyProfile)
|
||||
const [newIsAdmin, setNewIsAdmin] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [mode, setMode] = useState<'exact' | 'fuzzy'>('fuzzy')
|
||||
const [field, setField] = useState('default')
|
||||
@@ -56,15 +82,18 @@ export function AccountUsersPanel() {
|
||||
const sequence = useRef(0)
|
||||
const roleSequence = useRef(0)
|
||||
|
||||
const search = async (pageCursor = cursor) => {
|
||||
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(String(error)) }
|
||||
} catch (error) {
|
||||
if (request === sequence.current) setErr(afterWrite ? `操作已完成,但用户列表暂未刷新:${String(error)}` : String(error))
|
||||
}
|
||||
}
|
||||
const resetPaging = () => { setCursor(''); setTrail([]) }
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => { void search() }, 180)
|
||||
@@ -113,19 +142,21 @@ export function AccountUsersPanel() {
|
||||
return () => { window.clearTimeout(timer) }
|
||||
}, [selected?.userId, roleQuery, roleCursor, canViewRoles])
|
||||
|
||||
// 写入成功会推进目录版本,旧页游标随之失效:从第一页重读,刷新失败不改写成功结果。
|
||||
const refreshFirst = async () => {
|
||||
setCursor(''); setTrail([]); setNextCursor('')
|
||||
await search('')
|
||||
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 refreshFirst(); await open(account)
|
||||
setNotice(`已创建 ${account.owner};账号资料可空,角色另行分配`)
|
||||
const account = await api.createManagedUser(newOwner.trim(), newProfile, canAppointAdmins(me) && newIsAdmin)
|
||||
setNewOwner(''); setNewProfile(emptyProfile()); setNewIsAdmin(false)
|
||||
await open(account)
|
||||
setNotice(`已创建 ${account.owner}${account.isAdmin ? '(管理员)' : ''};账号资料可空,角色另行分配`)
|
||||
await refreshFirst()
|
||||
} catch (error) { setErr(String(error).includes('409') ? '登录名已存在' : String(error)) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
@@ -168,36 +199,54 @@ export function AccountUsersPanel() {
|
||||
setBusy(true); setErr(''); setNotice('')
|
||||
try {
|
||||
const updated = await api.adminSetUserEnabled(selected.userId, selected.stateVersion, !selected.enabled)
|
||||
setSelected(updated); await refreshFirst(); setNotice(updated.enabled ? '账号已启用' : '账号已停用')
|
||||
} catch (error) { setErr(String(error)) } finally { setBusy(false) }
|
||||
setSelected(updated); setNotice(updated.enabled ? '账号已启用;旧会话和 Key 不会恢复' : '账号已停用,旧会话、Key 与进行中的恢复提案已撤销')
|
||||
await refreshFirst()
|
||||
} catch (error) { setErr(String(error).includes('409') ? '状态版本或最后管理员保护发生冲突,请重新打开用户' : String(error)) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const setAdmin = async (isAdmin: boolean) => {
|
||||
if (!selected) return
|
||||
setBusy(true); setErr(''); setNotice('')
|
||||
try {
|
||||
const updated = await api.adminSetUserAdmin(selected.userId, selected.stateVersion, isAdmin)
|
||||
setSelected(updated); await refreshFirst(); setNotice(isAdmin ? '已任命为管理员' : '已撤销管理员')
|
||||
} catch (error) { setErr(String(error).includes('409') ? '用户状态已变化,请重新打开用户' : String(error)) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const policy = selected ? accountActionPolicy(me, selected) : null
|
||||
|
||||
if (!allowed('system.users.view')) return <p className="ad-hint">当前账号没有查看用户的系统权限</p>
|
||||
return <div>
|
||||
{allowed('system.users.create') && <div className="ad-create">
|
||||
<div className="ad-row"><input className="lg-input" aria-label="新用户登录名" placeholder="登录名" value={newOwner} onChange={event => setNewOwner(event.target.value)} />
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !newOwner.trim()} onClick={create}>创建用户</button></div>
|
||||
<CreateAdminOption me={me} checked={newIsAdmin} disabled={busy} onChange={setNewIsAdmin} />
|
||||
<Collapsible id="new-user-profile" trigger="可选资料:姓名、电话、邮箱、工号、部门、备注">
|
||||
<ProfileInputs profile={newProfile} onChange={setNewProfile} prefix="new-user" />
|
||||
</Collapsible>
|
||||
</div>}
|
||||
{notice && <div className="ad-notice" role="status"><Icon name="check" size={14} />{notice}</div>}
|
||||
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
|
||||
{selected ? <div className="rp-editor">
|
||||
{selected && policy ? <div className="rp-editor">
|
||||
<div className="ad-row"><button className="ad-mini" onClick={() => { sequence.current++; setSelected(null) }}>返回列表</button>
|
||||
<b>{selected.owner}</b><span className="ad-dim ad-mono">{selected.userId}</span></div>
|
||||
<p className="ad-hint">{selected.enabled ? '有效账号' : '停用账号'} · 当前角色 {canViewRoles ? (roleView?.role?.name ?? '未分配') : '无角色查看权限'}</p>
|
||||
{allowed('system.users.edit') && <Collapsible id={`user-profile-${selected.userId}`} trigger="编辑可选资料">
|
||||
<b>{selected.owner}</b><IdentityChip account={selected} /><span className="ad-dim ad-mono">{selected.userId}</span></div>
|
||||
<p className="ad-hint">{selected.enabled ? '有效账号' : '停用账号'} · {credentialLabel(selected)} · 资料版本 {selected.profileVersion} · 当前角色 {canViewRoles ? (roleView?.role?.name ?? '未分配') : '无角色查看权限'}</p>
|
||||
{selected.initializationState === 'pending' && <p className="ad-hint">交给可信 owner 的初始化定位:userId <span className="ad-mono">{selected.userId}</span> · 状态版本 {selected.stateVersion} · 代数 {selected.initializationGeneration}。凭据只经保护文件交付。</p>}
|
||||
{policy.protectedReason && <p className="ad-hint" role="note">{policy.protectedReason}</p>}
|
||||
{allowed('system.users.edit') && policy.maintain && <Collapsible id={`user-profile-${selected.userId}`} trigger="编辑可选资料">
|
||||
<ProfileInputs profile={draft} onChange={setDraft} prefix="user-profile" />
|
||||
<button className="lg-submit ad-create-btn" disabled={busy} onClick={saveProfile}>保存资料</button>
|
||||
</Collapsible>}
|
||||
<div className="ad-row">
|
||||
{canAssignRoles && <><select className="lg-input" aria-label="用户生效角色" value={selectedRoleId} onChange={event => setSelectedRoleId(event.target.value)}>
|
||||
{canAssignRoles && policy.maintain && <><select className="lg-input" aria-label="用户生效角色" value={selectedRoleId} onChange={event => setSelectedRoleId(event.target.value)}>
|
||||
<option value="">请选择角色</option>{roles.map(role => <option key={role.roleId} value={role.roleId}>{role.name}</option>)}
|
||||
</select><button className="ad-mini" disabled={busy || !selectedRoleId || selectedRoleId === roleView?.role?.roleId} onClick={assignRole}>替换角色</button></>}
|
||||
{allowed('system.users.disable') && <button className="ad-mini" disabled={busy} onClick={toggleState}>{selected.enabled ? '停用账号' : '启用账号'}</button>}
|
||||
{allowed('system.users.disable') && policy.toggleEnabled && <button className="ad-mini" disabled={busy} onClick={toggleState}>{selected.enabled ? '停用账号' : '启用账号'}</button>}
|
||||
<AdminAppointmentButton me={me} account={selected} busy={busy} onChange={setAdmin} />
|
||||
</div>
|
||||
{canAssignRoles && <div className="ad-row">
|
||||
{canAssignRoles && policy.maintain && <div className="ad-row">
|
||||
<input className="lg-input" aria-label="搜索可分配角色" placeholder="搜索有效角色" value={roleQuery}
|
||||
onChange={event => { setRoleQuery(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }} />
|
||||
<button className="ad-mini" disabled={roleLoading || !roleTrail.length} onClick={() => { setRoleCursor(roleTrail[roleTrail.length - 1]); setRoleTrail(roleTrail.slice(0, -1)) }}>上一页角色</button>
|
||||
@@ -205,25 +254,28 @@ export function AccountUsersPanel() {
|
||||
</div>}
|
||||
{canViewGrants && <Collapsible id={`system-grants-${selected.userId}`} trigger="明确系统权限">
|
||||
<div className="rp-grants">{catalog.filter(item => item.scope === 'system').map(item => <label className="ad-check" key={item.actionId}>
|
||||
<input type="checkbox" checked={grants.includes(item.actionId)} disabled={!canManageGrants || busy} onChange={event => setGrants(current => event.target.checked
|
||||
<input type="checkbox" checked={grants.includes(item.actionId)} disabled={!canManageGrants || !policy.maintain || busy} onChange={event => setGrants(current => event.target.checked
|
||||
? [...new Set([...current, item.actionId])] : current.filter(value => value !== item.actionId))} />{item.label}<span className="ad-dim ad-mono">{item.actionId}</span>
|
||||
</label>)}</div>
|
||||
{canManageGrants && <button className="lg-submit ad-create-btn" disabled={busy} onClick={saveGrants}>保存系统权限</button>}
|
||||
{canManageGrants && policy.maintain && <button className="lg-submit ad-create-btn" disabled={busy} onClick={saveGrants}>保存系统权限</button>}
|
||||
</Collapsible>}
|
||||
{!canViewGrants && allowed('system.permissions.manage') && <p className="ad-hint">需要同时具备系统权限查看权,才能安全编辑现有授权</p>}
|
||||
</div> : <>
|
||||
<div className="ad-row rp-filters">
|
||||
<input className="lg-input" aria-label="搜索用户" placeholder="搜索用户资料" value={query} onChange={event => { setQuery(event.target.value); setCursor(''); setTrail([]) }} />
|
||||
<select className="lg-input" aria-label="用户匹配方式" value={mode} onChange={event => setMode(event.target.value as typeof mode)}><option value="fuzzy">模糊匹配</option><option value="exact">精确匹配</option></select>
|
||||
<select className="lg-input" aria-label="用户搜索字段" value={field} onChange={event => setField(event.target.value)}>
|
||||
<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)}><option value="all">全部状态</option><option value="enabled">有效</option><option value="disabled">停用</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></tr></thead>
|
||||
<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.enabled ? '有效' : '停用'}</td><td><button className="ad-mini" onClick={() => open(account)}>查看与管理</button></td></tr>)}</tbody></table>
|
||||
<td>{account.profile.staffNo || '—'}</td><td><IdentityChip account={account} /></td><td>{account.enabled ? '有效' : '停用'}</td>
|
||||
<td>{credentialLabel(account)}</td><td><button className="ad-mini" onClick={() => open(account)}>查看与管理</button></td></tr>)}
|
||||
{accounts !== null && accounts.length === 0 && <tr><td colSpan={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>
|
||||
</>}
|
||||
|
||||
@@ -110,7 +110,7 @@ export function AdminZone() {
|
||||
<Icon name="chevron-down" size={14} /> 返回工作台
|
||||
</button>
|
||||
<span className="az-title">系统管理</span>
|
||||
<span className="az-sub">{isAdmin ? '根管理员' : '按系统权限开放'} · 角色与权限由服务端判定</span>
|
||||
<span className="az-sub">{me?.isSuperAdmin ? '超级管理员' : isAdmin ? '根管理员' : '按系统权限开放'} · 角色与权限由服务端判定</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span className="az-tenant">{me?.owner ?? ''}</span>
|
||||
</div>
|
||||
|
||||
@@ -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*/, '')
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// 门控集中在 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'
|
||||
@@ -16,8 +16,9 @@ import {
|
||||
// ---- 项目设置:成员(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'
|
||||
|
||||
@@ -46,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)
|
||||
@@ -56,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))
|
||||
@@ -65,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) => {
|
||||
@@ -120,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 !== ''
|
||||
|
||||
@@ -143,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
|
||||
@@ -151,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}
|
||||
|
||||
@@ -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,11 +9,16 @@ 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'
|
||||
import { accountActionPolicy, canAppointAdmins } from '../accountAuthority'
|
||||
import { AdminAppointmentButton, CreateAdminOption, IdentityChip } from './AccountUsersPanel'
|
||||
|
||||
// ---- 用户管理:页面建号可设置/生成初始密码,管理员重置生成临时密码;
|
||||
// 保留恢复提案与 Key 管理作为既有非登录页恢复流程。 ----
|
||||
interface AdminUserRow { userId: string; owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number }
|
||||
interface AdminUserRow { userId: string; owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number }
|
||||
|
||||
export function UsersPanel() {
|
||||
const me = useStore(s => s.me)
|
||||
@@ -23,17 +28,27 @@ export function UsersPanel() {
|
||||
const [owner, setOwner] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [initialPassword, setInitialPassword] = useState('')
|
||||
const [createAsAdmin, setCreateAsAdmin] = useState(false)
|
||||
const [generatedCredential, setGeneratedCredential] = useState<{ owner: string; password: string; kind: 'initial' | 'temporary' } | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
// 恢复提案面板:propFor 打开指定用户的提案视图(创建 / 状态 / 撤销)。
|
||||
const [propFor, setPropFor] = useState<AdminUserRow | null>(null)
|
||||
const [proposals, setProposals] = useState<RecoveryProposal[] | null>(null)
|
||||
|
||||
// 提案视图代次:切换/关闭目标推进代次,迟到响应不得覆盖新选中的账号。
|
||||
const proposalSeq = useRef(0)
|
||||
// 结果未确定的提案请求:显式重试沿用同一 idempotencyKey,不重复创建。
|
||||
const recoveryAttempt = useRef<RecoveryAttempt | null>(null)
|
||||
|
||||
const refresh = async () => {
|
||||
try { setUsers(await api.listAdminUsers()) } catch (e) { setErr(String(e)) }
|
||||
}
|
||||
const refreshProposals = async (userId: string) => {
|
||||
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() }, [])
|
||||
|
||||
@@ -41,10 +56,10 @@ export function UsersPanel() {
|
||||
setErr(''); setNotice('')
|
||||
setBusy(true)
|
||||
try {
|
||||
const out = await api.createAdminUser(owner.trim(), name.trim(), initialPassword.trim() || undefined)
|
||||
const out = await api.createAdminUser(owner.trim(), name.trim(), initialPassword.trim() || undefined, canAppointAdmins(me) && createAsAdmin)
|
||||
setGeneratedCredential(out.initialPassword ? { owner: out.owner, password: out.initialPassword, kind: 'initial' } : null)
|
||||
setNotice(out.initialPassword ? `已创建 ${out.owner},请将下方一次性初始密码交付给用户。首次登录后必须改密。` : `已创建 ${out.owner}。用户首次登录后必须改密。`)
|
||||
setOwner(''); setName(''); setInitialPassword('')
|
||||
setOwner(''); setName(''); setInitialPassword(''); setCreateAsAdmin(false)
|
||||
await refresh()
|
||||
} catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
@@ -59,33 +74,73 @@ export function UsersPanel() {
|
||||
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const setAdmin = async (u: AdminUserRow, isAdmin: boolean) => {
|
||||
setErr(''); setNotice(''); setBusy(true)
|
||||
try {
|
||||
await api.adminSetUserAdmin(u.userId, u.stateVersion, isAdmin)
|
||||
setNotice(isAdmin ? `已任命 ${u.owner} 为管理员` : `已撤销 ${u.owner} 的管理员身份`)
|
||||
await refresh()
|
||||
} catch (e) { setErr(String(e).includes('409') ? '用户状态已变化,请刷新后重试' : String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const openProposals = async (u: AdminUserRow) => {
|
||||
proposalSeq.current++
|
||||
setErr(''); setNotice(''); setPropFor(u); setProposals(null)
|
||||
await refreshProposals(u.userId)
|
||||
}
|
||||
const closeProposals = () => { proposalSeq.current++; setPropFor(null); setProposals(null) }
|
||||
|
||||
const propose = async () => {
|
||||
if (!propFor) return
|
||||
const target = propFor
|
||||
const seq = proposalSeq.current
|
||||
const stillSelected = () => seq === proposalSeq.current
|
||||
setErr(''); setNotice(''); setBusy(true)
|
||||
const idempotencyKey = recoveryAttemptKey(recoveryAttempt.current, target.userId, target.stateVersion, () => crypto.randomUUID())
|
||||
recoveryAttempt.current = { userId: target.userId, stateVersion: target.stateVersion, key: idempotencyKey }
|
||||
try {
|
||||
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) }
|
||||
}
|
||||
|
||||
@@ -99,6 +154,7 @@ export function UsersPanel() {
|
||||
<input className="lg-input" type="password" autoComplete="new-password" placeholder="初始密码(留空随机生成)" value={initialPassword} onChange={e => setInitialPassword(e.target.value)} />
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !owner.trim()} onClick={create}>创建账号</button>
|
||||
</div>
|
||||
<CreateAdminOption me={me} checked={createAsAdmin} disabled={busy} onChange={setCreateAsAdmin} />
|
||||
<p className="ad-hint">可填写管理员指定的初始密码;留空则由服务端生成一次性随机密码。用户首次登录后必须改密。</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -114,7 +170,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 && (
|
||||
@@ -141,19 +197,23 @@ export function UsersPanel() {
|
||||
<table className="ad-table">
|
||||
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>密码设置于</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{(users ?? []).map(u => (
|
||||
<tr key={u.owner}>
|
||||
{(users ?? []).map(u => {
|
||||
const policy = accountActionPolicy(me, u)
|
||||
return <tr key={u.owner}>
|
||||
<td className="ad-user-cell"><span className="ad-mono">{u.owner}</span></td>
|
||||
<td>{u.name || '—'}</td>
|
||||
<td>{u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
|
||||
<td>{u.isSuperAdmin ? <IdentityChip account={u} /> : u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
|
||||
<td>{u.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
|
||||
<td className="ad-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
|
||||
<td>{me?.isAdmin && <span className="ad-row">
|
||||
<button className="ad-mini" disabled={busy} onClick={() => resetPassword(u)}>重置临时密码</button>
|
||||
<button className="ad-mini" onClick={() => openProposals(u)}>恢复提案</button>
|
||||
{policy.resetPassword ? <>
|
||||
<button className="ad-mini" disabled={busy} onClick={() => resetPassword(u)}>重置临时密码</button>
|
||||
<button className="ad-mini" onClick={() => openProposals(u)}>恢复提案</button>
|
||||
</> : <span className="ad-dim">{policy.protectedReason}</span>}
|
||||
<AdminAppointmentButton me={me} account={u} busy={busy} onChange={isAdmin => setAdmin(u, isAdmin)} />
|
||||
</span>}</td>
|
||||
</tr>
|
||||
))}
|
||||
})}
|
||||
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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('会话证明')
|
||||
|
||||
@@ -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 '无法发起:该账号没有可用的合格会话证明(需该用户当前在任一设备保持完整密码登录)。历史来源或已锁死账号请走服务器本地处置'
|
||||
|
||||
+1
-1
@@ -90,7 +90,7 @@ export interface UIState {
|
||||
projectSettingsId: string | null
|
||||
}
|
||||
|
||||
export interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; mustChangePassword?: boolean; systemActions?: string[] }
|
||||
export interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; isSuperAdmin?: boolean; mustChangePassword?: boolean; systemActions?: string[] }
|
||||
|
||||
interface AppState {
|
||||
contextObs: Record<string, ContextObs>
|
||||
|
||||
@@ -543,6 +543,7 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.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); }
|
||||
.ad-chip-admin { background: var(--accent-soft); color: var(--accent-text); }
|
||||
.ad-chip-super { background: var(--accent); color: var(--text-on-accent); }
|
||||
.ad-chip-warn { background: var(--warn-soft); color: var(--warn-text); }
|
||||
.ad-reset { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; border: 1px solid color-mix(in srgb, var(--warn) 40%, transparent); background: var(--warn-soft); border-radius: var(--radius-lg); padding: 12px; font-size: var(--fs-ui-sm); }
|
||||
.ad-reset .lg-input { flex: 1; min-width: 160px; height: 32px; }
|
||||
|
||||
@@ -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('未设置密码')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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('未知错误剥状态码前缀透传', () => {
|
||||
|
||||
@@ -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
@@ -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')
|
||||
})
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// 超级管理员 UI 门控(计划 2026-10-19-0113 R3/R4/R8):身份标签可见;建号为管理员与
|
||||
// 任命/撤销入口只对超级管理员出现;超级管理员不可降级、停用或被普通管理员维护。
|
||||
// 服务端授权仍为权威,这里只锁定前端呈现与请求体。
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { api } from '../src/api'
|
||||
import { auth } from '../src/auth'
|
||||
import { accountActionPolicy, accountIdentityKind, canAppointAdmins } from '../src/accountAuthority'
|
||||
import { AdminAppointmentButton, CreateAdminOption, IdentityChip } from '../src/components/AccountUsersPanel'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
const superAdmin = { isAdmin: true, isSuperAdmin: true }
|
||||
const admin = { isAdmin: true, isSuperAdmin: false }
|
||||
const user = { isAdmin: false, isSuperAdmin: false }
|
||||
const noop = () => undefined
|
||||
|
||||
describe('身份标签', () => {
|
||||
it('超级管理员、管理员与普通用户各有可辨识标签', () => {
|
||||
expect(accountIdentityKind(superAdmin)).toBe('super')
|
||||
expect(accountIdentityKind(admin)).toBe('admin')
|
||||
expect(accountIdentityKind(user)).toBe('user')
|
||||
expect(renderToStaticMarkup(createElement(IdentityChip, { account: superAdmin }))).toContain('超级管理员')
|
||||
expect(renderToStaticMarkup(createElement(IdentityChip, { account: admin }))).toContain('>管理员<')
|
||||
expect(renderToStaticMarkup(createElement(IdentityChip, { account: user }))).toContain('普通用户')
|
||||
})
|
||||
it('isAdmin 不推导超级管理员身份', () => {
|
||||
expect(accountIdentityKind({ isAdmin: true })).toBe('admin')
|
||||
})
|
||||
})
|
||||
|
||||
describe('管理员任命入口只对超级管理员开放', () => {
|
||||
it('建号:超级管理员看到「创建为管理员账号」,普通管理员只见普通建号说明', () => {
|
||||
expect(canAppointAdmins(superAdmin)).toBe(true)
|
||||
expect(canAppointAdmins(admin)).toBe(false)
|
||||
expect(canAppointAdmins(null)).toBe(false)
|
||||
const superHtml = renderToStaticMarkup(createElement(CreateAdminOption, { me: superAdmin, checked: false, disabled: false, onChange: noop }))
|
||||
expect(superHtml).toContain('type="checkbox"')
|
||||
expect(superHtml).toContain('创建为管理员账号')
|
||||
const adminHtml = renderToStaticMarkup(createElement(CreateAdminOption, { me: admin, checked: false, disabled: false, onChange: noop }))
|
||||
expect(adminHtml).not.toContain('checkbox')
|
||||
expect(adminHtml).toContain('仅超级管理员可创建或任命管理员')
|
||||
})
|
||||
it('任命/撤销:超级管理员可任命普通用户、撤销管理员;普通管理员看不到任何任命按钮', () => {
|
||||
expect(accountActionPolicy(superAdmin, user).adminChange).toBe('promote')
|
||||
expect(accountActionPolicy(superAdmin, admin).adminChange).toBe('demote')
|
||||
expect(renderToStaticMarkup(createElement(AdminAppointmentButton, { me: superAdmin, account: user, busy: false, onChange: noop }))).toContain('设为管理员')
|
||||
expect(renderToStaticMarkup(createElement(AdminAppointmentButton, { me: superAdmin, account: admin, busy: false, onChange: noop }))).toContain('撤销管理员')
|
||||
for (const target of [user, admin, superAdmin]) {
|
||||
expect(accountActionPolicy(admin, target).adminChange).toBeNull()
|
||||
expect(renderToStaticMarkup(createElement(AdminAppointmentButton, { me: admin, account: target, busy: false, onChange: noop }))).toBe('')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('超级管理员账号受保护', () => {
|
||||
it('超级管理员本人不可被降级或停用,也不走管理员重置密码', () => {
|
||||
const self = accountActionPolicy(superAdmin, superAdmin)
|
||||
expect(self).toMatchObject({ adminChange: null, toggleEnabled: false, resetPassword: false, maintain: true })
|
||||
expect(self.protectedReason).toContain('不能停用、降级')
|
||||
expect(renderToStaticMarkup(createElement(AdminAppointmentButton, { me: superAdmin, account: superAdmin, busy: false, onChange: noop }))).toBe('')
|
||||
})
|
||||
it('普通管理员不能编辑、停用、降级或重置超级管理员,并获得明确说明', () => {
|
||||
const policy = accountActionPolicy(admin, superAdmin)
|
||||
expect(policy).toMatchObject({ maintain: false, toggleEnabled: false, resetPassword: false, adminChange: null })
|
||||
expect(policy.protectedReason).toContain('超级管理员账号受保护')
|
||||
})
|
||||
it('普通账号的既有维护能力不受影响', () => {
|
||||
expect(accountActionPolicy(admin, user)).toMatchObject({ maintain: true, toggleEnabled: true, resetPassword: true, protectedReason: '' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('账号管理请求体', () => {
|
||||
const stub = (body: unknown) => vi.spyOn(auth, 'fetch').mockImplementation(async () => new Response(JSON.stringify(body)))
|
||||
|
||||
it('adminSetUserAdmin 以乐观版本 PUT /api/admin/users/:userId/admin', async () => {
|
||||
const request = stub({ userId: 'u 1' })
|
||||
await api.adminSetUserAdmin('u 1', 7, true)
|
||||
const [url, options] = request.mock.calls[0]
|
||||
expect(url).toBe('/api/admin/users/u%201/admin')
|
||||
expect(options?.method).toBe('PUT')
|
||||
expect(JSON.parse(String(options?.body))).toEqual({ expectedStateVersion: 7, isAdmin: true })
|
||||
})
|
||||
it('建号只在请求管理员身份时携带 isAdmin', async () => {
|
||||
const request = stub({ userId: 'u1', owner: 'alice' })
|
||||
await api.createManagedUser('alice', { name: 'A' })
|
||||
await api.createManagedUser('bob', {}, true)
|
||||
await api.createAdminUser('carol', 'C')
|
||||
await api.createAdminUser('dave', 'D', undefined, true)
|
||||
const bodies = request.mock.calls.map(call => JSON.parse(String(call[1]?.body)))
|
||||
expect(bodies[0]).toEqual({ owner: 'alice', profile: { name: 'A' } })
|
||||
expect(bodies[1]).toEqual({ owner: 'bob', profile: {}, isAdmin: true })
|
||||
expect(bodies[2]).toEqual({ owner: 'carol', profile: { name: 'C' } })
|
||||
expect(bodies[3]).toEqual({ owner: 'dave', profile: { name: 'D' }, isAdmin: true })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user