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
This commit is contained in:
weichen
2026-10-14 04:58:46 +00:00
parent 731c9b7776
commit 010540f203
2 changed files with 10 additions and 2 deletions
+2 -2
View File
@@ -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<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)),
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)),
+8
View File
@@ -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) {