From 731c9b7776155e4abb204c26158e1165594b65d4 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 04:54:05 +0000 Subject: [PATCH 01/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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