From 731c9b7776155e4abb204c26158e1165594b65d4 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 04:54:05 +0000 Subject: [PATCH 01/17] Close account setup and recovery flows in the workbench Why: The old user panel accepted initial passwords, exposed administrator-issued recovery keys, and used owner-only candidate selections. Evidence: SELF receipts /tmp/iam-u3-frontend-normal-combo-build.json, /tmp/iam-u3-frontend-normal-combo-test.json, /tmp/iam-u3-browser-final.json, and /tmp/iam-u3-rdm-ui-browser-replay.json. Impact: Adds optional profiles, server-backed account search, effective-state controls, metadata-only proposals, owner confirmation and recovery, current-required password mode, and stable-ID RDM pickers. Verified with the Dev-pinned UI component SHA. Refs: IAM U3 r1 stages 4-5; Dev feature/iam-u3-account-directory-recovery --- src/api.ts | 42 ++++-- src/components/AccountUsersPanel.tsx | 209 +++++++++++++++++++++++++++ src/components/AdminZone.tsx | 2 +- src/components/LoginPage.tsx | 58 +++++--- src/components/MyAccountSettings.tsx | 133 +++++++++++++++++ src/components/SettingsModal.tsx | 9 +- src/components/adminRdmSections.tsx | 157 ++++++++++++-------- src/components/adminSections.tsx | 152 +------------------ src/passwords.ts | 11 -- src/rdmManagement.ts | 4 +- test/loginpage.test.ts | 6 +- 11 files changed, 526 insertions(+), 257 deletions(-) create mode 100644 src/components/AccountUsersPanel.tsx create mode 100644 src/components/MyAccountSettings.tsx delete mode 100644 src/passwords.ts diff --git a/src/api.ts b/src/api.ts index d8bb8f4..88da759 100644 --- a/src/api.ts +++ b/src/api.ts @@ -15,6 +15,13 @@ const BASE = '' * 自身命令面 fail-closed。 */ export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number } +export type PasswordChangeMode = 'grant_setup' | 'current_required' +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; initializationState: string; initializationGeneration: number } +export interface AdminUserRow { userId: string; owner: string; name: string; enabled: boolean; stateVersion: number; profileVersion: number; initializationState: string; initializationGeneration: number; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string } +export interface RecoveryProposal { id: string; userId: string; owner: string; status: 'pending' | 'confirmed' | 'completed' | 'revoked' | 'expired'; version: number; generation: number; createdBy: string; createdAt: string; expiresAt: string } +export interface AccountSearchPage { accounts: AccountRecord[]; nextCursor?: string; directoryVersion: string } + async function checked(res: Response): Promise { if (!res.ok) { const text = await res.text() @@ -48,19 +55,19 @@ export const api = { projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json(r)), /** 单页权威快照(含 identity + spec),供列表水合与预览渲染。 */ surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json(r)), - me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; sessionScope?: string; isAdmin?: boolean; mustChangePassword?: boolean; passwordSet?: boolean }>(r)), + me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; sessionScope?: string; isAdmin?: boolean; mustChangePassword?: boolean; passwordSet?: boolean; passwordChangeMode?: PasswordChangeMode }>(r)), // ---- 认证与会话(管理员发放制,R2)---- login: (owner: string, password: string) => auth.fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, password }) }).then(async r => { if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`) - const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string } + const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string; passwordChangeMode?: PasswordChangeMode } auth.beginSession() return me }), // 一次性登录 Key 兑换(恢复/激活):请求只带 Key,身份由服务端绑定记录决定; // 成功建立 set_password_only 受限会话(HttpOnly cookie),明文 Key 不落任何存储。 - redeemLoginKey: (key: string) => auth.fetch(`${BASE}/api/auth/login-key/redeem`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }) }).then(async r => { + redeemLoginKey: (key: string) => auth.fetch(`${BASE}/api/auth/recovery-grants/redeem`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }) }).then(async r => { if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`) - const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string } + const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string; passwordChangeMode?: PasswordChangeMode } auth.beginSession() return me }), @@ -73,12 +80,27 @@ export const api = { 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 }> }), - listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }[]>(r)), - 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, name, password, isAdmin }) }).then(r => json<{ owner: string; mustChangePassword: boolean }>(r)), - // ---- 管理员:一次性登录 Key(忘记密码恢复主入口;旧"直接重置密码"接口退出常规 UI)---- - adminIssueLoginKey: (owner: string, ttlMinutes?: number) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ purpose: 'recover', ...(ttlMinutes ? { ttlMinutes } : {}) }) }).then(r => json<{ id: string; owner: string; purpose: string; status: string; expiresAt: string; key: string }>(r)), - adminListLoginKeys: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`).then(r => json<{ keys: { id: string; owner: string; purpose: string; status: string; expiresAt: string; createdAt: string; createdBy?: string }[] }>(r)).then(d => d.keys ?? []), - adminRevokeLoginKey: (owner: string, id: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys/${encodeURIComponent(id)}`, { method: 'DELETE' }).then(r => json<{ ok: boolean }>(r)), + listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json(r)), + searchAdminUsers: (request: { query: string; mode: 'exact' | 'fuzzy'; status: 'all' | 'enabled' | 'disabled'; fields: string[]; cursor?: string; limit?: number }) => { + const params = new URLSearchParams({ query: request.query, mode: request.mode, status: request.status, + fields: request.fields.join(','), limit: String(request.limit ?? 20) }) + if (request.cursor) params.set('cursor', request.cursor) + return auth.fetch(`${BASE}/api/admin/users/search?${params}`).then(r => json(r)) + }, + createAdminUser: (owner: string, profile: Partial) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile }) }).then(r => json(r)), + adminGetUser: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}`).then(r => json(r)), + adminPatchProfile: (userId: string, expectedProfileVersion: number, patch: Partial) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/profile`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedProfileVersion, patch }) }).then(r => json(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(r)), + meProfile: () => auth.fetch(`${BASE}/api/me/profile`).then(r => json(r)), + patchMeProfile: (expectedProfileVersion: number, patch: Partial) => auth.fetch(`${BASE}/api/me/profile`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedProfileVersion, patch }) }).then(r => json(r)), + createRecoveryProposal: (userId: string, expectedStateVersion: number, idempotencyKey: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/recovery-proposals`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedStateVersion, idempotencyKey }) }).then(r => json(r)), + listAdminRecoveryProposals: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/recovery-proposals`).then(r => json<{ proposals: RecoveryProposal[] }>(r)).then(d => d.proposals ?? []), + adminGetRecoveryProposal: (userId: string, id: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/recovery-proposals/${encodeURIComponent(id)}`).then(r => json(r)), + adminRevokeRecoveryProposal: (userId: string, id: string, expectedVersion: number) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/recovery-proposals/${encodeURIComponent(id)}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedVersion }) }).then(r => json(r)), + listMyRecoveryProposals: () => auth.fetch(`${BASE}/api/me/recovery-proposals`).then(r => json<{ proposals: RecoveryProposal[] }>(r)).then(d => d.proposals ?? []), + getMyRecoveryProposal: (id: string) => auth.fetch(`${BASE}/api/me/recovery-proposals/${encodeURIComponent(id)}`).then(r => json(r)), + confirmMyRecoveryProposal: (id: string, expectedVersion: number) => auth.fetch(`${BASE}/api/auth/recovery-proposals/${encodeURIComponent(id)}/confirm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedVersion }) }).then(r => json<{ proposal: RecoveryProposal; grant: string }>(r)), + rotateMyRecoveryGrant: (id: string, expectedVersion: number, expectedGeneration: number) => auth.fetch(`${BASE}/api/auth/recovery-proposals/${encodeURIComponent(id)}/rotate-grant`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedVersion, expectedGeneration }) }).then(r => json<{ proposal: RecoveryProposal; grant: string }>(r)), createConversation: (projectId: string, title?: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title ?? '' }) }).then(r => json(r)), deleteConversation: (id: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'DELETE' }), setConversationProfile: (id: string, profileId: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profileId }) }).then(r => json(r)), diff --git a/src/components/AccountUsersPanel.tsx b/src/components/AccountUsersPanel.tsx new file mode 100644 index 0000000..80a3ef6 --- /dev/null +++ b/src/components/AccountUsersPanel.tsx @@ -0,0 +1,209 @@ +import { useEffect, useRef, useState } from 'react' +import { api, type AccountProfile, type AccountRecord, type RecoveryProposal } from '../api' +import { useStore } from '../store' +import { Collapsible, Icon } from '@ui' + +const emptyProfile = (): AccountProfile => ({ name: '', phone: '', email: '', staffNo: '', department: '', notes: '' }) +const profileFields: { key: keyof AccountProfile; label: string }[] = [ + { key: 'name', label: '姓名' }, { key: 'phone', label: '电话' }, { key: 'email', label: '邮箱' }, + { key: 'staffNo', label: '工号' }, { key: 'department', label: '部门' }, { key: 'notes', label: '备注' }, +] + +function ProfileInputs({ value, onChange, prefix }: { value: AccountProfile; onChange: (next: AccountProfile) => void; prefix: string }) { + return
+ {profileFields.map(field =>
+ + onChange({ ...value, [field.key]: event.target.value })} /> +
)} +
+} + +function recoveryStatus(status: RecoveryProposal['status']): string { + return ({ pending: '等待本人确认', confirmed: '本人已确认', completed: '已完成', revoked: '已撤销', expired: '已过期' })[status] +} + +export function UsersPanel() { + const me = useStore(s => s.me) + const [users, setUsers] = useState(null) + const [selectedId, setSelectedId] = useState('') + const [detail, setDetail] = useState(null) + const [proposals, setProposals] = useState(null) + const [createOwner, setCreateOwner] = useState('') + const [createProfile, setCreateProfile] = useState(emptyProfile) + const [draft, setDraft] = useState(emptyProfile) + const [search, setSearch] = useState('') + const [searchMode, setSearchMode] = useState<'exact' | 'fuzzy'>('fuzzy') + const [searchField, setSearchField] = useState('default') + const [statusFilter, setStatusFilter] = useState<'all' | 'enabled' | 'disabled'>('all') + const [cursor, setCursor] = useState('') + const [cursorTrail, setCursorTrail] = useState([]) + const [nextCursor, setNextCursor] = useState('') + const [busy, setBusy] = useState(false) + const [err, setErr] = useState('') + const [notice, setNotice] = useState('') + const requestSeq = useRef(0) + const searchSeq = useRef(0) + const searchRequest = () => ({ query: search.trim(), mode: searchMode, status: statusFilter, + fields: searchField === 'default' ? ['owner', 'name', 'userId'] : [searchField], cursor, limit: 20 }) + + const refresh = async () => { + const seq = ++searchSeq.current + const page = await api.searchAdminUsers(searchRequest()) + if (seq === searchSeq.current) { setUsers(page.accounts); setNextCursor(page.nextCursor ?? '') } + return page + } + useEffect(() => { + const seq = ++searchSeq.current + const timer = window.setTimeout(() => { + api.searchAdminUsers(searchRequest()).then(page => { + if (seq === searchSeq.current) { setUsers(page.accounts); setNextCursor(page.nextCursor ?? '') } + }).catch(error => { if (seq === searchSeq.current) setErr(String(error)) }) + }, 180) + return () => { window.clearTimeout(timer); searchSeq.current++; requestSeq.current++ } + }, [search, searchMode, searchField, statusFilter, cursor]) + + const openAccount = async (userId: string) => { + const seq = ++requestSeq.current + setSelectedId(userId); setDetail(null); setProposals(null); setErr(''); setNotice('') + try { + const [account, statuses] = await Promise.all([api.adminGetUser(userId), api.listAdminRecoveryProposals(userId)]) + if (seq !== requestSeq.current) return + setDetail(account); setDraft(account.profile); setProposals(statuses) + } catch (error) { if (seq === requestSeq.current) setErr(String(error)) } + } + + const create = async () => { + setErr(''); setNotice(''); setBusy(true) + try { + const account = await api.createAdminUser(createOwner.trim(), createProfile) + setCreateOwner(''); setCreateProfile(emptyProfile()) + setNotice(`已创建 ${account.owner}。等待可信 owner 按 userId 初始化首次凭据。`) + await refresh() + await openAccount(account.userId) + setNotice(`已创建 ${account.owner}。等待可信 owner 按 userId 初始化首次凭据。`) + } catch (error) { setErr(String(error).includes('409') ? '用户名已存在' : String(error)) } + finally { setBusy(false) } + } + + const saveProfile = async () => { + if (!detail) return + const patch: Partial = {} + for (const field of profileFields) if (draft[field.key] !== detail.profile[field.key]) patch[field.key] = draft[field.key] + if (Object.keys(patch).length === 0) { setNotice('资料没有变化'); return } + setErr(''); setNotice(''); setBusy(true) + try { + const updated = await api.adminPatchProfile(detail.userId, detail.profileVersion, patch) + setDetail(updated); setDraft(updated.profile); await refresh(); setNotice('资料已保存') + } catch (error) { setErr(String(error).includes('409') ? '资料已被其他操作修改,请重新打开账号' : String(error)) } + finally { setBusy(false) } + } + + const setEnabled = async () => { + if (!detail) return + setErr(''); setNotice(''); setBusy(true) + try { + const updated = await api.adminSetUserEnabled(detail.userId, detail.stateVersion, !detail.enabled) + setDetail(updated); await refresh(); setNotice(updated.enabled ? '账号已启用;旧会话和 Key 不会恢复' : '账号已停用,旧会话和 Key 已撤销') + } catch (error) { setErr(String(error).includes('409') ? '状态版本或最后管理员保护发生冲突,请重新打开账号' : String(error)) } + finally { setBusy(false) } + } + + const propose = async () => { + if (!detail) return + setErr(''); setNotice(''); setBusy(true) + try { + const idempotencyKey = globalThis.crypto?.randomUUID?.() ?? `proposal-${Date.now()}` + await api.createRecoveryProposal(detail.userId, detail.stateVersion, idempotencyKey) + setProposals(await api.listAdminRecoveryProposals(detail.userId)) + setNotice('恢复提案已建立。只有目标本人提案前的合格密码会话可以确认;管理员不会收到设密能力。') + } catch (error) { + const message = String(error) + setErr(message.includes('OWNER_PROOF_UNAVAILABLE') ? '目标没有合格的旧本人会话,当前无法在线恢复;异常恢复需另行获批的可信 owner 流程。' : message) + } finally { setBusy(false) } + } + + const revoke = async (proposal: RecoveryProposal) => { + if (!detail) return + setErr(''); setNotice(''); setBusy(true) + try { + await api.adminRevokeRecoveryProposal(detail.userId, proposal.id, proposal.version) + setProposals(await api.listAdminRecoveryProposals(detail.userId)) + setNotice('提案已撤销') + } catch (error) { setErr(String(error)) } finally { setBusy(false) } + } + + if (!me?.isAdmin) return null + return
+
+
+ setCreateOwner(event.target.value)} /> + +
+ + + +

