From e895a4f6a86e0c632c071144938f470eb0a2f039 Mon Sep 17 00:00:00 2001 From: weichen Date: Fri, 16 Oct 2026 07:49:25 +0000 Subject: [PATCH] Close the account chain UI against the current backend contract Why: the forced password gate still submitted an empty current password after the backend began requiring it (current_required), locking every account seeded with an initial password out of the workbench, and the admin user panel had no client for the owner-confirmed recovery proposal contract that replaced one-time key issuing (HTTP 410). Evidence: npm test 300 passed; tsc -b && vite build ok; knip issues 54 <= baseline 55 (SELF). Impact: mode-aware ChangePasswordGate with current-password input, admin recovery proposal panel, owner-side account settings section, recovery proposal client methods; no backend changes. Refs: backend 8dfc223, b9c0a34; frontend #94 --- src/api.ts | 36 ++++++- src/components/LoginPage.tsx | 53 +++++++--- src/components/MyAccountSettings.tsx | 142 +++++++++++++++++++++++++++ src/components/SettingsModal.tsx | 9 +- src/components/adminSections.tsx | 101 +++++++++++++++---- src/recovery.test.ts | 54 ++++++++++ src/recovery.ts | 29 ++++++ 7 files changed, 390 insertions(+), 34 deletions(-) create mode 100644 src/components/MyAccountSettings.tsx create mode 100644 src/recovery.test.ts create mode 100644 src/recovery.ts diff --git a/src/api.ts b/src/api.ts index d83542d..5a67fb2 100644 --- a/src/api.ts +++ b/src/api.ts @@ -15,6 +15,27 @@ const BASE = '' * 自身命令面 fail-closed。 */ export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number } +/** 账户链路(2026-10-13 恢复合同 + 2026-10-14 current_required 加固): + * passwordChangeMode 由服务端从会话派生;grant_setup 免当前密码, + * current_required 必须校验当前密码。 */ +export type PasswordChangeMode = 'grant_setup' | 'current_required' + +/** 恢复提案(属主确认制):管理员只能提案,确认须用提案前已存在的合格完整密码会话。 */ +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 +} + +/** 账号资料与档案视图(backend accountProfileJSON 契约)。 */ +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 ExecEnvView { configured: boolean root?: string @@ -58,7 +79,7 @@ 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; passwordChangeMode?: PasswordChangeMode; isAdmin?: boolean; mustChangePassword?: boolean; passwordSet?: boolean }>(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()}`) @@ -83,13 +104,24 @@ 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)), + listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ userId: string; owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number; profileVersion: number; initializationState: string }[]>(r)), // 建号只创建待初始化账号(无密码、无权限位);初始凭证由服务器本地 CLI 发放激活 Key。 createAdminUser: (owner: string, name: string) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile: { name } }) }).then(r => json<{ owner: string; initializationState: string }>(r)), // ---- 管理员:一次性登录 Key(HTTP 签发端点已被恢复提案契约替换为 410,客户端方法随之移除; // list/revoke 仍有效,供恢复提案视图复用;兑换入口不变)---- adminListLoginKeys: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`).then(r => json<{ keys: { id: string; owner: string; purpose: string; status: string; expiresAt: string; createdAt: string; createdBy?: string }[] }>(r)).then(d => d.keys ?? []), 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)), + // ---- 管理员:恢复提案(属主确认制,替换已 410 的直接签发 Key 入口)---- + createAdminRecoveryProposal: (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 ?? []), + 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)), + // ---- 本人:资料与恢复提案确认(须完整密码会话)---- + 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)), + 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)), adminExecEnv: () => auth.fetch(`${BASE}/api/admin/exec-env`).then(r => json(r)), adminExecEnvDecision: (id: string, allow: boolean, isolate: boolean) => auth.fetch(`${BASE}/api/admin/exec-env/requests/${encodeURIComponent(id)}/decision`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ allow, isolate }) }).then(r => json<{ status: string; detail?: string; layerId?: string }>(r)), adminExecEnvRebase: () => auth.fetch(`${BASE}/api/admin/exec-env/rebase`, { method: 'POST' }).then(r => json<{ base: string }>(r)), diff --git a/src/components/LoginPage.tsx b/src/components/LoginPage.tsx index a867858..c649f56 100644 --- a/src/components/LoginPage.tsx +++ b/src/components/LoginPage.tsx @@ -1,8 +1,10 @@ // 登录与改密(管理员发放制,R2 + 2026-10-13 登录恢复合同): -// LoginPage —— 用户名+密码(默认入口)/ 一次性登录 Key(忘记密码恢复,只输 Key); -// ChangePasswordGate —— 受限会话强制设密(恢复/首登同形:只输新密码,完成前不放行)。 -import { useState } from 'react' -import { api } from '../api' +// LoginPage —— 用户名+密码(默认入口)/ 一次性登录 Key(恢复/激活,只输 Key); +// ChangePasswordGate —— 受限会话强制设密:模式由服务端 passwordChangeMode 派生, +// grant_setup 免当前密码(激活/恢复 Key 兑换),current_required 必须校验当前密码 +//(初始密码首登;2026-10-14 后端加固,空 current 会被 401 拒绝)。 +import { useEffect, useState } from 'react' +import { api, type PasswordChangeMode } from '../api' import { Icon, BrandMark } from '@ui' function Err({ msg }: { msg: string }) { @@ -96,7 +98,7 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on setKey(e.target.value)} autoFocus placeholder="alk_…" /> -

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

