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
143 lines
8.0 KiB
TypeScript
143 lines
8.0 KiB
TypeScript
// 我的账号与恢复(设置 → 账号):可选资料编辑 + 恢复提案的属主确认视图。
|
||
// 管理员只能建立提案;确认必须使用提案之前已存在、来源可信的完整密码会话
|
||
//(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<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])
|
||
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 <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">忘记密码的恢复:由管理员发起提案,本人在此用当前登录会话确认后获得一次性 Key 兑换设密;历史未知来源账号无法在线证明。</p>
|
||
{(proposals ?? []).map(proposal => <div className="ad-create" key={proposal.id}>
|
||
<div className="ad-row"><span className="ad-mono">{proposal.id}</span><b>{recoveryStatusLabel[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>
|
||
}
|