From a1e7e4680f00ec05d9f7d0cf6e1ecef2cd0d1a77 Mon Sep 17 00:00:00 2001 From: agent-exec-transport Date: Sun, 18 Oct 2026 04:04:20 +0000 Subject: [PATCH] agent-exec transport d6268c248ca4bf77b9c9974ad0aa0162/63feb6000719 --- e2e/login-key.playwright.ts | 22 ++--- src/api.ts | 32 +------ src/components/AdminZone.tsx | 2 +- src/components/LoginPage.tsx | 14 +-- src/components/MyAccountSettings.tsx | 96 ++------------------- src/components/adminSections.tsx | 123 +++++++-------------------- src/recovery.test.ts | 54 ------------ src/recovery.ts | 29 ------- test/loginpage.test.ts | 14 ++- test/one-time-password.test.ts | 62 ++++++++++++++ 10 files changed, 124 insertions(+), 324 deletions(-) delete mode 100644 src/recovery.test.ts delete mode 100644 src/recovery.ts create mode 100644 test/one-time-password.test.ts diff --git a/e2e/login-key.playwright.ts b/e2e/login-key.playwright.ts index bad33ea..505eb94 100644 --- a/e2e/login-key.playwright.ts +++ b/e2e/login-key.playwright.ts @@ -1,5 +1,5 @@ -// 管理员随机临时密码与登录入口回归:管理员重置 → 用户密码登录 → 强制改密; -// 登录页不再提供一次性 Key 登录 tab。Key 的分发/管理 API 与非登录页流程保留。 +// 忘记密码唯一处置回归:管理员生成一次性登录密码 → 用户密码登录 → 强制改密; +// 用户行不再有恢复提案入口,登录页只保留用户名 + 密码,不提供一次性 Key 登录 tab。 // 驱动:任意 auth-on 实例(PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE; // scripts/e2e.sh 第 4 阶段顺路覆盖,或对隔离实例直连——见 Dev 任务卡 // feature/svc-login-recovery-flow 的真实浏览器验收记录)。 @@ -51,30 +51,32 @@ async function adminLogin(page: Page) { await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 }) } -test('管理员随机重置:临时密码登录后强制改密;登录页不提供 Key 登录', async ({ page }) => { +test('管理员生成一次性登录密码:登录后强制改密;登录页不提供 Key 登录', async ({ page }) => { // 全流程含多次 argon2 登录与两道门,30s 默认预算不够。 test.setTimeout(120_000) - // 1) 管理员进入用户管理,为目标用户生成随机临时密码(明文仅显示一次)。 + // 1) 管理员进入用户管理,为目标用户生成一次性登录密码(明文仅显示一次)。 await adminLogin(page) await page.locator('.tsb-user').click() await page.getByRole('button', { name: '系统管理…' }).click() const zone = page.getByRole('dialog', { name: '系统管理' }) await expect(zone).toBeVisible() const row = zone.locator('tr', { hasText: USER }) - await row.getByRole('button', { name: '重置临时密码' }).click() - const temporaryPassword = zone.locator('input[aria-label="一次性密码"]') - await expect(temporaryPassword).toBeVisible({ timeout: 10_000 }) - const password = await temporaryPassword.inputValue() + await expect(row.getByRole('button', { name: '恢复提案' })).toHaveCount(0) + await row.getByRole('button', { name: '生成一次性登录密码' }).click() + const oneTimePassword = zone.locator('input[aria-label="一次性密码"]') + await expect(oneTimePassword).toBeVisible({ timeout: 10_000 }) + await expect(zone.getByText('使用后必须强制改密')).toBeVisible() + const password = await oneTimePassword.inputValue() expect(password.length).toBeGreaterThan(0) // 2) 管理员退出系统管理与登录态,回到登录页。 - //(Key 面板内也有「关闭」;退出全屏层要点头部的「返回工作台」。) + //(一次性密码展示框内也有「关闭」;退出全屏层要点头部的「返回工作台」。) await zone.getByRole('button', { name: '返回工作台' }).click() await page.locator('.tsb-user').click() await page.getByRole('button', { name: '退出登录' }).click() await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible({ timeout: 10_000 }) - // 3) 用户使用临时密码登录后进入强制改密门。 + // 3) 用户使用一次性登录密码登录后进入强制改密门。 await passwordLogin(page, USER, password) const gate = page.locator('.lg-card') await expect(gate.getByText(`账号 ${USER}`)).toBeVisible({ timeout: 10_000 }) diff --git a/src/api.ts b/src/api.ts index 5a60fb0..6a24282 100644 --- a/src/api.ts +++ b/src/api.ts @@ -20,14 +20,6 @@ export interface AdminZoneSection { id: string; title: string; icon?: string; de * 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 { @@ -87,14 +79,6 @@ export const api = { 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 => { - 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 } - auth.beginSession() - return me - }), logout: () => { const request = auth.fetch(`${BASE}/api/auth/logout`, { method: 'POST' }) auth.clear() @@ -107,22 +91,12 @@ export const api = { 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)), // 管理员建号:可提供初始密码;省略密码时由服务端生成并仅返回一次。 createAdminUser: (owner: string, name: string, password?: string) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile: { name }, ...(password ? { password } : {}) }) }).then(r => json<{ userId: string; owner: string; passwordSet: boolean; mustChangePassword: boolean; initializationState: string; initialPassword?: string }>(r)), + // 忘记密码的唯一处置:管理员生成一次性登录密码(明文只在本次响应出现); + // 用户以该密码登录后进入 current_required 强制改密门,设置新密码后才放行。 adminResetUserPassword: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }).then(r => json<{ owner: string; temporaryPassword: string; mustChangePassword: boolean }>(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/AdminZone.tsx b/src/components/AdminZone.tsx index 1f6a8e0..8b99513 100644 --- a/src/components/AdminZone.tsx +++ b/src/components/AdminZone.tsx @@ -28,7 +28,7 @@ function ProfilesPanelWrapper() { } const SECTIONS: AdminSectionDef[] = [ - { key: 'users', label: '用户管理', icon: 'users', desc: '建号、发放初始密码、重置密码(管理员发放制)', render: () => }, + { key: 'users', label: '用户管理', icon: 'users', desc: '建号、发放初始密码、生成一次性登录密码(管理员发放制)', render: () => }, // U6/KTD5:RDM 专业角色管理——命令面齐备(rdmRolesAvailable)才显示;普通基座零暴露。 { key: 'rdm-roles', label: 'RDM 角色权限', icon: 'shield', desc: '专业角色:动作默认/上限、账号绑定、停用(仅管理员)', render: () => }, { key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', render: () => }, diff --git a/src/components/LoginPage.tsx b/src/components/LoginPage.tsx index 220fe40..5d74d23 100644 --- a/src/components/LoginPage.tsx +++ b/src/components/LoginPage.tsx @@ -61,7 +61,7 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
{ e.preventDefault(); submitPassword() }}>
Agent-System

