diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..6a89b54 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/.task/archive/feature-iam-u3-account-ui.md b/.task/archive/feature-iam-u3-account-ui.md new file mode 100644 index 0000000..9cc5b79 --- /dev/null +++ b/.task/archive/feature-iam-u3-account-ui.md @@ -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,用户在部署上验收,问题另立修复。 diff --git a/src/api.ts b/src/api.ts index e451083..a1e0cb9 100644 --- a/src/api.ts +++ b/src/api.ts @@ -132,15 +132,15 @@ export const api = { // ---- 认证与会话(管理员发放制,R2)---- login: (owner: string, password: string) => auth.fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, password }) }).then(async r => { if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`) - const me = await r.json() as { owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string } + 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; isSuperAdmin?: 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 }), diff --git a/src/components/AccountUsersPanel.tsx b/src/components/AccountUsersPanel.tsx index 0e27427..a05ffd5 100644 --- a/src/components/AccountUsersPanel.tsx +++ b/src/components/AccountUsersPanel.tsx @@ -15,6 +15,9 @@ const fields: { key: keyof AccountProfile; label: string }[] = [ { key: 'staffNo', label: '工号' }, { key: 'department', label: '部门' }, { key: 'notes', label: '备注' }, ] +export const credentialLabel = (account: Pick) => + account.initializationState === 'pending' ? '待可信 owner 初始化' : account.passwordSet ? '已设置密码' : '未设置密码' + function ProfileInputs({ profile, onChange, prefix }: { profile: AccountProfile; onChange: (value: AccountProfile) => void; prefix: string }) { return
{fields.map(field =>
@@ -79,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) @@ -136,9 +142,10 @@ 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 () => { @@ -147,8 +154,9 @@ export function AccountUsersPanel() { try { const account = await api.createManagedUser(newOwner.trim(), newProfile, canAppointAdmins(me) && newIsAdmin) setNewOwner(''); setNewProfile(emptyProfile()); setNewIsAdmin(false) - await refreshFirst(); await open(account) + await open(account) setNotice(`已创建 ${account.owner}${account.isAdmin ? '(管理员)' : ''};账号资料可空,角色另行分配`) + await refreshFirst() } catch (error) { setErr(String(error).includes('409') ? '登录名已存在' : String(error)) } finally { setBusy(false) } } @@ -191,8 +199,10 @@ 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) => { @@ -222,7 +232,8 @@ export function AccountUsersPanel() { {selected && policy ?
{selected.owner}{selected.userId}
-

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

+

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

+ {selected.initializationState === 'pending' &&

交给可信 owner 的初始化定位:userId {selected.userId} · 状态版本 {selected.stateVersion} · 代数 {selected.initializationGeneration}。凭据只经保护文件交付。

} {policy.protectedReason &&

{policy.protectedReason}

} {allowed('system.users.edit') && policy.maintain && @@ -251,17 +262,20 @@ export function AccountUsersPanel() { {!canViewGrants && allowed('system.permissions.manage') &&

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

}
: <>
- { setQuery(event.target.value); setCursor(''); setTrail([]) }} /> - - { setQuery(event.target.value); resetPaging() }} /> + + - +
- +
登录名姓名工号身份状态
{(accounts ?? []).map(account => - )}
登录名姓名工号身份状态首次凭据
{account.owner}{account.profile.name || '—'}{account.profile.staffNo || '—'}{account.enabled ? '有效' : '停用'}
+ {account.profile.staffNo || '—'}{account.enabled ? '有效' : '停用'} + {credentialLabel(account)})} + {accounts !== null && accounts.length === 0 && 没有匹配用户} + {accounts === null && 加载中…}
} diff --git a/src/components/LoginPage.tsx b/src/components/LoginPage.tsx index 220fe40..0c4e875 100644 --- a/src/components/LoginPage.tsx +++ b/src/components/LoginPage.tsx @@ -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*/, '') } diff --git a/src/components/adminRdmSections.tsx b/src/components/adminRdmSections.tsx index 9ae19b3..74f6b70 100644 --- a/src/components/adminRdmSections.tsx +++ b/src/components/adminRdmSections.tsx @@ -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([]) const [candRoleId, setCandRoleId] = useState('') const [candMatch, setCandMatch] = useState('fuzzy') + const [candFields, setCandFields] = useState('both') const [role, setRole] = useState<'member' | 'owner'>('member') // 成员表经 ref 参与取数(已加入标记),保持 fetchPage 身份稳定(Picker 依赖)。 const rowsRef = useRef(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('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 => )} +
-

