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
This commit is contained in:
weichen
2026-10-14 04:54:05 +00:00
parent 5313cfad4e
commit 731c9b7776
11 changed files with 526 additions and 257 deletions
+32 -10
View File
@@ -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<Response> {
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<SurfaceMeta[]>(r)),
/** 单页权威快照(含 identity + spec),供列表水合与预览渲染。 */
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<SurfaceSnapshot>(r)),
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; sessionScope?: string; 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<AdminUserRow[]>(r)),
searchAdminUsers: (request: { query: string; mode: 'exact' | 'fuzzy'; status: 'all' | 'enabled' | 'disabled'; fields: string[]; cursor?: string; limit?: number }) => {
const params = new URLSearchParams({ query: request.query, mode: request.mode, status: request.status,
fields: request.fields.join(','), limit: String(request.limit ?? 20) })
if (request.cursor) params.set('cursor', request.cursor)
return auth.fetch(`${BASE}/api/admin/users/search?${params}`).then(r => json<AccountSearchPage>(r))
},
createAdminUser: (owner: string, profile: Partial<AccountProfile>) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile }) }).then(r => json<AccountRecord>(r)),
adminGetUser: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}`).then(r => json<AccountRecord>(r)),
adminPatchProfile: (userId: string, expectedProfileVersion: number, patch: Partial<AccountProfile>) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/profile`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedProfileVersion, patch }) }).then(r => json<AccountRecord>(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<AccountRecord>(r)),
meProfile: () => auth.fetch(`${BASE}/api/me/profile`).then(r => json<AccountRecord>(r)),
patchMeProfile: (expectedProfileVersion: number, patch: Partial<AccountProfile>) => auth.fetch(`${BASE}/api/me/profile`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedProfileVersion, patch }) }).then(r => json<AccountRecord>(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<RecoveryProposal>(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<RecoveryProposal>(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<RecoveryProposal>(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<RecoveryProposal>(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<Conversation>(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<Conversation>(r)),
+209
View File
@@ -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 <div className="ad-create">
{profileFields.map(field => <div className="ad-row" key={field.key}>
<label className="lg-label" htmlFor={`${prefix}-${field.key}`}>{field.label}(可空)</label>
<input id={`${prefix}-${field.key}`} className="lg-input" value={value[field.key]}
onChange={event => onChange({ ...value, [field.key]: event.target.value })} />
</div>)}
</div>
}
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<AccountRecord[] | null>(null)
const [selectedId, setSelectedId] = useState('')
const [detail, setDetail] = useState<AccountRecord | null>(null)
const [proposals, setProposals] = useState<RecoveryProposal[] | null>(null)
const [createOwner, setCreateOwner] = useState('')
const [createProfile, setCreateProfile] = useState<AccountProfile>(emptyProfile)
const [draft, setDraft] = useState<AccountProfile>(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<string[]>([])
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<AccountProfile> = {}
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 <div>
<div className="ad-create">
<div className="ad-row">
<input className="lg-input" aria-label="新账号登录名" placeholder="登录名" value={createOwner} onChange={event => setCreateOwner(event.target.value)} />
<button className="lg-submit ad-create-btn" disabled={busy || !createOwner.trim()} onClick={create}>创建普通账号</button>
</div>
<Collapsible id="admin-create-optional-profile" trigger="可选资料:姓名、电话、邮箱、工号、部门、备注">
<ProfileInputs value={createProfile} onChange={setCreateProfile} prefix="create-profile" />
</Collapsible>
<p className="ad-hint">网页建号不设置密码或管理员身份。新账号由可信 owner 按稳定 userId 完成首次初始化。</p>
</div>
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
{selectedId ? <div className="settings-group">
<h4 className="settings-group-title">账号详情</h4>
<div className="ad-row">
<button className="ad-mini" onClick={() => { requestSeq.current++; setSelectedId(''); setDetail(null); setProposals(null) }}>返回列表</button>
<b>{detail?.owner ?? '加载账号…'}</b>
<span className="ad-dim ad-mono">{selectedId}</span>
</div>
{detail && <>
<p className="ad-hint">状态:{detail.enabled ? '有效' : '停用'} · {detail.initializationState === 'pending' ? '待可信 owner 初始化' : detail.passwordSet ? '已设置密码' : '未设置密码'} · 资料版本 {detail.profileVersion}</p>
{detail.initializationState === 'pending' && <p className="ad-hint">交给可信 owner 的初始化定位:userId <span className="ad-mono">{detail.userId}</span> · 状态版本 {detail.stateVersion} · 代数 {detail.initializationGeneration}。凭据只经保护文件交付。</p>}
<Collapsible id={`admin-profile-${detail.userId}`} trigger="编辑可选资料">
<ProfileInputs value={draft} onChange={setDraft} prefix="admin-profile" />
<button className="lg-submit ad-create-btn" disabled={busy} onClick={saveProfile}>保存资料</button>
</Collapsible>
<div className="ad-row">
<button className="ad-mini" disabled={busy} onClick={setEnabled}>{detail.enabled ? '停用账号' : '启用账号'}</button>
<button className="ad-mini" disabled={busy || !detail.enabled || !detail.passwordSet} onClick={propose}>建立本人恢复提案</button>
</div>
<p className="ad-hint">停用会撤销现有会话与 Key。恢复提案只返回状态;目标本人必须使用提案前已有的可信完整密码会话确认。</p>
<h4 className="settings-group-title">恢复提案</h4>
{(proposals ?? []).map(proposal => <div className="ad-row" key={proposal.id}>
<span className="ad-mono">{proposal.id}</span>
<span>{recoveryStatus(proposal.status)}</span>
<span className="ad-dim">到期 {new Date(proposal.expiresAt).toLocaleString()}</span>
{(proposal.status === 'pending' || proposal.status === 'confirmed') && <button className="ad-mini" disabled={busy} onClick={() => revoke(proposal)}>撤销</button>}
</div>)}
</>}
</div> : <>
<div className="ad-row">
<input className="lg-input" aria-label="搜索账号" placeholder="搜索账号资料" value={search} onChange={event => { setSearch(event.target.value); setCursor(''); setCursorTrail([]) }} />
<select className="lg-input" aria-label="匹配方式" value={searchMode} onChange={event => { setSearchMode(event.target.value as 'exact' | 'fuzzy'); setCursor(''); setCursorTrail([]) }}>
<option value="fuzzy">模糊匹配</option><option value="exact">精确匹配</option>
</select>
<select className="lg-input" aria-label="搜索字段" value={searchField} onChange={event => { setSearchField(event.target.value); setCursor(''); setCursorTrail([]) }}>
<option value="default">账号/姓名/ID</option>
<option value="owner">登录名</option><option value="name">姓名</option><option value="userId">userId</option>
<option value="phone">电话</option><option value="email">邮箱</option><option value="staffNo">工号</option>
<option value="department">部门</option><option value="notes">备注</option>
</select>
<select className="lg-input" aria-label="账号状态筛选" value={statusFilter} onChange={event => { setStatusFilter(event.target.value as typeof statusFilter); setCursor(''); setCursorTrail([]) }}>
<option value="all">全部状态</option><option value="enabled">有效</option><option value="disabled">停用</option>
</select>
</div>
<table className="ad-table"><thead><tr><th>登录名</th><th>姓名</th><th>系统身份</th><th>状态</th><th>首次凭据</th><th></th></tr></thead>
<tbody>{(users ?? []).map(user => <tr key={user.userId}>
<td className="ad-mono">{user.owner}</td><td>{user.profile.name || '—'}</td>
<td>{user.isAdmin ? '系统管理员' : '普通账号'}</td><td>{user.enabled ? '有效' : '停用'}</td>
<td>{user.initializationState === 'pending' ? '待可信 owner 初始化' : user.passwordSet ? '已设置' : '未设置'}</td>
<td><button className="ad-mini" onClick={() => openAccount(user.userId)}>查看与管理</button></td>
</tr>)}
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">没有匹配账号</td></tr>}
{users === null && <tr><td colSpan={6} className="ad-dim">加载中…</td></tr>}</tbody>
</table>
<div className="ad-row">
<button className="ad-mini" disabled={cursorTrail.length === 0} onClick={() => { setCursor(cursorTrail[cursorTrail.length - 1]); setCursorTrail(cursorTrail.slice(0, -1)) }}>上一页</button>
<button className="ad-mini" disabled={!nextCursor} onClick={() => { setCursorTrail([...cursorTrail, cursor]); setCursor(nextCursor) }}>下一页</button>
</div>
</>}
</div>
}
+1 -1
View File
@@ -27,7 +27,7 @@ function ProfilesPanelWrapper() {
}
const SECTIONS: AdminSectionDef[] = [
{ key: 'users', label: '用户管理', icon: 'users', desc: '建号、发放初始密码、重置密码(管理员发放制)', render: () => <UsersPanel /> },
{ key: 'users', label: '用户管理', icon: 'users', desc: '无凭据建号、可选资料、账号有效态与本人恢复提案', render: () => <UsersPanel /> },
// U6/KTD5:RDM 专业角色管理——命令面齐备(rdmRolesAvailable)才显示;普通基座零暴露。
{ key: 'rdm-roles', label: 'RDM 角色权限', icon: 'shield', desc: '专业角色:动作默认/上限、账号绑定、停用(仅管理员)', render: () => <RdmRolesPanel /> },
{ key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', render: () => <ProfilesPanelWrapper /> },
+39 -19
View File
@@ -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() {
<p>文档、决策与知识的沉淀、检索与生成——面向团队的自托管智能体工作台。</p>
<ul>
<li><Icon name="message" size={15} />项目化多任务工作区,收件箱驱动待办</li>
<li><Icon name="shield" size={15} />管理员发放账号,密码策略与密钥分级</li>
<li><Icon name="shield" size={15} />组织管理账号,密码策略与密钥分级</li>
<li><Icon name="search" size={15} />知识库混合检索(全文 + 向量)</li>
<li><Icon name="package" size={15} />文档生成流水线与工件管理</li>
</ul>
@@ -76,27 +74,27 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
<form className="lg-card" onSubmit={e => { e.preventDefault(); tab === 'password' ? submitPassword() : submitKey() }}>
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
<h2 className="lg-title">登录</h2>
<p className="lg-sub">账号由管理员发放;忘记密码请联系管理员签发一次性登录 Key</p>
<p className="lg-sub">账号由组织创建;首次凭据由可信 owner 初始化。忘记密码可查看本人恢复提案。</p>
<div className="lg-tabs" role="tablist">
<button type="button" role="tab" aria-selected={tab === 'password'} className={`lg-tab ${tab === 'password' ? 'on' : ''}`} onClick={() => setTab('password')}>账号密码</button>
<button type="button" role="tab" aria-selected={tab === 'loginkey'} className={`lg-tab ${tab === 'loginkey' ? 'on' : ''}`} onClick={() => setTab('loginkey')}>一次性登录 Key</button>
<button type="button" role="tab" aria-selected={tab === 'loginkey'} className={`lg-tab ${tab === 'loginkey' ? 'on' : ''}`} onClick={() => setTab('loginkey')}>激活/恢复能力</button>
</div>
{tab === 'password' ? (
<>
<label className="lg-label">用户名</label>
<input className="lg-input" value={owner} onChange={e => setOwner(e.target.value)} autoFocus autoComplete="username" placeholder="由管理员分配" />
<label className="lg-label">密码</label>
<input className="lg-input" type="password" value={password} onChange={e => setPassword(e.target.value)} autoComplete="current-password" placeholder="初始密码或常用密码" />
<input className="lg-input" type="password" value={password} onChange={e => setPassword(e.target.value)} autoComplete="current-password" placeholder="账号密码" />
<Err msg={err} />
<button className="lg-submit" type="submit" disabled={busy || !owner.trim() || !password}>{busy ? '验证中…' : '登录'}</button>
</>
) : (
<>
<label className="lg-label">一次性登录 Key</label>
<label className="lg-label">一次性设密能力</label>
<input className="lg-input lg-mono" value={key} onChange={e => setKey(e.target.value)} autoFocus placeholder="alk_…" />
<Err msg={err} />
<button className="lg-submit" type="submit" disabled={busy || !key}>{busy ? '兑换中…' : '兑换并设置新密码'}</button>
<p className="lg-hint">忘记密码时由管理员签发;单次有效、默认 30 分钟内兑换。程序化客户端请继续使用 API 密钥(Authorization 头)。</p>
<p className="lg-hint">新账号激活由可信 owner 交付;恢复能力由本人使用提案前的合格会话确认后获得。管理员不能代领。程序化客户端可使用独立 API 密钥。</p>
</>
)}
</form>
@@ -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<PasswordChangeMode | null>(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;
<form className="lg-card" onSubmit={e => { e.preventDefault(); submit() }}>
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
<h2 className="lg-title">设置新密码</h2>
<p className="lg-sub">账号 <b>{owner}</b>(服务端确认)须设置新密码后继续;无需输入当前密码</p>
<p className="lg-sub">账号 <b>{owner}</b>(服务端确认)须设置新密码后继续。</p>
{mode === 'current_required' && <>
<label className="lg-label" htmlFor="forced-current">当前密码</label>
<input id="forced-current" className="lg-input" type="password" value={current}
onChange={event => setCurrent(event.target.value)} autoComplete="current-password" />
</>}
{mode === 'grant_setup' && <p className="lg-hint">可信设密能力已验证,此次无需当前密码。</p>}
<label className="lg-label">新密码(≥8 位)</label>
<input className="lg-input" type="password" value={next} onChange={e => setNext(e.target.value)} autoFocus autoComplete="new-password" />
<label className="lg-label">确认新密码</label>
<input className="lg-input" type="password" value={confirm} onChange={e => setConfirm(e.target.value)} autoComplete="new-password" />
<Err msg={err} />
<button className="lg-submit" type="submit" disabled={busy || !next || !confirm}>{busy ? '提交中…' : '设置新密码'}</button>
<button className="lg-submit" type="submit" disabled={busy || !mode || (mode === 'current_required' && !current) || !next || !confirm}>{busy ? '提交中…' : '设置新密码'}</button>
{onCancel && <button type="button" className="lg-cancel" onClick={onCancel}>退出并返回登录</button>}
</form>
)}
@@ -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*/, '')
}
+133
View File
@@ -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<RecoveryProposal['status'], string> = {
pending: '等待本人确认', confirmed: '已确认,等待设密', completed: '已完成', revoked: '已撤销', expired: '已过期',
}
export function MyAccountSettings() {
const me = useStore(s => s.me)
const [account, setAccount] = useState<AccountRecord | null>(null)
const [draft, setDraft] = useState<AccountProfile | null>(null)
const [proposals, setProposals] = useState<RecoveryProposal[] | null>(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<AccountProfile> = {}
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 <SettingsGroup title="我的账号与恢复">
{account && draft ? <>
<p className="ad-hint">登录名 {account.owner} · userId {account.userId}。以下资料均可留空,也不会作为本人恢复证明。</p>
<Collapsible id={`my-profile-${account.userId}`} trigger="编辑可选资料">
<div className="ad-create">{fields.map(field => <div className="ad-row" key={field.key}>
<label className="lg-label" htmlFor={`my-profile-${field.key}`}>{field.label}(可空)</label>
<input id={`my-profile-${field.key}`} className="lg-input" value={draft[field.key]}
onChange={event => setDraft({ ...draft, [field.key]: event.target.value })} />
</div>)}
<button className="lg-submit ad-create-btn" disabled={busy} onClick={save}>保存我的资料</button></div>
</Collapsible>
</> : <p className="ad-hint">使用完整密码会话后可查看和修改个人资料。</p>}
<p className="ad-hint">管理员只能建立恢复提案。本人确认必须使用提案之前已存在、来源可信的完整密码会话;历史未知来源账号无法在线证明。</p>
{(proposals ?? []).map(proposal => <div className="ad-create" key={proposal.id}>
<div className="ad-row"><span className="ad-mono">{proposal.id}</span><b>{statusLabel[proposal.status]}</b>
<span className="ad-dim">到期 {new Date(proposal.expiresAt).toLocaleString()}</span></div>
<div className="ad-row">
{proposal.status === 'pending' && <button className="ad-mini" disabled={busy} onClick={() => confirm(proposal)}>由本人确认</button>}
{proposal.status === 'confirmed' && <button className="ad-mini" disabled={busy} onClick={() => rotate(proposal)}>显式重新生成设密能力</button>}
</div>
</div>)}
{proposals !== null && proposals.length === 0 && <p className="ad-hint">当前没有恢复提案。</p>}
{grant && <div className="ad-reset">
<p className="ad-hint">设密能力仅保存在当前页面内存,离开后不可读回;不自动复制到剪贴板。</p>
<button className="lg-submit ad-create-btn" disabled={busy} onClick={redeem}>立即兑换并设置新密码</button>
</div>}
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
</SettingsGroup>
}
+6 -3
View File
@@ -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<string, string> = {
@@ -19,7 +20,7 @@ export const groupNames: Record<string, string> = {
}
export function SettingsModal() {
const [section] = useState<Section>('settings')
const [section, setSection] = useState<Section>('settings')
const [schema, setSchema] = useState<SettingDesc[]>([])
const [, setUser] = useState<Record<string, any>>({})
const [draft, setDraft] = useState<Record<string, any>>({})
@@ -54,10 +55,11 @@ export function SettingsModal() {
return (
<SettingsShell
title="设置" active={section} onSelect={() => 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 ? <span style={{ color: 'var(--ok)' }}>✓ 已保存</span> : 'Agent-System · 设置中心'}
actions={section === 'settings' && schema.length > 0 ? (
@@ -108,6 +110,7 @@ export function SettingsModal() {
)
})}
{section === 'settings' && <BorrowPanels />}
{section === 'account' && <MyAccountSettings />}
</SettingsShell>
)
}
+99 -58
View File
@@ -163,29 +163,28 @@ export function RdmMembersSection({ project }: { project: Project }) {
const [busy, setBusy] = useState(false)
const [picked, setPicked] = useState<string[]>([])
const [role, setRole] = useState<'member' | 'owner'>('member')
const [searchMode, setSearchMode] = useState<'exact' | 'fuzzy'>('fuzzy')
const candidatesById = useRef(new Map<string, { userId: string; owner: string }>())
// 成员表经 ref 参与取数(已加入标记),保持 fetchPage 身份稳定(Picker 依赖)。
const rowsRef = useRef<MemberRow[] | null>(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<unknown>) => {
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 (
<div>
@@ -209,19 +209,24 @@ export function RdmMembersSection({ project }: { project: Project }) {
<div className="ad-create">
<div className="ad-row">
<Picker
key={searchMode}
aria-label="选择人员"
selected={picked}
onSelect={setPicked}
fetchPage={fetchPage}
placeholder="输入账号或姓名,或浏览候选…"
placeholder={searchMode === 'fuzzy' ? '输入至少两个字符搜索账号或姓名' : '输入完整账号或姓名精确搜索'}
/>
<select className="lg-input" aria-label="账号匹配方式" value={searchMode} onChange={event => { setSearchMode(event.target.value as 'exact' | 'fuzzy'); setPicked([]); candidatesById.current.clear() }}>
<option value="fuzzy">模糊匹配</option><option value="exact">精确匹配</option>
</select>
<select className="lg-input" value={role} onChange={e => setRole(e.target.value as 'member' | 'owner')}>
<option value="member">项目成员</option>
<option value="owner">项目负责人</option>
</select>
<button className="lg-submit ad-create-btn" disabled={busy || !!writeError || !pickedId}
onClick={() => run(`已添加 ${pickedId}`, async () => {
await cmd('rdm.project.member.add', memberAddPayload(projectId, pickedId, role))
<button className="lg-submit ad-create-btn" disabled={busy || !!writeError || !pickedAccount}
onClick={() => run(`已添加 ${pickedAccount?.owner ?? ''}`, async () => {
if (!pickedAccount) throw new Error('请重新搜索并选择稳定账号')
await cmd('rdm.project.member.add', memberAddPayload(projectId, pickedAccount.owner, role, pickedAccount.userId))
setPicked([])
})}>添加成员</button>
</div>
@@ -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<DutyCatalogDoc[] | null>(null)
const [members, setMembers] = useState<{ owner: string; role: string }[] | null>(null)
const [docId, setDocId] = useState('')
const [detail, setDetail] = useState<DutyGetOut | null | undefined>(undefined)
const [author, setAuthor] = useState<string[]>([])
@@ -317,12 +307,31 @@ export function RdmDutiesSection({ project }: { project: Project }) {
const [batchAuthor, setBatchAuthor] = useState<string[]>([])
const [batchReviewers, setBatchReviewers] = useState<string[]>([])
const [batchApprover, setBatchApprover] = useState<string[]>([])
const [dutySearchMode, setDutySearchMode] = useState<'exact' | 'fuzzy'>('fuzzy')
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const cmd = <T,>(id: string, params: Record<string, unknown>) => api.rdmProjectCommand<T>(project, id, params)
const memberPage = useMemberPickerPage(members)
const candidateOwners = useRef(new Map<string, string>())
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<string> => {
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<DutyGetOut>('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 (
<div>
@@ -440,7 +472,7 @@ export function RdmDutiesSection({ project }: { project: Project }) {
{(docs ?? []).map(d => (
<tr key={d.docId} className={d.docId === docId ? 'ad-row-on' : ''}>
<td><input type="checkbox" checked={batchSel.has(d.docId)}
onChange={e => { const n = new Set(batchSel); e.target.checked ? n.add(d.docId) : n.delete(d.docId); setBatchSel(n) }} /></td>
onChange={e => { const n = new Set(batchSel); e.target.checked ? n.add(d.docId) : n.delete(d.docId); setBatchSel(n); setBatchAuthor([]); setBatchReviewers([]); setBatchApprover([]) }} /></td>
<td><button className="ad-mini" onClick={() => selectDoc(d.docId)}>{d.title}</button>
<span className="ad-dim ad-mono"> · {d.docId}</span></td>
<td className="ad-dim">{d.typeKey}{d.stage ? ` / ${d.stage}` : ''}</td>
@@ -459,13 +491,19 @@ export function RdmDutiesSection({ project }: { project: Project }) {
{detail && (
<div className="settings-group" style={{ marginTop: 14 }}>
<h4>{detail.title} · 当前职责{detail.active ? <span className="ad-chip ad-chip-warn">在途第 {detail.active.round} 轮(变更将失效需重提交)</span> : null}</h4>
<div className="ad-row"><label className="lg-label" htmlFor="duty-search-mode">候选账号匹配</label>
<select id="duty-search-mode" className="lg-input" value={dutySearchMode} onChange={event => setDutySearchMode(event.target.value as 'exact' | 'fuzzy')}>
<option value="fuzzy">模糊匹配</option><option value="exact">精确匹配</option>
</select><span className="ad-hint">仅当前有效项目成员;输入后选择 userId</span></div>
<div className="setting-row"><label>编制人(单选)</label>
<div style={{ maxWidth: 320 }}><Picker aria-label="编制人" selected={author} onSelect={setAuthor} fetchPage={memberPage} /></div></div>
<div style={{ maxWidth: 320 }}><Picker aria-label="编制人" selected={author} onSelect={setAuthor} fetchPage={dutyPage} placeholder="输入账号或姓名搜索" emptyText="输入后显示有效成员" /></div>
<span className="ad-dim">{authorOwner || '未选择'}</span></div>
<div className="setting-row"><label>审核人(多选 ≥1)</label>
<div style={{ maxWidth: 320 }}><Picker aria-label="审核人" multiple selected={reviewers} onSelect={setReviewers} fetchPage={memberPage} /></div>
{reviewers.length > 0 && <span className="ad-dim">已选:{reviewers.join('、')}</span>}</div>
<div style={{ maxWidth: 320 }}><Picker aria-label="审核人" multiple selected={reviewers} onSelect={setReviewers} fetchPage={dutyPage} placeholder="输入账号或姓名搜索" emptyText="输入后显示有效成员" /></div>
{reviewers.length > 0 && <span className="ad-dim">已选:{reviewerOwners.join('、')}</span>}</div>
<div className="setting-row"><label>批准人(单选)</label>
<div style={{ maxWidth: 320 }}><Picker aria-label="批准人" selected={approver} onSelect={setApprover} fetchPage={memberPage} /></div></div>
<div style={{ maxWidth: 320 }}><Picker aria-label="批准人" selected={approver} onSelect={setApprover} fetchPage={dutyPage} placeholder="输入账号或姓名搜索" emptyText="输入后显示有效成员" /></div>
<span className="ad-dim">{approverOwner || '未选择'}</span></div>
<div className="ad-row">
<Btn variant="primary" icon="check" disabled={saveDisabled || !dirty} onClick={save}>保存当前职责</Btn>
{detail.active && <span className="ad-hint">保存将使在途第 {detail.active.round} 轮失效(提交人 {detail.active.createdBy})</span>}
@@ -507,13 +545,16 @@ export function RdmDutiesSection({ project }: { project: Project }) {
<div className="settings-group" style={{ marginTop: 14 }}>
<h4>批量分配(已选 {batchSel.size} 份;任一校验失败整批拒绝并列原因)</h4>
<div className="setting-row"><label>统一编制人</label>
<div style={{ maxWidth: 320 }}><Picker aria-label="批量编制人" selected={batchAuthor} onSelect={setBatchAuthor} fetchPage={memberPage} /></div></div>
<div style={{ maxWidth: 320 }}><Picker aria-label="批量编制人" selected={batchAuthor} onSelect={setBatchAuthor} fetchPage={batchDutyPage} placeholder="输入账号或姓名搜索" emptyText="输入后显示有效成员" /></div>
<span className="ad-dim">{ownerOf(batchAuthor[0] ?? '') || '未选择'}</span></div>
<div className="setting-row"><label>统一审核人</label>
<div style={{ maxWidth: 320 }}><Picker aria-label="批量审核人" multiple selected={batchReviewers} onSelect={setBatchReviewers} fetchPage={memberPage} /></div></div>
<div style={{ maxWidth: 320 }}><Picker aria-label="批量审核人" multiple selected={batchReviewers} onSelect={setBatchReviewers} fetchPage={batchDutyPage} placeholder="输入账号或姓名搜索" emptyText="输入后显示有效成员" /></div>
<span className="ad-dim">{ownersOf(batchReviewers).join('、') || '未选择'}</span></div>
<div className="setting-row"><label>统一批准人</label>
<div style={{ maxWidth: 320 }}><Picker aria-label="批量批准人" selected={batchApprover} onSelect={setBatchApprover} fetchPage={memberPage} /></div></div>
<div style={{ maxWidth: 320 }}><Picker aria-label="批量批准人" selected={batchApprover} onSelect={setBatchApprover} fetchPage={batchDutyPage} placeholder="输入账号或姓名搜索" emptyText="输入后显示有效成员" /></div>
<span className="ad-dim">{ownerOf(batchApprover[0] ?? '') || '未选择'}</span></div>
<div className="ad-row">
<Btn variant="primary" icon="check" disabled={busy || !!writeError || !batchAuthor[0] || !batchApprover[0] || batchReviewers.length === 0}
<Btn variant="primary" icon="check" disabled={busy || !!writeError || !ownerOf(batchAuthor[0] ?? '') || !ownerOf(batchApprover[0] ?? '') || batchReviewers.length === 0 || ownersOf(batchReviewers).some(owner => !owner)}
onClick={batchApply}>批量分配 {batchSel.size} 份文档</Btn>
<button className="ad-mini" onClick={() => setBatchSel(new Set())}>清空选择</button>
</div>
+2 -150
View File
@@ -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 <span className="ad-chip ad-chip-warn">待使用</span>
if (status === 'consumed') return <span className="ad-chip">已使用</span>
if (status === 'expired') return <span className="ad-chip">已过期</span>
return <span className="ad-chip">已撤销</span>
}
export function UsersPanel() {
const me = useStore(s => s.me)
const [users, setUsers] = useState<AdminUserRow[] | null>(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<string | null>(null)
const [issued, setIssued] = useState<{ key: string; expiresAt: string } | null>(null)
const [keys, setKeys] = useState<LoginKeyRow[] | null>(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 (
<div>
{me?.isAdmin && (
<div className="ad-create">
<div className="ad-row">
<input className="lg-input" placeholder="用户名(登录名)" value={owner} onChange={e => setOwner(e.target.value)} />
<input className="lg-input" placeholder="显示名(可空)" value={name} onChange={e => setName(e.target.value)} />
</div>
<div className="ad-row">
<input className="lg-input lg-mono" placeholder="初始密码(≥8 位)" value={pw} onChange={e => setPw(e.target.value)} />
<button type="button" className="ad-mini" title="生成随机密码" onClick={() => setPw(randomPassword())}>随机</button>
<label className="ad-check"><input type="checkbox" checked={asAdmin} onChange={e => setAsAdmin(e.target.checked)} />管理员</label>
<button className="lg-submit ad-create-btn" disabled={busy || !owner.trim() || !pw} onClick={create}>创建账号</button>
</div>
<p className="ad-hint">创建后该用户首次登录必须修改此初始密码;忘记密码时为其生成一次性登录 Key。</p>
</div>
)}
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
{keyFor ? (
<div className="ad-reset">
<div className="ad-row">
<span><b>{keyFor}</b> 的一次性登录 Key(忘记密码恢复)</span>
<button className="lg-submit ad-create-btn" disabled={busy} onClick={issue}>{issued ? '重新签发(旧 Key 与受限会话立即失效)' : '生成 Key'}</button>
<button type="button" className="ad-mini" onClick={() => { setKeyFor(null); setIssued(null); setKeys(null) }}>关闭</button>
</div>
{issued && (
<div className="ad-row">
<input className="lg-input lg-mono" readOnly value={issued.key} aria-label="一次性登录 Key(仅显示一次)" onFocus={e => e.currentTarget.select()} />
<button type="button" className="ad-mini" onClick={copyKey}>复制</button>
</div>
)}
{issued && <p className="ad-hint">明文仅此一次显示,关闭后不可再取;单次有效,{new Date(issued.expiresAt).toLocaleString()} 过期。请经安全渠道交付本人。</p>}
{keys !== null && keys.length > 0 && (
<table className="ad-table">
<thead><tr><th>签发时间</th><th>用途</th><th>状态</th><th>过期时间</th><th></th></tr></thead>
<tbody>
{keys.map(k => (
<tr key={k.id}>
<td className="ad-dim">{new Date(k.createdAt).toLocaleString()}</td>
<td>{k.purpose === 'recover' ? '恢复' : '激活'}</td>
<td>{keyStatusChip(k.status)}</td>
<td className="ad-dim">{new Date(k.expiresAt).toLocaleString()}</td>
<td>{k.status === 'unused' && <button className="ad-mini" disabled={busy} onClick={() => revoke(k.id)}>撤销</button>}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
) : (
<table className="ad-table">
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>密码设置于</th><th></th></tr></thead>
<tbody>
{(users ?? []).map(u => (
<tr key={u.owner}>
<td className="ad-user-cell"><span className="ad-mono">{u.owner}</span></td>
<td>{u.name || '—'}</td>
<td>{u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
<td>{u.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
<td className="ad-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
<td>{me?.isAdmin && (
<button className="ad-mini" onClick={() => openKeyPanel(u.owner)}>生成登录 Key</button>
)}</td>
</tr>
))}
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
</tbody>
</table>
)}
</div>
)
}
// ---- 用户管理:账号资料与恢复提案(U3)----
export { UsersPanel } from './AccountUsersPanel'
// ---- 智能体画像(原 SettingsModal ProfilesSection:数据化导入导出 + 编辑)----
export function ProfilesSection({ profiles }: { profiles: AgentProfile[] }) {
-11
View File
@@ -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
}
+2 -2
View File
@@ -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 })
+3 -3
View File
@@ -31,10 +31,10 @@ describe('humanChangePasswordError(改密门)', () => {
})
})
describe('humanRedeemError(一次性登录 Key 兑换)', () => {
describe('humanRedeemError(可信激活与本人恢复能力兑换)', () => {
it('409 已使用 / 401 无效过期 / 429 退避各有文案', () => {
expect(humanRedeemError('409: login key already used')).toBe('该 Key 已被使用过,请联系管理员重新签发')
expect(humanRedeemError('401: invalid or expired login key')).toBe('Key 无效或已过期,请联系管理员重新签发')
expect(humanRedeemError('409: login key already used')).toBe('该能力已被使用过;请由本人核对提案状态,或联系可信 owner 续办初始化')
expect(humanRedeemError('401: invalid or expired login key')).toBe('能力无效或已过期;请核对本人提案状态或联系可信 owner 续办初始化')
expect(humanRedeemError('429: too many failed attempts; retry later')).toBe('尝试次数过多,请稍后再试')
})
it('未知错误剥状态码前缀透传', () => {