登录

-

账号由管理员发放;忘记密码请联系管理员重置临时密码

+

账号由管理员发放;忘记密码请联系管理员生成一次性登录密码,使用后须立即改密

setOwner(e.target.value)} autoFocus autoComplete="username" placeholder="由管理员分配" /> @@ -97,7 +97,7 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string; if (!active) return if (me.owner !== owner || me.sessionScope !== 'set_password_only' || (me.passwordChangeMode !== 'grant_setup' && me.passwordChangeMode !== 'current_required')) { - setErr('当前设密凭据不再有效,请重新登录;恢复场景请先核对「设置 → 账号」中的恢复提案状态。') + setErr('当前设密凭据不再有效,请重新登录;忘记密码请联系管理员重新生成一次性登录密码。') return } setMode(me.passwordChangeMode) @@ -137,6 +137,7 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;

设置新密码

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

{mode === 'current_required' && <> +

初始密码或管理员生成的一次性登录密码使用后必须强制改密;「当前密码」填写刚才登录用的密码。

setCurrent(e.target.value)} autoComplete="current-password" /> @@ -164,15 +165,6 @@ export function humanLoginError(raw: string): string { return raw.replace(/^\d{3}:\s*/, '') } -/** 一次性登录 Key 兑换文案:401/409/429 语义不同,逐条映射防漂移。 */ -export function humanRedeemError(raw: string): string { - if (raw.includes('409')) return '该 Key 已被使用过,请联系管理员重新签发' - if (raw.includes('429')) return '尝试次数过多,请稍后再试' - if (raw.includes('401')) return 'Key 无效或已过期,请联系管理员重新签发' - if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)' - return raw.replace(/^\d{3}:\s*/, '') -} - /** 改密门错误文案:改密的 401 有多种真实原因(会话过期/并发失效/退避), * 不能沿用登录的「用户名或密码不正确」一刀切。 * 后端文案是现行 HTTP 契约的一部分,这里按其区分并用测试锁定。 */ diff --git a/src/components/MyAccountSettings.tsx b/src/components/MyAccountSettings.tsx index 31b158e..ab5a929 100644 --- a/src/components/MyAccountSettings.tsx +++ b/src/components/MyAccountSettings.tsx @@ -1,12 +1,9 @@ -// 我的账号与恢复(设置 → 账号):可选资料编辑 + 恢复提案的属主确认视图。 -// 管理员只能建立提案;确认必须使用提案之前已存在、来源可信的完整密码会话 -//(backend eligibleRecoveryProof 契约)。确认产出的一次性 grant 只在本次响应 -// 出现,可就地兑换进受限设密会话(完成前不放行工作台)。 +// 我的账号(设置 → 账号):可选资料编辑 + 忘记密码指引。忘记密码只有一个处置: +// 由管理员生成一次性登录密码,用户用它登录后进入强制改密门,设置新密码才放行。 import { useEffect, useRef, useState } from 'react' -import { api, type AccountProfile, type AccountRecord, type RecoveryProposal } from '../api' +import { api, type AccountProfile, type AccountRecord } 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: '邮箱' }, @@ -17,8 +14,6 @@ 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) @@ -27,9 +22,9 @@ export function MyAccountSettings() { useEffect(() => { if (!me?.auth) return const seq = ++sequence.current - Promise.all([api.meProfile(), api.listMyRecoveryProposals()]).then(([profile, statuses]) => { + api.meProfile().then(profile => { if (seq !== sequence.current) return - setAccount(profile); setDraft(profile.profile); setProposals(statuses) + setAccount(profile); setDraft(profile.profile) }).catch(error => { if (seq === sequence.current) setErr(String(error)) }) return () => { sequence.current++ } }, [me?.owner, me?.auth]) @@ -44,74 +39,14 @@ export function MyAccountSettings() { setBusy(true); setErr(''); setNotice('') try { const updated = await api.patchMeProfile(account.profileVersion, patch) - setAccount(updated); setDraft(updated.profile); setNotice('个人资料已保存;资料不会成为恢复身份的证明') + 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 + return {account && draft ? <> -