候选来自系统账号目录(姓名为空时显示账号),无效账号不提供;已加入项目的账号显示为不可选——调整身份请在成员表中操作。最后一位负责人不可移除/降级。

+

候选来自系统账号目录(输入后检索,姓名为空时显示账号);无效账号与已加入项目的账号显示为不可选——调整身份请在成员表中操作。最后一位负责人不可移除/降级。

(null) const [proposals, setProposals] = useState(null) + // 提案视图代次:切换/关闭目标推进代次,迟到响应不得覆盖新选中的账号。 + const proposalSeq = useRef(0) + // 结果未确定的提案请求:显式重试沿用同一 idempotencyKey,不重复创建。 + const recoveryAttempt = useRef(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() }, []) @@ -72,32 +84,63 @@ export function UsersPanel() { } const openProposals = async (u: AdminUserRow) => { + proposalSeq.current++ setErr(''); setNotice(''); setPropFor(u); setProposals(null) await refreshProposals(u.userId) } + const closeProposals = () => { proposalSeq.current++; setPropFor(null); setProposals(null) } const propose = async () => { if (!propFor) return + const target = propFor + const seq = proposalSeq.current + const stillSelected = () => seq === proposalSeq.current setErr(''); setNotice(''); setBusy(true) + const idempotencyKey = recoveryAttemptKey(recoveryAttempt.current, target.userId, target.stateVersion, () => crypto.randomUUID()) + recoveryAttempt.current = { userId: target.userId, stateVersion: target.stateVersion, key: idempotencyKey } try { - await api.createAdminRecoveryProposal(propFor.userId, propFor.stateVersion, crypto.randomUUID()) - setNotice(`已为 ${propFor.owner} 建立恢复提案:请该用户在「设置 → 账号」由本人确认;确认后一次性 Key 只在其会话内出现`) - await refreshProposals(propFor.userId) + const created = await api.createAdminRecoveryProposal(target.userId, target.stateVersion, idempotencyKey) + recoveryAttempt.current = null + const statuses = await api.listAdminRecoveryProposals(target.userId).catch(() => null) + if (!stillSelected()) return + if (statuses) setProposals(statuses) + setNotice(recoveryProposalNotice(target.owner, (statuses?.find(p => p.id === created.id) ?? created).status)) } catch (e) { - if (String(e).includes('409')) { - await resetPassword(propFor) + const message = String(e) + if (message.includes('409')) { + recoveryAttempt.current = null + await resetPassword(target) return } - setErr(recoveryProposalErrorMessage(String(e))) + if (!stillSelected()) return + if (recoveryProposalRejected(message)) { + recoveryAttempt.current = null + setErr(recoveryProposalErrorMessage(message)) + return + } + // 响应丢失或服务端异常时提案可能已提交:先重读元数据,再允许沿用同一 key 显式重试。 + try { + const statuses = await api.listAdminRecoveryProposals(target.userId) + if (!stillSelected()) return + setProposals(statuses) + if (statuses.length > 0) { + setNotice(`提案响应未收到;状态已刷新。最近记录:${recoveryStatusLabel[statuses[0].status]}。请核对下方记录;再次提交会沿用同一请求标识。`) + } else { + setErr('提案结果未确定;状态已刷新。再次提交会沿用同一请求标识。') + } + } catch { + if (stillSelected()) setErr('提案结果未知且状态暂时无法读取;请稍后核对,不会自动重复创建。') + } } finally { setBusy(false) } } const revokeProposal = async (p: RecoveryProposal) => { if (!propFor) return - setErr(''); setBusy(true) + setErr(''); setNotice(''); setBusy(true) try { await api.adminRevokeRecoveryProposal(propFor.userId, p.id, p.version) await refreshProposals(propFor.userId) + setNotice('提案已撤销') } catch (e) { setErr(String(e)) } finally { setBusy(false) } } @@ -127,7 +170,7 @@ export function UsersPanel() {
{propFor.owner} 的恢复提案(属主确认制:管理员提案,本人确认后获一次性 Key) - +

前提:该用户当前在任一设备保持完整密码登录(会话证明)。已锁死且无会话的账号无法在线恢复,请走服务器本地处置。