网页建号不设置密码或管理员身份。新账号由可信 owner 按稳定 userId 完成首次初始化。

+
+ {notice &&
{notice}
} + {err &&
{err}
} + {selectedId ?
+

账号详情

+
+ + {detail?.owner ?? '加载账号…'} + {selectedId} +
+ {detail && <> +

状态:{detail.enabled ? '有效' : '停用'} · {detail.initializationState === 'pending' ? '待可信 owner 初始化' : detail.passwordSet ? '已设置密码' : '未设置密码'} · 资料版本 {detail.profileVersion}

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

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

} + + + + +
+ + +
+

停用会撤销现有会话与 Key。恢复提案只返回状态;目标本人必须使用提案前已有的可信完整密码会话确认。

+

恢复提案

+ {(proposals ?? []).map(proposal =>
+ {proposal.id} + {recoveryStatus(proposal.status)} + 到期 {new Date(proposal.expiresAt).toLocaleString()} + {(proposal.status === 'pending' || proposal.status === 'confirmed') && } +
)} + } +
: <> +
+ { setSearch(event.target.value); setCursor(''); setCursorTrail([]) }} /> + + + +
+ + {(users ?? []).map(user => + + + + + )} + {users !== null && users.length === 0 && } + {users === null && } +
登录名姓名系统身份状态首次凭据
{user.owner}{user.profile.name || '—'}{user.isAdmin ? '系统管理员' : '普通账号'}{user.enabled ? '有效' : '停用'}{user.initializationState === 'pending' ? '待可信 owner 初始化' : user.passwordSet ? '已设置' : '未设置'}
没有匹配账号
加载中…
+
+ + +
+ } +
+} diff --git a/src/components/AdminZone.tsx b/src/components/AdminZone.tsx index ae9762a..c218bb4 100644 --- a/src/components/AdminZone.tsx +++ b/src/components/AdminZone.tsx @@ -27,7 +27,7 @@ function ProfilesPanelWrapper() { } const SECTIONS: AdminSectionDef[] = [ - { key: 'users', label: '用户管理', icon: 'users', desc: '建号、发放初始密码、重置密码(管理员发放制)', render: () => }, + { key: 'users', label: '用户管理', icon: 'users', desc: '无凭据建号、可选资料、账号有效态与本人恢复提案', render: () => }, // U6/KTD5:RDM 专业角色管理——命令面齐备(rdmRolesAvailable)才显示;普通基座零暴露。 { key: 'rdm-roles', label: 'RDM 角色权限', icon: 'shield', desc: '专业角色:动作默认/上限、账号绑定、停用(仅管理员)', render: () => }, { key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', render: () => }, diff --git a/src/components/LoginPage.tsx b/src/components/LoginPage.tsx index a867858..b0b3927 100644 --- a/src/components/LoginPage.tsx +++ b/src/components/LoginPage.tsx @@ -1,8 +1,6 @@ -// 登录与改密(管理员发放制,R2 + 2026-10-13 登录恢复合同): -// LoginPage —— 用户名+密码(默认入口)/ 一次性登录 Key(忘记密码恢复,只输 Key); -// ChangePasswordGate —— 受限会话强制设密(恢复/首登同形:只输新密码,完成前不放行)。 -import { useState } from 'react' -import { api } from '../api' +// 登录与设密:普通密码登录、可信 owner 激活/本人确认恢复 grant。 +import { useEffect, useState } from 'react' +import { api, type PasswordChangeMode } from '../api' import { Icon, BrandMark } from '@ui' function Err({ msg }: { msg: string }) { @@ -18,7 +16,7 @@ function BrandPanel() {

文档、决策与知识的沉淀、检索与生成——面向团队的自托管智能体工作台。

  • 项目化多任务工作区,收件箱驱动待办
  • -
  • 管理员发放账号,密码策略与密钥分级
  • +
  • 组织管理账号,密码策略与密钥分级
  • 知识库混合检索(全文 + 向量)
  • 文档生成流水线与工件管理
@@ -76,27 +74,27 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
{ e.preventDefault(); tab === 'password' ? submitPassword() : submitKey() }}>
Agent-System

登录

-

账号由管理员发放;忘记密码请联系管理员签发一次性登录 Key

+

账号由组织创建;首次凭据由可信 owner 初始化。忘记密码可查看本人恢复提案。

- +
{tab === 'password' ? ( <> setOwner(e.target.value)} autoFocus autoComplete="username" placeholder="由管理员分配" /> - setPassword(e.target.value)} autoComplete="current-password" placeholder="初始密码或常用密码" /> + setPassword(e.target.value)} autoComplete="current-password" placeholder="账号密码" /> ) : ( <> - + setKey(e.target.value)} autoFocus placeholder="alk_…" /> -

忘记密码时由管理员签发;单次有效、默认 30 分钟内兑换。程序化客户端请继续使用 API 密钥(Authorization 头)。

+