+

忘记密码时由管理员发起恢复提案,并由你在已登录设备的「设置 → 账号」中本人确认后获得;新账号使用管理员交付的激活 Key。单次有效、限时兑换。程序化客户端请继续使用 API 密钥(Authorization 头)。

)} @@ -105,23 +107,46 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on ) } -/** 强制设密门:恢复 Key 兑换或初始密码首登建立的受限会话,必须完成设密。 - * 免当前密码(R5):身份与授权均由服务端受限会话确认,页面不出现当前密码输入框; - * 完成前不能进入工作台,完成后的唯一出口是回密码登录页。 */ +/** current_required 模式必须由用户输入当前密码(后端 CompleteForcedPasswordChecked + * 校验);grant_setup(激活/恢复 Key 兑换的受限会话)免当前密码。 */ +export function requiresCurrentPassword(mode: PasswordChangeMode | null | undefined): boolean { + return mode === 'current_required' +} + +/** 强制设密门:受限会话必须完成设密。模式不由前端猜测——进门前先经 /api/me + * 读取服务端派生的 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 (requiresCurrentPassword(mode) && !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(requiresCurrentPassword(mode) ? current : '', next) setDone(true) } catch (e) { setErr(humanChangePasswordError(String(e))) @@ -143,13 +168,19 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
{ e.preventDefault(); submit() }}>
Agent-System

设置新密码

-

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

+

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

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

身份经可信激活/恢复能力确认,此次无需输入当前密码。

} setNext(e.target.value)} autoFocus autoComplete="new-password" /> setConfirm(e.target.value)} autoComplete="new-password" /> - + {onCancel && } )} diff --git a/src/components/MyAccountSettings.tsx b/src/components/MyAccountSettings.tsx new file mode 100644 index 0000000..31b158e --- /dev/null +++ b/src/components/MyAccountSettings.tsx @@ -0,0 +1,142 @@ +// 我的账号与恢复(设置 → 账号):可选资料编辑 + 恢复提案的属主确认视图。 +// 管理员只能建立提案;确认必须使用提案之前已存在、来源可信的完整密码会话 +//(backend eligibleRecoveryProof 契约)。确认产出的一次性 grant 只在本次响应 +// 出现,可就地兑换进受限设密会话(完成前不放行工作台)。 +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' +import { recoveryStatusLabel } from '../recovery' + +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: '备注' }, +] + +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]) + if (!result.grant) { + setErr('提案已确认,但一次性明文不可读回。若仍保有原本人会话,可显式重新生成设密能力。') + return + } + 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]) + if (!result.grant) { + setErr('重新生成已提交,但一次性明文不可读回。请核对当前提案状态,再由本人显式操作。') + return + } + 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 })} /> +
)} +
+
+ :

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

} + +

忘记密码的恢复:由管理员发起提案,本人在此用当前登录会话确认后获得一次性 Key 兑换设密;历史未知来源账号无法在线证明。

+ {(proposals ?? []).map(proposal =>
+
{proposal.id}{recoveryStatusLabel[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..3d1205e 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/adminSections.tsx b/src/components/adminSections.tsx index 1afe886..31fa3e7 100644 --- a/src/components/adminSections.tsx +++ b/src/components/adminSections.tsx @@ -2,18 +2,20 @@ // 全部收进这里,由 AdminZone 以分区注册表呈现。数据入口与原弹窗完全一致 //(api.* 不变),只换呈现位置;插件面板/声明工作区经 UIRenderer 内联渲染。 import { useEffect, useState } from 'react' -import { api } from '../api' +import { api, type RecoveryProposal } from '../api' import { store, useStore } from '../store' import type { AgentProfile } from '../contracts' import { Btn, EmptyState, Icon, SettingRow, SettingsGroup, UIRenderer, type DocBridge } from '@ui' import { SettingInput, flatten, unflatten } from './SettingsModal' import type { SettingDesc } from '../contracts' import { docBridge } from './Workbench' +import { recoveryProposalErrorMessage, recoveryProposalHint, recoveryStatusLabel } from '../recovery' // ---- 用户管理(原 AdminUsersModal 主体,R2 管理员发放制):建号仅创建待初始化 // 账号(HTTP 契约不含密码与管理员位),初始凭证由服务器本地 CLI 发放激活 Key; -// 登录 Key 签发已被恢复提案契约替换(HTTP 410),提案视图为后续增量。---- -interface AdminUserRow { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string } +// 恢复走属主确认制提案:管理员提案 → 用户在「设置 → 账号」本人确认 → 一次性 Key +// 只在属主会话内出现(直接签发 Key 的 HTTP 入口已被 410 替换)。---- +interface AdminUserRow { userId: string; owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number } export function UsersPanel() { const me = useStore(s => s.me) @@ -23,10 +25,16 @@ export function UsersPanel() { const [owner, setOwner] = useState('') const [name, setName] = useState('') const [busy, setBusy] = useState(false) + // 恢复提案面板:propFor 打开指定用户的提案视图(创建 / 状态 / 撤销)。 + const [propFor, setPropFor] = useState(null) + const [proposals, setProposals] = useState(null) const refresh = async () => { try { setUsers(await api.listAdminUsers()) } catch (e) { setErr(String(e)) } } + const refreshProposals = async (userId: string) => { + try { setProposals(await api.listAdminRecoveryProposals(userId)) } catch (e) { setErr(String(e)) } + } useEffect(() => { refresh() }, []) const create = async () => { @@ -40,6 +48,30 @@ export function UsersPanel() { } catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) } } + const openProposals = async (u: AdminUserRow) => { + setErr(''); setNotice(''); setPropFor(u); setProposals(null) + await refreshProposals(u.userId) + } + + const propose = async () => { + if (!propFor) return + setErr(''); setNotice(''); setBusy(true) + try { + await api.createAdminRecoveryProposal(propFor.userId, propFor.stateVersion, crypto.randomUUID()) + setNotice(`已为 ${propFor.owner} 建立恢复提案:请该用户在「设置 → 账号」由本人确认;确认后一次性 Key 只在其会话内出现`) + await refreshProposals(propFor.userId) + } catch (e) { setErr(recoveryProposalErrorMessage(String(e))) } finally { setBusy(false) } + } + + const revokeProposal = async (p: RecoveryProposal) => { + if (!propFor) return + setErr(''); setBusy(true) + try { + await api.adminRevokeRecoveryProposal(propFor.userId, p.id, p.version) + await refreshProposals(propFor.userId) + } catch (e) { setErr(String(e)) } finally { setBusy(false) } + } + return (
{me?.isAdmin && ( @@ -54,21 +86,54 @@ export function UsersPanel() { )} {notice &&
{notice}
} {err &&
{err}
} - - - - {(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() : '—'}
暂无账号
+ {propFor ? ( +
+
+ {propFor.owner} 的恢复提案(属主确认制:管理员提案,本人确认后获一次性 Key) + + +
+

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

+ {proposals !== null && proposals.length > 0 && ( + + + + {proposals.map(p => ( + + + + + + + + ))} + +
提案状态到期时间发起人
{p.id}{recoveryStatusLabel[p.status]}{recoveryProposalHint(p.status) && · {recoveryProposalHint(p.status)}}{new Date(p.expiresAt).toLocaleString()}{p.createdBy}{(p.status === 'pending' || p.status === 'confirmed') && ( + + )}
+ )} + {proposals !== null && proposals.length === 0 &&

该用户当前没有恢复提案。

} +
+ ) : ( + + + + {(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 && ( + + )}
暂无账号
+ )}
) } diff --git a/src/recovery.test.ts b/src/recovery.test.ts new file mode 100644 index 0000000..4b9c7cd --- /dev/null +++ b/src/recovery.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { recoveryProposalErrorMessage, recoveryProposalHint, recoveryStatusLabel } from './recovery' +import { humanChangePasswordError, humanRedeemError, requiresCurrentPassword } from './components/LoginPage' + +// 账户链路文案与模式判定(2026-10-14 current_required 加固后前后端契约对齐的 +// 防漂移锁):改密门必须区分 grant_setup / current_required,恢复提案 409 的 +// 两种语义必须分开表达。 + +describe('requiresCurrentPassword', () => { + it('current_required 必须要当前密码,grant_setup 免当前密码', () => { + expect(requiresCurrentPassword('current_required')).toBe(true) + expect(requiresCurrentPassword('grant_setup')).toBe(false) + }) +}) + +describe('humanChangePasswordError', () => { + it('后端 current password incorrect 映射为当前密码不正确', () => { + expect(humanChangePasswordError('401: current password incorrect')).toBe('当前密码不正确') + }) +}) + +describe('humanRedeemError', () => { + it('401/409/429 语义分离', () => { + expect(humanRedeemError('409: login key already used')).toContain('已被使用') + expect(humanRedeemError('401: invalid or expired login key')).toContain('无效或已过期') + expect(humanRedeemError('429: too many failed attempts')).toContain('稍后再试') + }) +}) + +describe('recoveryStatusLabel', () => { + it('覆盖全部五种提案状态', () => { + for (const status of ['pending', 'confirmed', 'completed', 'revoked', 'expired'] as const) { + expect(recoveryStatusLabel[status]).toBeTruthy() + } + }) +}) + +describe('recoveryProposalHint', () => { + it('pending/confirmed 给出下一步,终态不给', () => { + expect(recoveryProposalHint('pending')).toContain('本人确认') + expect(recoveryProposalHint('confirmed')).toContain('新密码') + expect(recoveryProposalHint('completed')).toBe('') + }) +}) + +describe('recoveryProposalErrorMessage', () => { + it('OWNER_PROOF_UNAVAILABLE 与活动提案冲突分开表达', () => { + expect(recoveryProposalErrorMessage('409: OWNER_PROOF_UNAVAILABLE')).toContain('会话证明') + expect(recoveryProposalErrorMessage('409: store: conflict')).toContain('已有进行中的恢复提案') + }) + it('未识别错误剥状态码原样透出', () => { + expect(recoveryProposalErrorMessage('500: boom')).toBe('boom') + }) +}) diff --git a/src/recovery.ts b/src/recovery.ts new file mode 100644 index 0000000..6b60749 --- /dev/null +++ b/src/recovery.ts @@ -0,0 +1,29 @@ +// 恢复提案展示与错误文案(纯函数):管理端 UsersPanel 与本人 MyAccountSettings +// 共用,vitest 锁定语义防漂移。后端 409 两种含义按响应体区分: +// OWNER_PROOF_UNAVAILABLE=无合格会话证明;其余 conflict=已有活动提案。 +import type { RecoveryProposal } from './api' + +export const recoveryStatusLabel: Record = { + pending: '待本人确认', + confirmed: '已确认,待兑换设密', + completed: '已完成', + revoked: '已撤销', + expired: '已过期', +} + +/** 管理端视角的下一步指引:提案成立后动作在属主侧(设置 → 账号)。 */ +export function recoveryProposalHint(status: RecoveryProposal['status']): string { + if (status === 'pending') return '等待该用户在「设置 → 账号」由本人确认;确认后一次性 Key 只在其会话内出现' + if (status === 'confirmed') return '该用户已确认;请提醒其尽快在受限会话中完成新密码设置,到期未用需重新生成' + return '' +} + +export function recoveryProposalErrorMessage(raw: string): string { + if (raw.includes('OWNER_PROOF_UNAVAILABLE')) { + return '无法发起:该账号没有可用的合格会话证明(需该用户当前在任一设备保持完整密码登录)。历史来源或已锁死账号请走服务器本地处置' + } + if (raw.includes('409')) return '该用户已有进行中的恢复提案;请先撤销旧提案或等它完成/过期' + if (raw.includes('403')) return '需要管理员身份' + if (raw.includes('404')) return '用户不存在或已被移除' + return raw.replace(/^\d{3}:\s*/, '') +}