{proposals !== null && proposals.length > 0 && ( diff --git a/src/components/duties/RdmDutiesSection.tsx b/src/components/duties/RdmDutiesSection.tsx index b7e8523..8883827 100644 --- a/src/components/duties/RdmDutiesSection.tsx +++ b/src/components/duties/RdmDutiesSection.tsx @@ -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([]) const [legacyOpen, setLegacyOpen] = useState(false) + const [candMode, setCandMode] = useState('fuzzy') + const [candFields, setCandFields] = useState('both') + const [accountIds, setAccountIds] = useState({}) 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(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) => { + 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 }) {
{view === 'member' && } {view === 'document' && } + {view === 'document' && (() => { + const doc = docs.find(item => item.docId === docId) + return doc ? ( + onCandidate(doc.docId, candidate, duty)} /> + ) : null + })()} {view === 'matrix' && }
{docCursor && } diff --git a/src/duties/draft.ts b/src/duties/draft.ts index eddf52b..20c1676 100644 --- a/src/duties/draft.ts +++ b/src/duties/draft.ts @@ -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, } } diff --git a/src/duties/views.tsx b/src/duties/views.tsx index cde818c..9b42568 100644 --- a/src/duties/views.tsx +++ b/src/duties/views.tsx @@ -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 + +/** 文档职责候选检索: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(null) + const [duty, setDuty] = useState('reviewer') + const known = useRef(new Map()) + 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 ( +
+

搜索候选人

+
+ + +
+
+ setPicked(known.current.get(ids[0] ?? '') ?? null)} + fetchPage={fetchKnown} + reloadKey={reloadKey} + disabled={disabled} + placeholder={mode === 'fuzzy' ? '输入至少两个字符搜索账号或姓名' : '输入完整账号或姓名精确搜索'} + emptyText="输入后显示当前有效成员" + /> + + +
+ {picked &&

{picked.title} · {picked.owner}{replacementNote(doc, picked.owner, duty) ? `(${replacementNote(doc, picked.owner, duty)})` : ''}

} +
+ ) +} + export function DutyMatrixView({ docs, members, disabled, narrow, onCell, }: { diff --git a/src/rdmManagement.ts b/src/rdmManagement.ts index 344f777..06ae571 100644 --- a/src/rdmManagement.ts +++ b/src/rdmManagement.ts @@ -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 + +/** 只保留本条职责实际涉及的人;为空不携带。 */ +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, 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 = { fuzzy: '模糊匹配', exact: '精确账号或姓名', } +export type SearchFieldsChoice = 'both' | 'owner' | 'name' +export const SEARCH_FIELDS: Record = { + 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) => Promise, + projectId: string, + opts: AccountSearchOptions & { joinedOwners?: () => Iterable }, +) { + 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 = []): PersonPickItem[] { +/** + * 候选映射(id=userId;无 userId 的行无法稳定提交,不提供)。无效账号可见但不可选; + * member:已加入(服务端 joined 或已在成员表)不可选;duty:只有当前成员可选。 + */ +export function accountsToPickItems( + accounts: RdmAccount[] | undefined, joinedOwners: Iterable = [], 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 } : {}), } }) } diff --git a/src/recovery.test.ts b/src/recovery.test.ts index 4b9c7cd..4337fd4 100644 --- a/src/recovery.test.ts +++ b/src/recovery.test.ts @@ -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('会话证明') diff --git a/src/recovery.ts b/src/recovery.ts index 6b60749..497637f 100644 --- a/src/recovery.ts +++ b/src/recovery.ts @@ -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 '无法发起:该账号没有可用的合格会话证明(需该用户当前在任一设备保持完整密码登录)。历史来源或已锁死账号请走服务器本地处置' diff --git a/test/account-users-panel.test.ts b/test/account-users-panel.test.ts new file mode 100644 index 0000000..4085e92 --- /dev/null +++ b/test/account-users-panel.test.ts @@ -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('未设置密码') + }) +}) diff --git a/test/duty-draft.test.ts b/test/duty-draft.test.ts index 7747784..0c00e78 100644 --- a/test/duty-draft.test.ts +++ b/test/duty-draft.test.ts @@ -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') }) }) diff --git a/test/loginpage.test.ts b/test/loginpage.test.ts index 1aa008c..afaa887 100644 --- a/test/loginpage.test.ts +++ b/test/loginpage.test.ts @@ -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('未知错误剥状态码前缀透传', () => { diff --git a/test/rdm-management.test.ts b/test/rdm-management.test.ts index 14cebfc..40462d8 100644 --- a/test/rdm-management.test.ts +++ b/test/rdm-management.test.ts @@ -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' }) diff --git a/test/rdm-members-search.test.ts b/test/rdm-members-search.test.ts index 50f4bba..a7a7448 100644 --- a/test/rdm-members-search.test.ts +++ b/test/rdm-members-search.test.ts @@ -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[] = [] + const run = async (params: Record) => { 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') })