新账号激活由可信 owner 交付;恢复能力由本人使用提案前的合格会话确认后获得。管理员不能代领。程序化客户端可使用独立 API 密钥。

)} @@ -105,23 +103,39 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on ) } -/** 强制设密门:恢复 Key 兑换或初始密码首登建立的受限会话,必须完成设密。 - * 免当前密码(R5):身份与授权均由服务端受限会话确认,页面不出现当前密码输入框; - * 完成前不能进入工作台,完成后的唯一出口是回密码登录页。 */ +/** 设密门由服务端 passwordChangeMode 决定是否必须输入当前密码。 */ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string; onDone: () => void; onCancel?: () => void }) { + const [mode, setMode] = useState(null) + const [current, setCurrent] = useState('') const [next, setNext] = useState('') const [confirm, setConfirm] = useState('') const [err, setErr] = useState('') const [busy, setBusy] = useState(false) const [done, setDone] = useState(false) + useEffect(() => { + let active = true + api.me().then(me => { + if (!active) return + if (me.owner !== owner || me.sessionScope !== 'set_password_only' || + (me.passwordChangeMode !== 'grant_setup' && me.passwordChangeMode !== 'current_required')) { + setErr('当前设密凭据不再有效,请重新登录或由本人核对恢复提案状态。') + return + } + setMode(me.passwordChangeMode) + }).catch(() => { if (active) setErr('无法确认当前设密方式,请重新登录。') }) + return () => { active = false } + }, [owner]) + const submit = async () => { setErr('') + if (!mode) { setErr('设密方式尚未由服务端确认'); return } + if (mode === 'current_required' && !current) { setErr('请填写当前密码'); return } if (next !== confirm) { setErr('两次输入的新密码不一致'); return } if (next.length < 8) { setErr('新密码至少 8 位'); return } setBusy(true) try { - await api.changePassword('', next) + await api.changePassword(mode === 'current_required' ? current : '', next) setDone(true) } catch (e) { setErr(humanChangePasswordError(String(e))) @@ -143,13 +157,19 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
{ e.preventDefault(); submit() }}>
Agent-System

设置新密码

-

账号 {owner}(服务端确认)须设置新密码后继续;无需输入当前密码

+

账号 {owner}(服务端确认)须设置新密码后继续。

+ {mode === 'current_required' && <> + + setCurrent(event.target.value)} autoComplete="current-password" /> + } + {mode === 'grant_setup' &&

可信设密能力已验证,此次无需当前密码。

} setNext(e.target.value)} autoFocus autoComplete="new-password" /> setConfirm(e.target.value)} autoComplete="new-password" /> - + {onCancel && } )} @@ -168,9 +188,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/MyAccountSettings.tsx b/src/components/MyAccountSettings.tsx new file mode 100644 index 0000000..f9bbe2e --- /dev/null +++ b/src/components/MyAccountSettings.tsx @@ -0,0 +1,133 @@ +import { useEffect, useRef, useState } from 'react' +import { api, type AccountProfile, type AccountRecord, type RecoveryProposal } from '../api' +import { useStore } from '../store' +import { Collapsible, Icon, SettingsGroup } from '@ui' + +const fields: { key: keyof AccountProfile; label: string }[] = [ + { key: 'name', label: '姓名' }, { key: 'phone', label: '电话' }, { key: 'email', label: '邮箱' }, + { key: 'staffNo', label: '工号' }, { key: 'department', label: '部门' }, { key: 'notes', label: '备注' }, +] + +const statusLabel: Record = { + pending: '等待本人确认', confirmed: '已确认,等待设密', completed: '已完成', revoked: '已撤销', expired: '已过期', +} + +export function MyAccountSettings() { + const me = useStore(s => s.me) + const [account, setAccount] = useState(null) + const [draft, setDraft] = useState(null) + const [proposals, setProposals] = useState(null) + const [grant, setGrant] = useState<{ proposalId: string; value: string } | null>(null) + const [err, setErr] = useState('') + const [notice, setNotice] = useState('') + const [busy, setBusy] = useState(false) + const sequence = useRef(0) + + useEffect(() => { + if (!me?.auth) return + const seq = ++sequence.current + Promise.all([api.meProfile(), api.listMyRecoveryProposals()]).then(([profile, statuses]) => { + if (seq !== sequence.current) return + setAccount(profile); setDraft(profile.profile); setProposals(statuses) + }).catch(error => { if (seq === sequence.current) setErr(String(error)) }) + return () => { sequence.current++ } + }, [me?.owner, me?.auth]) + + if (!me?.auth) return null + + const save = async () => { + if (!account || !draft) return + const patch: Partial = {} + for (const field of fields) if (draft[field.key] !== account.profile[field.key]) patch[field.key] = draft[field.key] + if (Object.keys(patch).length === 0) { setNotice('资料没有变化'); return } + setBusy(true); setErr(''); setNotice('') + try { + const updated = await api.patchMeProfile(account.profileVersion, patch) + setAccount(updated); setDraft(updated.profile); setNotice('个人资料已保存;资料不会成为恢复身份的证明') + } catch (error) { setErr(String(error).includes('409') ? '资料版本已变化,请关闭设置后重新打开' : String(error)) } + finally { setBusy(false) } + } + + const refreshProposal = async (id: string) => { + const updated = await api.getMyRecoveryProposal(id) + setProposals(current => current?.map(item => item.id === id ? updated : item) ?? [updated]) + return updated + } + + const confirm = async (proposal: RecoveryProposal) => { + setBusy(true); setErr(''); setNotice(''); setGrant(null) + try { + const result = await api.confirmMyRecoveryProposal(proposal.id, proposal.version) + setProposals(current => current?.map(item => item.id === proposal.id ? result.proposal : item) ?? [result.proposal]) + setGrant({ proposalId: proposal.id, value: result.grant }) + setNotice('本人确认成功。设密能力只在此响应可用,请立即兑换并设置新密码。') + } catch (error) { + const current = await refreshProposal(proposal.id).catch(() => null) + setErr(current?.status === 'confirmed' + ? '确认结果待核验:提案已确认,但明文不能读回。若仍保有原本人会话,可显式重新生成设密能力。' + : `确认未完成或结果未知:${String(error)}。请核对当前提案状态。`) + } finally { setBusy(false) } + } + + const rotate = async (proposal: RecoveryProposal) => { + setBusy(true); setErr(''); setNotice(''); setGrant(null) + try { + const result = await api.rotateMyRecoveryGrant(proposal.id, proposal.version, proposal.generation) + setProposals(current => current?.map(item => item.id === proposal.id ? result.proposal : item) ?? [result.proposal]) + setGrant({ proposalId: proposal.id, value: result.grant }) + setNotice('已重新生成设密能力;旧能力与派生受限会话已失效。') + } catch (error) { + await refreshProposal(proposal.id).catch(() => null) + setErr(`重新生成结果待核验:${String(error)}。请核对提案状态,再由本人显式操作。`) + } finally { setBusy(false) } + } + + const redeem = async () => { + if (!grant) return + setBusy(true); setErr(''); setNotice('') + try { + await api.redeemLoginKey(grant.value) + location.reload() // 新 HttpOnly 受限会话由应用启动门送到设密页 + } catch (error) { + const session = await api.me().catch(() => null) + if (session?.sessionScope === 'set_password_only' && session.passwordChangeMode === 'grant_setup') { + location.reload() + return + } + await refreshProposal(grant.proposalId).catch(() => null) + setGrant(null) + setErr(`兑换结果待核验:${String(error)}。不会自动重复兑换;若仍保有原本人会话,请显式重新生成能力。`) + } finally { setBusy(false) } + } + + return + {account && draft ? <> +

登录名 {account.owner} · userId {account.userId}。以下资料均可留空,也不会作为本人恢复证明。

+ +
{fields.map(field =>
+ + setDraft({ ...draft, [field.key]: event.target.value })} /> +
)} +
+
+ :

使用完整密码会话后可查看和修改个人资料。

} + +

管理员只能建立恢复提案。本人确认必须使用提案之前已存在、来源可信的完整密码会话;历史未知来源账号无法在线证明。

+ {(proposals ?? []).map(proposal =>
+
{proposal.id}{statusLabel[proposal.status]} + 到期 {new Date(proposal.expiresAt).toLocaleString()}
+
+ {proposal.status === 'pending' && } + {proposal.status === 'confirmed' && } +
+
)} + {proposals !== null && proposals.length === 0 &&

当前没有恢复提案。

} + {grant &&
+

设密能力仅保存在当前页面内存,离开后不可读回;不自动复制到剪贴板。

+ +
} + {notice &&
{notice}
} + {err &&
{err}
} +
+} diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 51ca5d1..0d8747a 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -9,8 +9,9 @@ import type { ThemeChoice } from '../theme' import type { SettingDesc } from '../contracts' import { SettingsShell, SettingRow, SettingsGroup, Btn } from '@ui' import { BorrowPanels } from './BorrowPanels' +import { MyAccountSettings } from './MyAccountSettings' -type Section = 'settings' +type Section = 'settings' | 'account' // 分组显示名(项目设置面板共用)。 export const groupNames: Record = { @@ -19,7 +20,7 @@ export const groupNames: Record = { } export function SettingsModal() { - const [section] = useState
('settings') + const [section, setSection] = useState
('settings') const [schema, setSchema] = useState([]) const [, setUser] = useState>({}) const [draft, setDraft] = useState>({}) @@ -54,10 +55,11 @@ export function SettingsModal() { return ( undefined} + title="设置" active={section} onSelect={key => setSection(key as Section)} onClose={() => store.openSettings(false)} sections={[ { key: 'settings', label: '常规', icon: 'settings' }, + { key: 'account', label: '我的账号', icon: 'user' }, ]} footer={saved ? ✓ 已保存 : 'Agent-System · 设置中心'} actions={section === 'settings' && schema.length > 0 ? ( @@ -108,6 +110,7 @@ export function SettingsModal() { ) })} {section === 'settings' && } + {section === 'account' && } ) } diff --git a/src/components/adminRdmSections.tsx b/src/components/adminRdmSections.tsx index b8abad1..29d6c2c 100644 --- a/src/components/adminRdmSections.tsx +++ b/src/components/adminRdmSections.tsx @@ -163,29 +163,28 @@ export function RdmMembersSection({ project }: { project: Project }) { const [busy, setBusy] = useState(false) const [picked, setPicked] = useState([]) const [role, setRole] = useState<'member' | 'owner'>('member') + const [searchMode, setSearchMode] = useState<'exact' | 'fuzzy'>('fuzzy') + const candidatesById = useRef(new Map()) // 成员表经 ref 参与取数(已加入标记),保持 fetchPage 身份稳定(Picker 依赖)。 const rowsRef = useRef(null) rowsRef.current = rows - /** 人员选择器取数适配(P7-A):rdm.account.list 目录(member.manage 生效者)。 - * query 透传后端(基座升级 AccountSearcher 后全量生效);旧基座忽略 query 时 - * 对首页做本地包含匹配降级(翻页为浏览模式,不过滤)。 */ + /** 服务端在 member.manage 当前范围内匹配、排序和分页;选择稳定 userId。 */ const fetchPage = useCallback(async (query: string, cursor: string, limit: number) => { - const out = await cmd<{ accounts?: { owner: string; name?: string; valid: boolean }[]; next?: string }>( - 'rdm.account.list', { projectId, cursor, limit, query }) + const out = await cmd<{ accounts?: { userId: string; owner: string; name?: string; valid: boolean }[]; next?: string; directoryVersion?: string }>( + 'rdm.account.list', { projectId, cursor, limit, query, mode: searchMode, searchFields: ['owner', 'name'] }) const joined = new Set((rowsRef.current ?? []).map(m => m.owner)) - let items = (out.accounts ?? []).filter(a => a.valid).map(a => ({ - id: a.owner, + const items = (out.accounts ?? []).filter(a => a.valid && a.userId).map(a => { + candidatesById.current.set(a.userId, { userId: a.userId, owner: a.owner }) + return { + id: a.userId, title: a.name || a.owner, - subtitle: a.owner, + subtitle: `${a.owner} · ${a.userId}`, badge: joined.has(a.owner) ? '已加入' : undefined, - })) - if (query !== '' && cursor === '') { - const q = query.toLowerCase() - items = items.filter(it => it.id.toLowerCase().includes(q) || it.title.toLowerCase().includes(q)) - } + } + }) return { items, nextCursor: out.next || undefined } - }, [project]) + }, [project, searchMode]) const refresh = async () => { try { @@ -193,7 +192,7 @@ export function RdmMembersSection({ project }: { project: Project }) { setRows(out.members ?? []) } catch (e) { setErr(String(e)) } } - useEffect(() => { setPicked([]); refresh() }, [project]) + useEffect(() => { setPicked([]); candidatesById.current.clear(); refresh() }, [project]) const run = async (label: string, fn: () => Promise) => { setErr(''); setNotice(''); setBusy(true) @@ -202,6 +201,7 @@ export function RdmMembersSection({ project }: { project: Project }) { } const pickedId = picked[0] ?? '' + const pickedAccount = candidatesById.current.get(pickedId) return (
@@ -209,19 +209,24 @@ export function RdmMembersSection({ project }: { project: Project }) {
+ -
@@ -286,27 +291,12 @@ interface DutyGetOut { } /** 职责候选 = 当前项目成员(方案 §4:候选只能来自项目成员)。 - * member.list 为小全量列表:本地 query 过滤 + 游标分页。 */ -function useMemberPickerPage(members: { owner: string; role: string }[] | null) { - return useCallback(async (query: string, cursor: string, limit: number) => { - const q = query.trim().toLowerCase() - const all = (members ?? []) - .filter(m => q === '' || m.owner.toLowerCase().includes(q)) - .map(m => ({ id: m.owner, title: m.owner, subtitle: m.role === 'owner' ? '项目负责人' : '项目成员' })) - const start = cursor === '' ? 0 : all.findIndex(x => x.id === cursor) + 1 - const page = all.slice(start, start + limit) - return { - items: page, - nextCursor: start + limit < all.length ? page[page.length - 1]?.id : undefined, - } - }, [members]) -} + * 账号候选改由 Backend 的受控文档目录提供者按当前职责权限与成员范围查询。 */ export function RdmDutiesSection({ project }: { project: Project }) { const projectId = project.id const writeError = projectWorkError(project) const [docs, setDocs] = useState(null) - const [members, setMembers] = useState<{ owner: string; role: string }[] | null>(null) const [docId, setDocId] = useState('') const [detail, setDetail] = useState(undefined) const [author, setAuthor] = useState([]) @@ -317,12 +307,31 @@ export function RdmDutiesSection({ project }: { project: Project }) { const [batchAuthor, setBatchAuthor] = useState([]) const [batchReviewers, setBatchReviewers] = useState([]) const [batchApprover, setBatchApprover] = useState([]) + const [dutySearchMode, setDutySearchMode] = useState<'exact' | 'fuzzy'>('fuzzy') const [err, setErr] = useState('') const [notice, setNotice] = useState('') const [busy, setBusy] = useState(false) const cmd = (id: string, params: Record) => api.rdmProjectCommand(project, id, params) - const memberPage = useMemberPickerPage(members) + const candidateOwners = useRef(new Map()) + const ownerOf = (userId: string) => candidateOwners.current.get(userId) ?? '' + const ownersOf = (ids: string[]) => ids.map(ownerOf) + const selectedIDs = (ids: string[]) => Object.fromEntries(ids.map(id => [ownerOf(id), id]).filter(([owner]) => owner)) + const fetchDutyPage = useCallback(async (locator: string, query: string, cursor: string, limit: number) => { + if (!locator || !query.trim()) return { items: [], nextCursor: undefined } + const out = await api.rdmProjectCommand<{ accounts?: { userId: string; owner: string; name?: string; valid: boolean }[]; next?: string }>( + project, 'rdm.account.list', { projectId, docId: locator, query, cursor, limit, + mode: dutySearchMode, searchFields: ['owner', 'name'] }) + const items = (out.accounts ?? []).filter(account => account.valid && account.userId).map(account => { + candidateOwners.current.set(account.userId, account.owner) + return { id: account.userId, title: account.name || account.owner, subtitle: `${account.owner} · ${account.userId}` } + }) + return { items, nextCursor: out.next || undefined } + }, [project, projectId, dutySearchMode]) + const dutyPage = useCallback((query: string, cursor: string, limit: number) => + fetchDutyPage(docId, query, cursor, limit), [fetchDutyPage, docId]) + const batchDutyPage = useCallback((query: string, cursor: string, limit: number) => + fetchDutyPage([...batchSel][0] ?? '', query, cursor, limit), [fetchDutyPage, batchSel]) const requests = useRef({ project, epoch: 0, selected: '', selection: 0, catalog: 0, status: 0, action: 0 }) requests.current.project = project const currentProject = (epoch: number) => requests.current.project === project && requests.current.epoch === epoch @@ -352,6 +361,15 @@ export function RdmDutiesSection({ project }: { project: Project }) { setSub(undefined); setErr(`整文状态读取失败:${e}`) } } + const resolveCurrentOwner = async (document: string, owner: string): Promise => { + if (!owner) return '' + const out = await cmd<{ accounts?: { userId: string; owner: string; valid: boolean }[] }>('rdm.account.list', + { projectId, docId: document, query: owner, mode: 'exact', searchFields: ['owner'], limit: 2 }) + const match = (out.accounts ?? []).find(account => account.owner === owner && account.valid && account.userId) + if (!match) return '' + candidateOwners.current.set(match.userId, owner) + return match.userId + } /** 选中文档:读当前职责(duty.get)并回填选择器(方案:当前职责与冻结名单分列)。 */ const selectDoc = async (id: string, keepNotice = false) => { const epoch = requests.current.epoch @@ -361,15 +379,26 @@ export function RdmDutiesSection({ project }: { project: Project }) { setErr(''); if (!keepNotice) setNotice('') setDocId(id); setDetail(undefined); setSub(undefined); setBatchSel(new Set()) setAuthor([]); setReviewers([]); setApprover([]) + setBatchAuthor([]); setBatchReviewers([]); setBatchApprover([]); candidateOwners.current.clear() if (!id) return try { const out = await cmd('rdm.duty.get', { projectId, docId: id }) if (!currentDocument(epoch, selection, id)) return if (out.docId !== id) throw new Error('文档职责与当前选择不一致,请重新选择文档') setDetail(out) - setAuthor(out.assigned && out.roles.author ? [out.roles.author] : []) - setReviewers(out.assigned ? out.roles.reviewers ?? [] : []) - setApprover(out.assigned && out.roles.approver ? [out.roles.approver] : []) + if (out.assigned) { + try { + const names = [out.roles.author, ...(out.roles.reviewers ?? []), out.roles.approver] + const ids = await Promise.all(names.map(owner => resolveCurrentOwner(id, owner))) + if (!currentDocument(epoch, selection, id)) return + setAuthor(ids[0] ? [ids[0]] : []) + setReviewers(ids.slice(1, -1).filter(Boolean)) + setApprover(ids[ids.length - 1] ? [ids[ids.length - 1]] : []) + if (ids.some(identity => !identity)) setErr('现有职责包含失效或未授权账号;请重新搜索并选择有效成员') + } catch (error) { + if (currentDocument(epoch, selection, id)) setErr(`账号目录查验失败,当前职责不可编辑:${String(error)}`) + } + } } catch (e) { if (!currentDocument(epoch, selection, id)) return setDetail(null); setErr(String(e)) @@ -381,13 +410,10 @@ export function RdmDutiesSection({ project }: { project: Project }) { requests.current.selected = ''; requests.current.selection++; requests.current.status++ requests.current.action++ setDocId(''); setDetail(undefined); setSub(undefined); setBatchSel(new Set()) - setDocs(null); setMembers(null); setAuthor([]); setReviewers([]); setApprover([]) + setDocs(null); setAuthor([]); setReviewers([]); setApprover([]); candidateOwners.current.clear() setBatchAuthor([]); setBatchReviewers([]); setBatchApprover([]) setErr(''); setNotice(''); setBusy(false) void refreshCatalog(epoch) - cmd<{ members: { owner: string; role: string }[] }>('rdm.project.member.list', { projectId }) - .then(out => { if (currentProject(epoch)) setMembers(out.members ?? []) }) - .catch(() => { if (currentProject(epoch)) setMembers([]) }) return () => { requests.current.epoch++; requests.current.selection++; requests.current.status++ requests.current.catalog++; requests.current.action++; requests.current.selected = '' @@ -412,24 +438,30 @@ export function RdmDutiesSection({ project }: { project: Project }) { } finally { if (currentAction()) setBusy(false) } } + const authorOwner = ownerOf(author[0] ?? '') + const reviewerOwners = ownersOf(reviewers) + const approverOwner = ownerOf(approver[0] ?? '') const dirty = detail != null && ( - (author[0] ?? '') !== (detail.assigned ? detail.roles.author : '') - || JSON.stringify(reviewers) !== JSON.stringify(detail.assigned ? detail.roles.reviewers ?? [] : []) - || (approver[0] ?? '') !== (detail.assigned ? detail.roles.approver : '')) + authorOwner !== (detail.assigned ? detail.roles.author : '') + || JSON.stringify(reviewerOwners) !== JSON.stringify(detail.assigned ? detail.roles.reviewers ?? [] : []) + || approverOwner !== (detail.assigned ? detail.roles.approver : '')) - const saveDisabled = busy || !!writeError || !docId || detail == null || !author[0] || !approver[0] || reviewers.length === 0 + const saveDisabled = busy || !!writeError || !docId || detail == null || !authorOwner || !approverOwner || + reviewers.length === 0 || reviewerOwners.some(owner => !owner) const save = () => { if (saveDisabled || !dirty || requests.current.selected !== docId || detail?.docId !== docId) return if (detail.active && !window.confirm( `文档「${detail.title}」在途第 ${detail.active.round} 轮审签将因职责变更失效,需重新提交。确认变更?`)) return return run('已保存当前职责', () => - cmd('rdm.review.assign.doc', assignDocDutiesPayload(projectId, docId, author[0], reviewers, approver[0]))) + cmd('rdm.review.assign.doc', { ...assignDocDutiesPayload(projectId, docId, authorOwner, reviewerOwners, approverOwner), + accountIds: selectedIDs([...author, ...reviewers, ...approver]) })) } const batchApply = () => run(`已批量分配 ${batchSel.size} 份文档`, () => cmd('rdm.review.assign.doc.batch', { projectId, docs: [...batchSel].map(d => ({ - docId: d, author: batchAuthor[0], reviewers: batchReviewers, approver: batchApprover[0] })) })) + docId: d, author: ownerOf(batchAuthor[0] ?? ''), reviewers: ownersOf(batchReviewers), + approver: ownerOf(batchApprover[0] ?? ''), accountIds: selectedIDs([...batchAuthor, ...batchReviewers, ...batchApprover]) })) })) return (
@@ -440,7 +472,7 @@ export function RdmDutiesSection({ project }: { project: Project }) { {(docs ?? []).map(d => ( { const n = new Set(batchSel); e.target.checked ? n.add(d.docId) : n.delete(d.docId); setBatchSel(n) }} /> + onChange={e => { const n = new Set(batchSel); e.target.checked ? n.add(d.docId) : n.delete(d.docId); setBatchSel(n); setBatchAuthor([]); setBatchReviewers([]); setBatchApprover([]) }} /> · {d.docId} {d.typeKey}{d.stage ? ` / ${d.stage}` : ''} @@ -459,13 +491,19 @@ export function RdmDutiesSection({ project }: { project: Project }) { {detail && (

{detail.title} · 当前职责{detail.active ? 在途第 {detail.active.round} 轮(变更将失效需重提交) : null}

+
+ 仅当前有效项目成员;输入后选择 userId
-
+
+ {authorOwner || '未选择'}
-
- {reviewers.length > 0 && 已选:{reviewers.join('、')}}
+
+ {reviewers.length > 0 && 已选:{reviewerOwners.join('、')}}
-
+
+ {approverOwner || '未选择'}
保存当前职责 {detail.active && 保存将使在途第 {detail.active.round} 轮失效(提交人 {detail.active.createdBy})} @@ -507,13 +545,16 @@ export function RdmDutiesSection({ project }: { project: Project }) {

批量分配(已选 {batchSel.size} 份;任一校验失败整批拒绝并列原因)

-
+
+ {ownerOf(batchAuthor[0] ?? '') || '未选择'}
-
+
+ {ownersOf(batchReviewers).join('、') || '未选择'}
-
+
+ {ownerOf(batchApprover[0] ?? '') || '未选择'}
- !owner)} onClick={batchApply}>批量分配 {batchSel.size} 份文档
diff --git a/src/components/adminSections.tsx b/src/components/adminSections.tsx index 4e56ab8..c1f9553 100644 --- a/src/components/adminSections.tsx +++ b/src/components/adminSections.tsx @@ -9,157 +9,9 @@ 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 { randomPassword } from '../passwords' -// ---- 用户管理(原 AdminUsersModal 主体,R2 管理员发放制 + 2026-10-13 恢复 Key)---- -interface AdminUserRow { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string } -interface LoginKeyRow { id: string; owner: string; purpose: string; status: string; expiresAt: string; createdAt: string; createdBy?: string } - -/** Key 状态徽标:复用既有 chip 体系,不新增颜色。 */ -function keyStatusChip(status: string) { - if (status === 'unused') return 待使用 - if (status === 'consumed') return 已使用 - if (status === 'expired') return 已过期 - return 已撤销 -} - -export function UsersPanel() { - const me = useStore(s => s.me) - const [users, setUsers] = useState(null) - const [err, setErr] = useState('') - const [notice, setNotice] = useState('') - const [owner, setOwner] = useState('') - const [name, setName] = useState('') - const [pw, setPw] = useState('') - const [asAdmin, setAsAdmin] = useState(false) - const [busy, setBusy] = useState(false) - // 登录 Key 面板:keyFor 打开指定用户的签发/状态视图;issued 明文仅签发响应持有一次。 - const [keyFor, setKeyFor] = useState(null) - const [issued, setIssued] = useState<{ key: string; expiresAt: string } | null>(null) - const [keys, setKeys] = useState(null) - - const refresh = async () => { - try { setUsers(await api.listAdminUsers()) } catch (e) { setErr(String(e)) } - } - const refreshKeys = async (owner: string) => { - try { setKeys(await api.adminListLoginKeys(owner)) } catch (e) { setErr(String(e)) } - } - useEffect(() => { refresh() }, []) - - const openKeyPanel = async (owner: string) => { - setErr(''); setNotice(''); setIssued(null); setKeyFor(owner); setKeys(null) - await refreshKeys(owner) - } - - const create = async () => { - setErr(''); setNotice('') - if (pw.length < 8) { setErr('初始密码至少 8 位'); return } - setBusy(true) - try { - await api.createAdminUser(owner.trim(), name.trim(), pw, asAdmin) - setNotice(`已创建 ${owner.trim()}:首次登录须改密`) - setOwner(''); setName(''); setPw('') - await refresh() - } catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) } - } - - const issue = async () => { - if (!keyFor) return - setErr(''); setNotice(''); setBusy(true) - try { - const out = await api.adminIssueLoginKey(keyFor) - setIssued({ key: out.key, expiresAt: out.expiresAt }) - await refreshKeys(keyFor) - } catch (e) { setErr(String(e)) } finally { setBusy(false) } - } - - const copyKey = async () => { - if (!issued) return - try { await navigator.clipboard.writeText(issued.key); setNotice('已复制到剪贴板,请安全交付给该用户') } catch { setErr('复制失败,请手动选择复制') } - } - - const revoke = async (id: string) => { - if (!keyFor) return - setErr(''); setBusy(true) - try { - await api.adminRevokeLoginKey(keyFor, id) - await refreshKeys(keyFor) - } catch (e) { setErr(String(e)) } finally { setBusy(false) } - } - - return ( -
- {me?.isAdmin && ( -
-
- setOwner(e.target.value)} /> - setName(e.target.value)} /> -
-
- setPw(e.target.value)} /> - - - -
-

创建后该用户首次登录必须修改此初始密码;忘记密码时为其生成一次性登录 Key。

-
- )} - {notice &&
{notice}
} - {err &&
{err}
} - {keyFor ? ( -
-
- {keyFor} 的一次性登录 Key(忘记密码恢复) - - -
- {issued && ( -
- e.currentTarget.select()} /> - -
- )} - {issued &&

明文仅此一次显示,关闭后不可再取;单次有效,{new Date(issued.expiresAt).toLocaleString()} 过期。请经安全渠道交付本人。

} - {keys !== null && keys.length > 0 && ( - - - - {keys.map(k => ( - - - - - - - - ))} - -
签发时间用途状态过期时间
{new Date(k.createdAt).toLocaleString()}{k.purpose === 'recover' ? '恢复' : '激活'}{keyStatusChip(k.status)}{new Date(k.expiresAt).toLocaleString()}{k.status === 'unused' && }
- )} -
- ) : ( - - - - {(users ?? []).map(u => ( - - - - - - - - - ))} - {users !== null && users.length === 0 && } - -
用户名显示名角色状态密码设置于
{u.owner}{u.name || '—'}{u.isAdmin ? ADMIN : USER}{u.mustChangePassword ? 待改密 : u.hasPassword ? '正常' : '未设密码'}{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}{me?.isAdmin && ( - - )}
暂无账号
- )} -
- ) -} +// ---- 用户管理:账号资料与恢复提案(U3)---- +export { UsersPanel } from './AccountUsersPanel' // ---- 智能体画像(原 SettingsModal ProfilesSection:数据化导入导出 + 编辑)---- export function ProfilesSection({ profiles }: { profiles: AgentProfile[] }) { diff --git a/src/passwords.ts b/src/passwords.ts deleted file mode 100644 index b0ad3c3..0000000 --- a/src/passwords.ts +++ /dev/null @@ -1,11 +0,0 @@ -// 浏览器端随机初始密码(管理员未显式给定密码时): -// crypto.getRandomValues 拒绝易混淆字符(0/O/1/l/I),保证可抄写。 -const ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789' - -export function randomPassword(length = 12): string { - const buf = new Uint32Array(length) - crypto.getRandomValues(buf) - let out = '' - for (let i = 0; i < length; i++) out += ALPHABET[buf[i] % ALPHABET.length] - return out -} diff --git a/src/rdmManagement.ts b/src/rdmManagement.ts index cb7a2cd..1ae8229 100644 --- a/src/rdmManagement.ts +++ b/src/rdmManagement.ts @@ -102,8 +102,8 @@ 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 }) +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 }) 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('未知错误剥状态码前缀透传', () => { From 010540f2037a044cf70c04dc037c591ab8238699 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 04:58:46 +0000 Subject: [PATCH 02/17] Treat duplicate recovery responses as status-only Why: A lost confirmation or grant-rotation response must not imply that the one-time plaintext can be read again. Evidence: SELF receipt /tmp/iam-u3-frontend-idempotent-test.json and a normal pinned-UI build after the change. Impact: The owner view shows current proposal status and offers an explicit new-grant action only when the server returns no plaintext. Refs: IAM U3 r1 stages 4-5; Dev feature/iam-u3-account-directory-recovery --- src/api.ts | 4 ++-- src/components/MyAccountSettings.tsx | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/api.ts b/src/api.ts index 88da759..7da9540 100644 --- a/src/api.ts +++ b/src/api.ts @@ -99,8 +99,8 @@ export const api = { adminRevokeRecoveryProposal: (userId: string, id: string, expectedVersion: number) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/recovery-proposals/${encodeURIComponent(id)}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedVersion }) }).then(r => json(r)), listMyRecoveryProposals: () => auth.fetch(`${BASE}/api/me/recovery-proposals`).then(r => json<{ proposals: RecoveryProposal[] }>(r)).then(d => d.proposals ?? []), getMyRecoveryProposal: (id: string) => auth.fetch(`${BASE}/api/me/recovery-proposals/${encodeURIComponent(id)}`).then(r => json(r)), - confirmMyRecoveryProposal: (id: string, expectedVersion: number) => auth.fetch(`${BASE}/api/auth/recovery-proposals/${encodeURIComponent(id)}/confirm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedVersion }) }).then(r => json<{ proposal: RecoveryProposal; grant: string }>(r)), - rotateMyRecoveryGrant: (id: string, expectedVersion: number, expectedGeneration: number) => auth.fetch(`${BASE}/api/auth/recovery-proposals/${encodeURIComponent(id)}/rotate-grant`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedVersion, expectedGeneration }) }).then(r => json<{ proposal: RecoveryProposal; grant: string }>(r)), + confirmMyRecoveryProposal: (id: string, expectedVersion: number) => auth.fetch(`${BASE}/api/auth/recovery-proposals/${encodeURIComponent(id)}/confirm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedVersion }) }).then(r => json<{ proposal: RecoveryProposal; grant?: string }>(r)), + rotateMyRecoveryGrant: (id: string, expectedVersion: number, expectedGeneration: number) => auth.fetch(`${BASE}/api/auth/recovery-proposals/${encodeURIComponent(id)}/rotate-grant`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedVersion, expectedGeneration }) }).then(r => json<{ proposal: RecoveryProposal; grant?: string }>(r)), createConversation: (projectId: string, title?: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title ?? '' }) }).then(r => json(r)), deleteConversation: (id: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'DELETE' }), setConversationProfile: (id: string, profileId: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profileId }) }).then(r => json(r)), diff --git a/src/components/MyAccountSettings.tsx b/src/components/MyAccountSettings.tsx index f9bbe2e..ea16d15 100644 --- a/src/components/MyAccountSettings.tsx +++ b/src/components/MyAccountSettings.tsx @@ -59,6 +59,10 @@ export function MyAccountSettings() { try { const result = await api.confirmMyRecoveryProposal(proposal.id, proposal.version) setProposals(current => current?.map(item => item.id === proposal.id ? result.proposal : item) ?? [result.proposal]) + if (!result.grant) { + setErr('提案已确认,但一次性明文不可读回。若仍保有原本人会话,可显式重新生成设密能力。') + return + } setGrant({ proposalId: proposal.id, value: result.grant }) setNotice('本人确认成功。设密能力只在此响应可用,请立即兑换并设置新密码。') } catch (error) { @@ -74,6 +78,10 @@ export function MyAccountSettings() { try { const result = await api.rotateMyRecoveryGrant(proposal.id, proposal.version, proposal.generation) setProposals(current => current?.map(item => item.id === proposal.id ? result.proposal : item) ?? [result.proposal]) + if (!result.grant) { + setErr('重新生成已提交,但一次性明文不可读回。请核对当前提案状态,再由本人显式操作。') + return + } setGrant({ proposalId: proposal.id, value: result.grant }) setNotice('已重新生成设密能力;旧能力与派生受限会话已失效。') } catch (error) { From a961b19ebb793d6c02a72002050d269700b1a5bd Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 05:01:26 +0000 Subject: [PATCH 03/17] Normalize legacy duty fields at the UI boundary Why: RDM duty.get publishes capitalized domain role keys, so a selected document appeared to have no assigned people in the new stable-ID picker. Evidence: SELF receipts /tmp/iam-u3-rdm-ui-wire-compat.json and /tmp/iam-u3-frontend-wire-compat-test.json; normal pinned-UI build passed. Impact: The workbench accepts both existing capitalized and lower-camel role fields without changing the RDM public response contract. Refs: IAM U3 r1 stages 4-5; Dev feature/iam-u3-account-directory-recovery --- src/components/adminRdmSections.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/components/adminRdmSections.tsx b/src/components/adminRdmSections.tsx index 29d6c2c..34fff69 100644 --- a/src/components/adminRdmSections.tsx +++ b/src/components/adminRdmSections.tsx @@ -385,10 +385,17 @@ export function RdmDutiesSection({ project }: { project: Project }) { const out = await cmd('rdm.duty.get', { projectId, docId: id }) if (!currentDocument(epoch, selection, id)) return if (out.docId !== id) throw new Error('文档职责与当前选择不一致,请重新选择文档') - setDetail(out) - if (out.assigned) { + // Historical RDM domain.Roles serializes capitalized keys. Keep the + // published response compatible and normalize only at this UI boundary. + const wire = out.roles as DutyGetOut['roles'] & { Author?: string; Reviewers?: string[]; Approver?: string } + const detailView: DutyGetOut = { ...out, roles: { + author: wire.author ?? wire.Author ?? '', reviewers: wire.reviewers ?? wire.Reviewers ?? [], + approver: wire.approver ?? wire.Approver ?? '', + } } + setDetail(detailView) + if (detailView.assigned) { try { - const names = [out.roles.author, ...(out.roles.reviewers ?? []), out.roles.approver] + const names = [detailView.roles.author, ...(detailView.roles.reviewers ?? []), detailView.roles.approver] const ids = await Promise.all(names.map(owner => resolveCurrentOwner(id, owner))) if (!currentDocument(epoch, selection, id)) return setAuthor(ids[0] ? [ids[0]] : []) From 2e7e5b7a648f19da314ce194dbb807540a6e3044 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 15:53:46 +0000 Subject: [PATCH 04/17] Reconcile U3 account writes and recovery proposals Why: A successful mutation from a later search page reused an invalid signed cursor and falsely reported a write conflict; a lost recovery-proposal response left a committed proposal hidden and retried with a fresh key. Evidence: Isolated browser red runs reproduced the stale-page duplicate warning and unknown proposal status; after the fix the combined page-two and lost-response flow passed, as did 272 Frontend tests and a production build. Impact: Successful writes restart pagination and keep their success status, while uncertain proposal outcomes re-read metadata and reuse the same idempotency key for explicit retry. Refs: IAM U3 Stage 7; Frontend PR #86 --- src/components/AccountUsersPanel.tsx | 49 ++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/src/components/AccountUsersPanel.tsx b/src/components/AccountUsersPanel.tsx index 80a3ef6..c4b38ec 100644 --- a/src/components/AccountUsersPanel.tsx +++ b/src/components/AccountUsersPanel.tsx @@ -43,16 +43,24 @@ export function UsersPanel() { const [err, setErr] = useState('') const [notice, setNotice] = useState('') const requestSeq = useRef(0) + const recoveryAttempt = useRef<{ userId: string; stateVersion: number; key: string } | null>(null) const searchSeq = useRef(0) - const searchRequest = () => ({ query: search.trim(), mode: searchMode, status: statusFilter, - fields: searchField === 'default' ? ['owner', 'name', 'userId'] : [searchField], cursor, limit: 20 }) + const searchRequest = (pageCursor = cursor) => ({ query: search.trim(), mode: searchMode, status: statusFilter, + fields: searchField === 'default' ? ['owner', 'name', 'userId'] : [searchField], cursor: pageCursor, limit: 20 }) - const refresh = async () => { + const refresh = async (pageCursor = cursor) => { const seq = ++searchSeq.current - const page = await api.searchAdminUsers(searchRequest()) + const page = await api.searchAdminUsers(searchRequest(pageCursor)) if (seq === searchSeq.current) { setUsers(page.accounts); setNextCursor(page.nextCursor ?? '') } return page } + const refreshAfterMutation = async () => { + // A successful write advances the signed directory revision. The old page + // cursor is invalid even before React applies the pending state update. + setCursor(''); setCursorTrail([]); setNextCursor('') + try { await refresh('') } + catch (error) { setErr(`操作已完成,但账号列表暂未刷新:${String(error)}`) } + } useEffect(() => { const seq = ++searchSeq.current const timer = window.setTimeout(() => { @@ -78,10 +86,9 @@ export function UsersPanel() { try { const account = await api.createAdminUser(createOwner.trim(), createProfile) setCreateOwner(''); setCreateProfile(emptyProfile()) - setNotice(`已创建 ${account.owner}。等待可信 owner 按 userId 初始化首次凭据。`) - await refresh() await openAccount(account.userId) setNotice(`已创建 ${account.owner}。等待可信 owner 按 userId 初始化首次凭据。`) + await refreshAfterMutation() } catch (error) { setErr(String(error).includes('409') ? '用户名已存在' : String(error)) } finally { setBusy(false) } } @@ -94,7 +101,7 @@ export function UsersPanel() { setErr(''); setNotice(''); setBusy(true) try { const updated = await api.adminPatchProfile(detail.userId, detail.profileVersion, patch) - setDetail(updated); setDraft(updated.profile); await refresh(); setNotice('资料已保存') + setDetail(updated); setDraft(updated.profile); setNotice('资料已保存'); await refreshAfterMutation() } catch (error) { setErr(String(error).includes('409') ? '资料已被其他操作修改,请重新打开账号' : String(error)) } finally { setBusy(false) } } @@ -104,7 +111,7 @@ export function UsersPanel() { setErr(''); setNotice(''); setBusy(true) try { const updated = await api.adminSetUserEnabled(detail.userId, detail.stateVersion, !detail.enabled) - setDetail(updated); await refresh(); setNotice(updated.enabled ? '账号已启用;旧会话和 Key 不会恢复' : '账号已停用,旧会话和 Key 已撤销') + setDetail(updated); setNotice(updated.enabled ? '账号已启用;旧会话和 Key 不会恢复' : '账号已停用,旧会话和 Key 已撤销'); await refreshAfterMutation() } catch (error) { setErr(String(error).includes('409') ? '状态版本或最后管理员保护发生冲突,请重新打开账号' : String(error)) } finally { setBusy(false) } } @@ -112,14 +119,36 @@ export function UsersPanel() { const propose = async () => { if (!detail) return setErr(''); setNotice(''); setBusy(true) + const previous = recoveryAttempt.current + const idempotencyKey = previous?.userId === detail.userId && previous.stateVersion === detail.stateVersion + ? previous.key : (globalThis.crypto?.randomUUID?.() ?? `proposal-${Date.now()}`) + recoveryAttempt.current = { userId: detail.userId, stateVersion: detail.stateVersion, key: idempotencyKey } try { - const idempotencyKey = globalThis.crypto?.randomUUID?.() ?? `proposal-${Date.now()}` await api.createRecoveryProposal(detail.userId, detail.stateVersion, idempotencyKey) setProposals(await api.listAdminRecoveryProposals(detail.userId)) + recoveryAttempt.current = null setNotice('恢复提案已建立。只有目标本人提案前的合格密码会话可以确认;管理员不会收到设密能力。') } catch (error) { const message = String(error) - setErr(message.includes('OWNER_PROOF_UNAVAILABLE') ? '目标没有合格的旧本人会话,当前无法在线恢复;异常恢复需另行获批的可信 owner 流程。' : message) + if (message.includes('OWNER_PROOF_UNAVAILABLE')) { + recoveryAttempt.current = null + setErr('目标没有合格的旧本人会话,当前无法在线恢复;异常恢复需另行获批的可信 owner 流程。') + } else { + // A lost POST response may still mean the proposal committed. Read + // metadata before offering any explicit retry with the same key. + try { + const statuses = await api.listAdminRecoveryProposals(detail.userId) + setProposals(statuses) + if (statuses.some(proposal => proposal.status === 'pending' || proposal.status === 'confirmed')) { + recoveryAttempt.current = null + setNotice('提案状态已核对:已有待处理提案。只有目标本人可以确认并取得设密能力。') + } else { + setErr('提案结果未确定;状态已刷新。再次提交会沿用同一请求标识。') + } + } catch { + setErr('提案结果未知且状态暂时无法读取;请稍后核对,不会自动重复创建。') + } + } } finally { setBusy(false) } } From 34f6a1bf2fab57310c0d117daf559157092b901d Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 16:02:17 +0000 Subject: [PATCH 05/17] Handle terminal recovery proposal outcomes Why: A lost proposal response followed by revocation could leave admins with an ambiguous result, and a late response could overwrite a newly selected account. Evidence: Frontend 272 tests and production build passed; isolated browser replay covered lost response, terminal retry, and account switch. Impact: Recovery notices reflect current proposal state, and stale account requests cannot update the selected detail. Refs: IAM U3 account directory and recovery --- src/components/AccountUsersPanel.tsx | 35 ++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/src/components/AccountUsersPanel.tsx b/src/components/AccountUsersPanel.tsx index c4b38ec..9700cf4 100644 --- a/src/components/AccountUsersPanel.tsx +++ b/src/components/AccountUsersPanel.tsx @@ -23,6 +23,13 @@ function recoveryStatus(status: RecoveryProposal['status']): string { return ({ pending: '等待本人确认', confirmed: '本人已确认', completed: '已完成', revoked: '已撤销', expired: '已过期' })[status] } +function recoveryProposalNotice(status: RecoveryProposal['status']): string { + if (status === 'pending' || status === 'confirmed') { + return `恢复提案${recoveryStatus(status)}。只有目标本人提案前的合格密码会话可以确认;管理员不会收到设密能力。` + } + return `恢复提案${recoveryStatus(status)}。如需再次恢复,请核对下方记录。` +} + export function UsersPanel() { const me = useStore(s => s.me) const [users, setUsers] = useState(null) @@ -118,17 +125,25 @@ export function UsersPanel() { const propose = async () => { if (!detail) return + const seq = requestSeq.current + const userId = detail.userId + const stateVersion = detail.stateVersion + const stillSelected = () => seq === requestSeq.current setErr(''); setNotice(''); setBusy(true) const previous = recoveryAttempt.current - const idempotencyKey = previous?.userId === detail.userId && previous.stateVersion === detail.stateVersion + const idempotencyKey = previous?.userId === userId && previous.stateVersion === stateVersion ? previous.key : (globalThis.crypto?.randomUUID?.() ?? `proposal-${Date.now()}`) - recoveryAttempt.current = { userId: detail.userId, stateVersion: detail.stateVersion, key: idempotencyKey } + recoveryAttempt.current = { userId, stateVersion, key: idempotencyKey } try { - await api.createRecoveryProposal(detail.userId, detail.stateVersion, idempotencyKey) - setProposals(await api.listAdminRecoveryProposals(detail.userId)) + const created = await api.createRecoveryProposal(userId, stateVersion, idempotencyKey) + const statuses = await api.listAdminRecoveryProposals(userId) + if (!stillSelected()) return + setProposals(statuses) recoveryAttempt.current = null - setNotice('恢复提案已建立。只有目标本人提案前的合格密码会话可以确认;管理员不会收到设密能力。') + const current = statuses.find(proposal => proposal.id === created.id) ?? created + setNotice(recoveryProposalNotice(current.status)) } catch (error) { + if (!stillSelected()) return const message = String(error) if (message.includes('OWNER_PROOF_UNAVAILABLE')) { recoveryAttempt.current = null @@ -137,16 +152,16 @@ export function UsersPanel() { // A lost POST response may still mean the proposal committed. Read // metadata before offering any explicit retry with the same key. try { - const statuses = await api.listAdminRecoveryProposals(detail.userId) + const statuses = await api.listAdminRecoveryProposals(userId) + if (!stillSelected()) return setProposals(statuses) - if (statuses.some(proposal => proposal.status === 'pending' || proposal.status === 'confirmed')) { - recoveryAttempt.current = null - setNotice('提案状态已核对:已有待处理提案。只有目标本人可以确认并取得设密能力。') + if (statuses.length > 0) { + setNotice(`提案响应未收到;状态已刷新。最近记录:${recoveryStatus(statuses[0].status)}。请核对下方记录;再次提交会沿用同一请求标识。`) } else { setErr('提案结果未确定;状态已刷新。再次提交会沿用同一请求标识。') } } catch { - setErr('提案结果未知且状态暂时无法读取;请稍后核对,不会自动重复创建。') + if (stillSelected()) setErr('提案结果未知且状态暂时无法读取;请稍后核对,不会自动重复创建。') } } } finally { setBusy(false) } From 055476d1354834f63a28fb895d4f281b724dc1cb Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 16:03:49 +0000 Subject: [PATCH 06/17] Clarify confirmed recovery proposal notice Why: A confirmed proposal should not instruct the owner to confirm it again. Evidence: Frontend tests and production build passed at the changed source. Impact: Confirmation notice describes the existing state and keeps credential authority with the owner. Refs: IAM U3 account recovery --- src/components/AccountUsersPanel.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/AccountUsersPanel.tsx b/src/components/AccountUsersPanel.tsx index 9700cf4..3ee8d92 100644 --- a/src/components/AccountUsersPanel.tsx +++ b/src/components/AccountUsersPanel.tsx @@ -24,8 +24,11 @@ function recoveryStatus(status: RecoveryProposal['status']): string { } function recoveryProposalNotice(status: RecoveryProposal['status']): string { - if (status === 'pending' || status === 'confirmed') { - return `恢复提案${recoveryStatus(status)}。只有目标本人提案前的合格密码会话可以确认;管理员不会收到设密能力。` + if (status === 'pending') { + return '恢复提案等待本人确认。只有目标本人提案前的合格密码会话可以确认;管理员不会收到设密能力。' + } + if (status === 'confirmed') { + return '恢复提案已由本人确认。只有目标本人可以取得设密能力;管理员不会收到设密能力。' } return `恢复提案${recoveryStatus(status)}。如需再次恢复,请核对下方记录。` } From bed8a5c7661dd7a37f09f9800440b14a6dc4a1e0 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 16:07:12 +0000 Subject: [PATCH 07/17] Run Frontend test and build in Gitea CI Why: The Frontend repository lacked an exact-HEAD CI gate for U3 delivery. Evidence: Workflow syntax parsed locally; Frontend and pinned UI tests and build passed locally; platform run pending. Impact: PR and main checks now fetch fixed Frontend/UI commits and run install, tests, and production build. Refs: IAM U3 delivery gate --- .gitea/workflows/ci.yml | 54 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 .gitea/workflows/ci.yml diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..5ea8173 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,54 @@ +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_SHA: 7c35a78abc8ef7db6fb2d508b2971d59a90de964 + run: | + set -euo pipefail + test "${GITHUB_SERVER_URL}" = "https://git-home.u1680832.nyat.app:30319" + test "${GITHUB_REPOSITORY}" = "weichen/Agent-System-Frontend" + [[ "${GITHUB_SHA}" =~ ^[0-9a-f]{40}$ ]] + [[ "${UI_SHA}" =~ ^[0-9a-f]{40}$ ]] + test -n "${TOKEN}" + 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}" + cd "${RUNNER_TEMP}/iam-frontend-ci/ui" + git init -q + git remote add origin "${GITHUB_SERVER_URL}/weichen/Agent-System-UI.git" + git -c http.extraheader="AUTHORIZATION: token ${TOKEN}" 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 + 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 + 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 From 3203e8d4f396b21fca9b6361ba76830cff8e6fa4 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 16:07:50 +0000 Subject: [PATCH 08/17] Accept platform CI server URL Why: The runner may inject an internal Gitea URL rather than the public browser URL. Evidence: PR CI run 3212 exited at checkout preflight before any fetch; updated YAML parses locally. Impact: Checkout validates the injected URL shape and logs nonsecret event context. Refs: IAM U3 Frontend PR #86 --- .gitea/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 5ea8173..c18a177 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -15,7 +15,8 @@ jobs: UI_SHA: 7c35a78abc8ef7db6fb2d508b2971d59a90de964 run: | set -euo pipefail - test "${GITHUB_SERVER_URL}" = "https://git-home.u1680832.nyat.app:30319" + 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}$ ]] From ee735f2e2ed4a73c8372598fde7fd54bd9d45ee0 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 16:08:41 +0000 Subject: [PATCH 09/17] Use dedicated read access for private UI CI dependency Why: Gitea job token can fetch Frontend but receives repository-not-found for private UI. Evidence: PR CI run 3213 checkout log shows Frontend fetch succeeded and UI fetch failed; Frontend secret listing lacks SUBMODULE_TOKEN. Impact: CI now requests the dedicated UI read credential and fails clearly until it is provisioned. Refs: IAM U3 Frontend PR #86 --- .gitea/workflows/ci.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index c18a177..cbf9e93 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -12,6 +12,7 @@ jobs: - name: Check out fixed Frontend and UI commits env: TOKEN: ${{ secrets.GITHUB_TOKEN }} + UI_TOKEN: ${{ secrets.SUBMODULE_TOKEN }} UI_SHA: 7c35a78abc8ef7db6fb2d508b2971d59a90de964 run: | set -euo pipefail @@ -21,6 +22,10 @@ jobs: [[ "${GITHUB_SHA}" =~ ^[0-9a-f]{40}$ ]] [[ "${UI_SHA}" =~ ^[0-9a-f]{40}$ ]] test -n "${TOKEN}" + if [ -z "${UI_TOKEN}" ]; then + echo "SUBMODULE_TOKEN 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" @@ -32,7 +37,7 @@ jobs: cd "${RUNNER_TEMP}/iam-frontend-ci/ui" git init -q git remote add origin "${GITHUB_SERVER_URL}/weichen/Agent-System-UI.git" - git -c http.extraheader="AUTHORIZATION: token ${TOKEN}" fetch --depth=1 origin "${UI_SHA}" + git -c http.extraheader="AUTHORIZATION: token ${UI_TOKEN}" 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 From 93ee72313143cad677c46b2b5a7691f052f76157 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 16:14:07 +0000 Subject: [PATCH 10/17] Fetch private UI in CI with read-only deploy key Why: The Gitea job token cannot read the private UI repository and this server lacks HTTPS deploy-token API support. Evidence: Repository-scoped UI deploy key read was verified; Frontend Actions secret UI_DEPLOY_KEY is listed; prior CI run 3214 confirmed the missing credential gate. Impact: Exact-pin CI uses a read-only SSH key and pinned host identity, deleting the transient key after checkout. Refs: IAM U3 Frontend PR #86 --- .gitea/workflows/ci.yml | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index cbf9e93..b12f169 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: - name: Check out fixed Frontend and UI commits env: TOKEN: ${{ secrets.GITHUB_TOKEN }} - UI_TOKEN: ${{ secrets.SUBMODULE_TOKEN }} + UI_DEPLOY_KEY: ${{ secrets.UI_DEPLOY_KEY }} UI_SHA: 7c35a78abc8ef7db6fb2d508b2971d59a90de964 run: | set -euo pipefail @@ -22,8 +22,8 @@ jobs: [[ "${GITHUB_SHA}" =~ ^[0-9a-f]{40}$ ]] [[ "${UI_SHA}" =~ ^[0-9a-f]{40}$ ]] test -n "${TOKEN}" - if [ -z "${UI_TOKEN}" ]; then - echo "SUBMODULE_TOKEN is required for the private UI dependency" >&2 + 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 @@ -34,10 +34,15 @@ jobs: 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 + 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" + trap 'rm -f "${RUNNER_TEMP}/iam-frontend-ci/ui_deploy_key" "${RUNNER_TEMP}/iam-frontend-ci/ui_known_hosts"' EXIT + 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 "${GITHUB_SERVER_URL}/weichen/Agent-System-UI.git" - git -c http.extraheader="AUTHORIZATION: token ${UI_TOKEN}" fetch --depth=1 origin "${UI_SHA}" + 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 From a1bd3e29837d87bd41ea1251d779a6441da92f38 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 16:22:50 +0000 Subject: [PATCH 11/17] Verify the Node archive used by Frontend CI Why: The CI runner executed a mirror-downloaded toolchain without authenticating its contents. Evidence: The pinned SHA256 matches the signed Node.js v22.23.3 release manifest and the current mirror archive. Impact: CI fails before extraction if the Node archive differs from the reviewed release artifact. Refs: IAM U3 Frontend PR #86 --- .gitea/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index b12f169..2028e0f 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -48,9 +48,12 @@ jobs: 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: | From bc9b17a665bcb85d1da792a488b6492b95aa63f4 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 16:24:12 +0000 Subject: [PATCH 12/17] Register CI secret cleanup before writing the key Why: An early filesystem failure could exit before the UI deploy key cleanup trap existed. Evidence: Parsed workflow confirms the EXIT trap precedes the first secret write. Impact: Normal checkout failures remove both transient credential files from the runner. Refs: IAM U3 Frontend PR #86 --- .gitea/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 2028e0f..8515323 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -35,9 +35,9 @@ jobs: 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" - trap 'rm -f "${RUNNER_TEMP}/iam-frontend-ci/ui_deploy_key" "${RUNNER_TEMP}/iam-frontend-ci/ui_known_hosts"' EXIT 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 From d47c49350bf3af160584e255d6d29f28cfc81e99 Mon Sep 17 00:00:00 2001 From: agent-exec-transport Date: Sun, 18 Oct 2026 16:31:17 +0000 Subject: [PATCH 13/17] agent-exec transport 832aaeb220c114fdce38450a7e3d2dc7/e80375762b4e --- .gitea/workflows/ci.yml | 68 ++++++++++++++++++++++++++++ src/api.ts | 6 +-- src/components/AccountUsersPanel.tsx | 42 +++++++++++------ src/components/LoginPage.tsx | 4 +- src/components/adminSections.tsx | 65 +++++++++++++++++++++----- src/recovery.test.ts | 40 +++++++++++++++- src/recovery.ts | 21 +++++++++ test/account-users-panel.test.ts | 10 ++++ test/loginpage.test.ts | 6 +-- 9 files changed, 228 insertions(+), 34 deletions(-) create mode 100644 .gitea/workflows/ci.yml create mode 100644 test/account-users-panel.test.ts diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..8515323 --- /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: 7c35a78abc8ef7db6fb2d508b2971d59a90de964 + 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/src/api.ts b/src/api.ts index 90745e5..0c5d646 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; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string } + const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string; passwordChangeMode?: PasswordChangeMode } auth.beginSession() return me }), // 一次性登录 Key 兑换(恢复/激活):请求只带 Key,身份由服务端绑定记录决定; // 成功建立 set_password_only 受限会话(HttpOnly cookie),明文 Key 不落任何存储。 - redeemLoginKey: (key: string) => auth.fetch(`${BASE}/api/auth/login-key/redeem`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }) }).then(async r => { + redeemLoginKey: (key: string) => auth.fetch(`${BASE}/api/auth/recovery-grants/redeem`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }) }).then(async r => { if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`) - const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string } + const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string; passwordChangeMode?: PasswordChangeMode } auth.beginSession() return me }), diff --git a/src/components/AccountUsersPanel.tsx b/src/components/AccountUsersPanel.tsx index 4b5dc56..67fe108 100644 --- a/src/components/AccountUsersPanel.tsx +++ b/src/components/AccountUsersPanel.tsx @@ -12,6 +12,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 =>
@@ -56,15 +59,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,9 +119,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 () => { @@ -124,8 +131,9 @@ export function AccountUsersPanel() { try { const account = await api.createManagedUser(newOwner.trim(), newProfile) setNewOwner(''); setNewProfile(emptyProfile()) - await refreshFirst(); await open(account) + await open(account) setNotice(`已创建 ${account.owner};账号资料可空,角色另行分配`) + await refreshFirst() } catch (error) { setErr(String(error).includes('409') ? '登录名已存在' : String(error)) } finally { setBusy(false) } } @@ -168,8 +176,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) } } if (!allowed('system.users.view')) return