登录名 {account.owner} · userId {account.userId}。以下资料均可留空,也不会作为本人恢复证明。

+

登录名 {account.owner} · userId {account.userId}。以下资料均可留空。

{fields.map(field =>
@@ -122,20 +57,7 @@ export function MyAccountSettings() { :

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

} -

忘记密码的恢复:由管理员发起提案,本人在此用当前登录会话确认后获得一次性 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/adminSections.tsx b/src/components/adminSections.tsx index ad04896..dafa33f 100644 --- a/src/components/adminSections.tsx +++ b/src/components/adminSections.tsx @@ -2,17 +2,16 @@ // 全部收进这里,由 AdminZone 以分区注册表呈现。数据入口与原弹窗完全一致 //(api.* 不变),只换呈现位置;插件面板/声明工作区经 UIRenderer 内联渲染。 import { useEffect, useState } from 'react' -import { api, type RecoveryProposal } from '../api' +import { api } 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' -// ---- 用户管理:页面建号可设置/生成初始密码,管理员重置生成临时密码; -// 保留恢复提案与 Key 管理作为既有非登录页恢复流程。 ---- +// ---- 用户管理:页面建号可设置/生成初始密码;忘记密码只有一个处置—— +// 管理员生成一次性登录密码,用户使用后必须强制改密。 ---- interface AdminUserRow { userId: string; owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number } export function UsersPanel() { @@ -23,18 +22,12 @@ export function UsersPanel() { const [owner, setOwner] = useState('') const [name, setName] = useState('') const [initialPassword, setInitialPassword] = useState('') - const [generatedCredential, setGeneratedCredential] = useState<{ owner: string; password: string; kind: 'initial' | 'temporary' } | null>(null) + const [generatedCredential, setGeneratedCredential] = useState<{ owner: string; password: string; kind: 'initial' | 'one-time' } | null>(null) 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 () => { @@ -49,46 +42,16 @@ export function UsersPanel() { } catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) } } - const resetPassword = async (u: AdminUserRow) => { + const issueOneTimePassword = async (u: AdminUserRow) => { setErr(''); setNotice(''); setGeneratedCredential(null); setBusy(true) try { const out = await api.adminResetUserPassword(u.owner) - setGeneratedCredential({ owner: u.owner, password: out.temporaryPassword, kind: 'temporary' }) - setNotice(`已为 ${u.owner} 生成临时密码。该用户登录后必须立即修改密码。`) + setGeneratedCredential({ owner: u.owner, password: out.temporaryPassword, kind: 'one-time' }) + setNotice(`已为 ${u.owner} 生成一次性登录密码,原密码已失效。该用户用它登录后必须立即设置新密码(强制改密)。`) await refresh() } catch (e) { setErr(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) { - if (String(e).includes('409')) { - await resetPassword(propFor) - return - } - 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 && ( @@ -104,60 +67,32 @@ export function UsersPanel() { )} {notice &&
{notice}
} {generatedCredential &&
-

{generatedCredential.kind === 'initial' ? '初始密码' : '临时密码'}仅显示一次,请立即安全交付给 {generatedCredential.owner}。

+

{generatedCredential.kind === 'initial' + ? `初始密码仅显示一次,请立即安全交付给 ${generatedCredential.owner}。` + : `一次性登录密码仅显示一次,请立即安全交付给 ${generatedCredential.owner};使用后必须强制改密。`}

} {err &&
{err}
} - {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 && - - - }
暂无账号
- )} + {me?.isAdmin &&

用户忘记密码时,点该用户行的「生成一次性登录密码」:原密码立即失效,用户用一次性密码登录后必须设置新密码才能继续。

} + + + + {(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 deleted file mode 100644 index 4b9c7cd..0000000 --- a/src/recovery.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -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 deleted file mode 100644 index 6b60749..0000000 --- a/src/recovery.ts +++ /dev/null @@ -1,29 +0,0 @@ -// 恢复提案展示与错误文案(纯函数):管理端 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*/, '') -} diff --git a/test/loginpage.test.ts b/test/loginpage.test.ts index 1aa008c..2868a3c 100644 --- a/test/loginpage.test.ts +++ b/test/loginpage.test.ts @@ -1,7 +1,7 @@ // 登录/改密门错误文案:登录与改密的 401 语义不同,映射分开锁定 // (后端错误串是现行 HTTP 契约的一部分,这里逐字对拍防漂移)。 import { describe, expect, it } from 'vitest' -import { humanChangePasswordError, humanLoginError, humanRedeemError } from '../src/components/LoginPage' +import { humanChangePasswordError, humanLoginError, requiresCurrentPassword } from '../src/components/LoginPage' describe('humanLoginError(登录)', () => { it('401 统一为用户名或密码不正确', () => { @@ -31,14 +31,10 @@ describe('humanChangePasswordError(改密门)', () => { }) }) -describe('humanRedeemError(一次性登录 Key 兑换)', () => { - 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('429: too many failed attempts; retry later')).toBe('尝试次数过多,请稍后再试') - }) - it('未知错误剥状态码前缀透传', () => { - expect(humanRedeemError('400: bad')).toBe('bad') +describe('requiresCurrentPassword(强制改密门模式)', () => { + it('current_required(初始/一次性登录密码首登)必须要当前密码,grant_setup 免当前密码', () => { + expect(requiresCurrentPassword('current_required')).toBe(true) + expect(requiresCurrentPassword('grant_setup')).toBe(false) }) }) diff --git a/test/one-time-password.test.ts b/test/one-time-password.test.ts new file mode 100644 index 0000000..21d25a3 --- /dev/null +++ b/test/one-time-password.test.ts @@ -0,0 +1,62 @@ +// 忘记密码唯一处置守护:管理员生成一次性登录密码 → 用户登录 → 强制改密。 +// 恢复提案 UI/API 已整体移除;登录页只保留用户名 + 密码一种凭据入口。 +import { describe, expect, it } from 'vitest' +import { readFileSync, readdirSync, statSync } from 'node:fs' +import { join, relative, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const SRC = join(dirname(fileURLToPath(import.meta.url)), '..', 'src') +const read = (...parts: string[]) => readFileSync(join(SRC, ...parts), 'utf8') + +function walk(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir)) { + const p = join(dir, name) + if (statSync(p).isDirectory()) walk(p, out) + else if (/\.(ts|tsx)$/.test(p)) out.push(p) + } + return out +} + +describe('恢复提案已移除', () => { + it('src 不再调用恢复提案或登录 Key 兑换端点', () => { + const hits = walk(SRC).filter(p => /recovery-proposals|RecoveryProposal|login-key\/redeem|redeemLoginKey/.test(readFileSync(p, 'utf8'))) + expect(hits.map(p => relative(SRC, p))).toEqual([]) + }) +}) + +describe('管理员用户管理:唯一忘记密码动作', () => { + const panel = read('components', 'adminSections.tsx') + it('每行只有「生成一次性登录密码」一个按钮,无恢复提案或重置临时密码入口', () => { + expect(panel.match(/>生成一次性登录密码 { + expect(panel).toContain('使用后必须强制改密') + expect(panel.match(/aria-label="一次性密码"/g)).toHaveLength(1) + expect(panel).not.toMatch(/localStorage|sessionStorage|clipboard/) + }) +}) + +describe('登录页:仅用户名 + 密码', () => { + const login = read('components', 'LoginPage.tsx') + const loginPage = login.slice(login.indexOf('export function LoginPage'), login.indexOf('export function requiresCurrentPassword')) + it('只有一个用户名输入与一个密码输入,无第二凭据入口', () => { + expect(loginPage.match(/ { + expect(loginPage).toContain('生成一次性登录密码,使用后须立即改密') + }) +}) + +describe('我的账号:无恢复提案确认', () => { + const settings = read('components', 'MyAccountSettings.tsx') + it('只给出联系管理员生成一次性登录密码的指引,没有可操作的恢复按钮', () => { + expect(settings).toContain('请联系管理员生成一次性登录密码') + expect(settings).toContain('强制改密') + expect(settings).not.toMatch(/由本人确认|重新生成设密能力|兑换/) + }) +})