当前账号没有查看用户的系统权限

@@ -186,7 +196,8 @@ export function AccountUsersPanel() { {selected ?
{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}。凭据只经保护文件交付。

} {allowed('system.users.edit') && @@ -213,17 +224,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.isAdmin ? '系统管理员' : '普通账号'}{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/adminSections.tsx b/src/components/adminSections.tsx index ad04896..9a090e5 100644 --- a/src/components/adminSections.tsx +++ b/src/components/adminSections.tsx @@ -1,7 +1,7 @@ // 系统管理分区面板(重设计方案 阶段②):普通用户界面不再暴露的系统能力 // 全部收进这里,由 AdminZone 以分区注册表呈现。数据入口与原弹窗完全一致 //(api.* 不变),只换呈现位置;插件面板/声明工作区经 UIRenderer 内联渲染。 -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { api, type RecoveryProposal } from '../api' import { store, useStore } from '../store' import type { AgentProfile } from '../contracts' @@ -9,7 +9,10 @@ import { Btn, EmptyState, Icon, SettingRow, SettingsGroup, UIRenderer, type DocB import { SettingInput, flatten, unflatten } from './SettingsModal' import type { SettingDesc } from '../contracts' import { docBridge } from './Workbench' -import { recoveryProposalErrorMessage, recoveryProposalHint, recoveryStatusLabel } from '../recovery' +import { + recoveryAttemptKey, recoveryProposalErrorMessage, recoveryProposalHint, recoveryProposalNotice, recoveryProposalRejected, + recoveryStatusLabel, type RecoveryAttempt, +} from '../recovery' // ---- 用户管理:页面建号可设置/生成初始密码,管理员重置生成临时密码; // 保留恢复提案与 Key 管理作为既有非登录页恢复流程。 ---- @@ -29,11 +32,20 @@ export function UsersPanel() { const [propFor, setPropFor] = useState(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() }, []) @@ -60,32 +72,63 @@ export function UsersPanel() { } const openProposals = async (u: AdminUserRow) => { + proposalSeq.current++ setErr(''); setNotice(''); setPropFor(u); setProposals(null) await refreshProposals(u.userId) } + const closeProposals = () => { proposalSeq.current++; setPropFor(null); setProposals(null) } const propose = async () => { if (!propFor) return + const target = propFor + const seq = proposalSeq.current + const stillSelected = () => seq === proposalSeq.current setErr(''); setNotice(''); setBusy(true) + const idempotencyKey = recoveryAttemptKey(recoveryAttempt.current, target.userId, target.stateVersion, () => crypto.randomUUID()) + recoveryAttempt.current = { userId: target.userId, stateVersion: target.stateVersion, key: idempotencyKey } try { - await api.createAdminRecoveryProposal(propFor.userId, propFor.stateVersion, crypto.randomUUID()) - setNotice(`已为 ${propFor.owner} 建立恢复提案:请该用户在「设置 → 账号」由本人确认;确认后一次性 Key 只在其会话内出现`) - await refreshProposals(propFor.userId) + const created = await api.createAdminRecoveryProposal(target.userId, target.stateVersion, idempotencyKey) + recoveryAttempt.current = null + const statuses = await api.listAdminRecoveryProposals(target.userId).catch(() => null) + if (!stillSelected()) return + if (statuses) setProposals(statuses) + setNotice(recoveryProposalNotice(target.owner, (statuses?.find(p => p.id === created.id) ?? created).status)) } catch (e) { - if (String(e).includes('409')) { - await resetPassword(propFor) + const message = String(e) + if (message.includes('409')) { + recoveryAttempt.current = null + await resetPassword(target) return } - setErr(recoveryProposalErrorMessage(String(e))) + if (!stillSelected()) return + if (recoveryProposalRejected(message)) { + recoveryAttempt.current = null + setErr(recoveryProposalErrorMessage(message)) + return + } + // 响应丢失或服务端异常时提案可能已提交:先重读元数据,再允许沿用同一 key 显式重试。 + try { + const statuses = await api.listAdminRecoveryProposals(target.userId) + if (!stillSelected()) return + setProposals(statuses) + if (statuses.length > 0) { + setNotice(`提案响应未收到;状态已刷新。最近记录:${recoveryStatusLabel[statuses[0].status]}。请核对下方记录;再次提交会沿用同一请求标识。`) + } else { + setErr('提案结果未确定;状态已刷新。再次提交会沿用同一请求标识。') + } + } catch { + if (stillSelected()) setErr('提案结果未知且状态暂时无法读取;请稍后核对,不会自动重复创建。') + } } finally { setBusy(false) } } const revokeProposal = async (p: RecoveryProposal) => { if (!propFor) return - setErr(''); setBusy(true) + setErr(''); setNotice(''); setBusy(true) try { await api.adminRevokeRecoveryProposal(propFor.userId, p.id, p.version) await refreshProposals(propFor.userId) + setNotice('提案已撤销') } catch (e) { setErr(String(e)) } finally { setBusy(false) } } @@ -114,7 +157,7 @@ export function UsersPanel() {
{propFor.owner} 的恢复提案(属主确认制:管理员提案,本人确认后获一次性 Key) - +

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

{proposals !== null && proposals.length > 0 && ( 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/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('未知错误剥状态码前缀透传', () => { From 72c941c772534bc5a80fea675bbf3ae9d199f67c Mon Sep 17 00:00:00 2001 From: agent-exec-transport Date: Sun, 18 Oct 2026 16:51:49 +0000 Subject: [PATCH 14/17] agent-exec transport 4a9e9a1c45e7ac50517ea954d95026bb/16e6bae28677 --- src/components/adminRdmSections.tsx | 49 +++++--- src/components/duties/RdmDutiesSection.tsx | 37 +++++- src/duties/draft.ts | 28 +++-- src/duties/views.tsx | 67 +++++++++++ src/rdmManagement.ts | 126 +++++++++++++++++---- test/duty-draft.test.ts | 11 ++ test/rdm-management.test.ts | 15 +++ test/rdm-members-search.test.ts | 125 ++++++++++++++++---- 8 files changed, 382 insertions(+), 76 deletions(-) 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 => )} +
-

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

+

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

([]) 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/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/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') }) From afd9e7504b79d32af66929a300769bc0dcb76ac9 Mon Sep 17 00:00:00 2001 From: agent-exec-transport Date: Sun, 18 Oct 2026 16:57:45 +0000 Subject: [PATCH 15/17] agent-exec transport a522bfd9db3df00c549eaa7e63f94562/3f39344cbd2e --- .gitea/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 8515323..6a89b54 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: env: TOKEN: ${{ secrets.GITHUB_TOKEN }} UI_DEPLOY_KEY: ${{ secrets.UI_DEPLOY_KEY }} - UI_SHA: 7c35a78abc8ef7db6fb2d508b2971d59a90de964 + UI_SHA: 5c2b1036dd1aff89ec4eeae0c96493694e33ad4c run: | set -euo pipefail printf 'context server=%s repository=%s sha=%s\n' "${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}" "${GITHUB_SHA}" From 080c63081642e5670c8c7ff2646905f5b398c807 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 19 Oct 2026 01:06:46 +0000 Subject: [PATCH 16/17] Add the IAM U3 Frontend task card for the PR gate Why: the branch predates Frontend governance adoption and had no task card, so pr-check refused PR #86. Evidence: card records the agent-exec units, host verification log, CI and independent review of e4041b3. Impact: task card only. Refs: Frontend PR #86 Co-authored-by: Cursor --- .task/feature-iam-u3-account-ui.md | 44 ++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .task/feature-iam-u3-account-ui.md diff --git a/.task/feature-iam-u3-account-ui.md b/.task/feature-iam-u3-account-ui.md new file mode 100644 index 0000000..6f86854 --- /dev/null +++ b/.task/feature-iam-u3-account-ui.md @@ -0,0 +1,44 @@ +# 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 | + +## 结果与验证 + +- 结果:每个冲突文件的取舍(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,用户在部署上验收,问题另立修复。 From 22ef3c4b67ea04d8980de6e4d0dd0f1763b66f27 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 19 Oct 2026 01:07:00 +0000 Subject: [PATCH 17/17] Fold-close the IAM U3 Frontend task card before merge Why: close the card in the same PR before merging. Evidence: pr-check PR_CHECK_OK on 080c630. Impact: task card only. Refs: Frontend PR #86 Co-authored-by: Cursor --- .task/{ => archive}/feature-iam-u3-account-ui.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) rename .task/{ => archive}/feature-iam-u3-account-ui.md (93%) diff --git a/.task/feature-iam-u3-account-ui.md b/.task/archive/feature-iam-u3-account-ui.md similarity index 93% rename from .task/feature-iam-u3-account-ui.md rename to .task/archive/feature-iam-u3-account-ui.md index 6f86854..9cc5b79 100644 --- a/.task/feature-iam-u3-account-ui.md +++ b/.task/archive/feature-iam-u3-account-ui.md @@ -1,7 +1,7 @@ # IAM U3 账号管理与本人恢复前端 任务卡 任务主记录:本文件随 PR 描述维护。 -状态:进行中(待合入);owner:weichen。PR:[Frontend PR #86](https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Frontend/pulls/86) +状态:已完成(合入前闭合);owner:weichen。PR:[Frontend PR #86](https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Frontend/pulls/86) ## 基线与授权 @@ -35,6 +35,7 @@ | 节点 | 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) | ## 结果与验证