diff --git a/e2e/login-key.playwright.ts b/e2e/login-key.playwright.ts index bad33ea..323731d 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,17 +51,17 @@ 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() + 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() diff --git a/src/api.ts b/src/api.ts index b6f6744..a2bdaed 100644 --- a/src/api.ts +++ b/src/api.ts @@ -32,14 +32,6 @@ export interface UserRoleView { binding?: UserRoleBinding; role?: RoleRecord } * 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 { @@ -136,14 +128,6 @@ export const api = { auth.beginSession() return me }), - // 一次性登录 Key 兑换(恢复/激活):请求只带 Key,身份由服务端绑定记录决定; - // 成功建立 set_password_only 受限会话(HttpOnly cookie),明文 Key 不落任何存储。 - 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; isSuperAdmin?: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string; passwordChangeMode?: PasswordChangeMode } - auth.beginSession() - return me - }), logout: () => { const request = auth.fetch(`${BASE}/api/auth/logout`, { method: 'POST' }) auth.clear() @@ -170,21 +154,9 @@ export const api = { 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(r)), // 任命/撤销管理员:仅超级管理员(R3);超级管理员本身不可降级(R5)。 adminSetUserAdmin: (userId: string, expectedStateVersion: number, isAdmin: boolean) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/admin`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedStateVersion, isAdmin }) }).then(r => json(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/AccountUsersPanel.tsx b/src/components/AccountUsersPanel.tsx index a05ffd5..a108551 100644 --- a/src/components/AccountUsersPanel.tsx +++ b/src/components/AccountUsersPanel.tsx @@ -16,7 +16,7 @@ const fields: { key: keyof AccountProfile; label: string }[] = [ ] export const credentialLabel = (account: Pick) => - account.initializationState === 'pending' ? '待可信 owner 初始化' : account.passwordSet ? '已设置密码' : '未设置密码' + account.passwordSet ? '已设置密码' : '待设置密码' function ProfileInputs({ profile, onChange, prefix }: { profile: AccountProfile; onChange: (value: AccountProfile) => void; prefix: string }) { return
{fields.map(field =>
@@ -233,7 +233,6 @@ export function AccountUsersPanel() {
{selected.owner}{selected.userId}

{selected.enabled ? '有效账号' : '停用账号'} · {credentialLabel(selected)} · 资料版本 {selected.profileVersion} · 当前角色 {canViewRoles ? (roleView?.role?.name ?? '未分配') : '无角色查看权限'}

- {selected.initializationState === 'pending' &&

交给可信 owner 的初始化定位:userId {selected.userId} · 状态版本 {selected.stateVersion} · 代数 {selected.initializationGeneration}。凭据只经保护文件交付。

} {policy.protectedReason &&

{policy.protectedReason}

} {allowed('system.users.edit') && policy.maintain && diff --git a/src/components/AdminZone.tsx b/src/components/AdminZone.tsx index 7163089..971b394 100644 --- a/src/components/AdminZone.tsx +++ b/src/components/AdminZone.tsx @@ -30,15 +30,15 @@ function ProfilesPanelWrapper() { return } -/** 账号目录(按系统权限开放)+ 登录凭据与恢复提案(根管理员专属,旧 /api/admin/users 列表与密码端点)。 */ +/** 账号目录(按系统权限开放)+ 登录凭据管理(根管理员专属)。 */ function UsersSection() { const isAdmin = useStore(s => !!s.me?.isAdmin) return <> {isAdmin && <>
-

登录凭据与恢复

-

建号并发放初始密码、重置临时密码、发起恢复提案(仅根管理员)

+

登录凭据

+

建号并发放初始密码、生成一次性登录密码(仅根管理员)

} @@ -46,7 +46,7 @@ function UsersSection() { } const SECTIONS: AdminSectionDef[] = [ - { key: 'users', label: '用户管理', icon: 'users', desc: '可选资料、账号状态、唯一生效角色与明确系统权限;根管理员另可发放与恢复登录凭据', requiredAction: 'system.users.view', render: () => }, + { key: 'users', label: '用户管理', icon: 'users', desc: '可选资料、账号状态、唯一生效角色与明确系统权限;根管理员可发放登录凭据', requiredAction: 'system.users.view', render: () => }, { key: 'roles', label: '角色与权限', icon: 'shield', desc: '职位角色、默认权限与上限;支持单角色和角色矩阵配置', requiredAction: 'system.roles.view', render: () => }, { key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', rootOnly: true, render: () => }, // P2 受管:模型/密钥/Tavily/遥测/运行基础设施的唯一写入面(Backend PR 188 系统设置 API)。 diff --git a/src/components/LoginPage.tsx b/src/components/LoginPage.tsx index 0c4e875..e68cdc6 100644 --- a/src/components/LoginPage.tsx +++ b/src/components/LoginPage.tsx @@ -164,15 +164,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 '该能力已被使用过;请由本人核对提案状态,或联系可信 owner 续办初始化' - if (raw.includes('429')) return '尝试次数过多,请稍后再试' - if (raw.includes('401')) return '能力无效或已过期;请核对本人提案状态或联系可信 owner 续办初始化' - 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..7beb141 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 =>
@@ -121,21 +56,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 603d400..2216d55 100644 --- a/src/components/adminSections.tsx +++ b/src/components/adminSections.tsx @@ -1,23 +1,19 @@ // 系统管理分区面板(重设计方案 阶段②):普通用户界面不再暴露的系统能力 // 全部收进这里,由 AdminZone 以分区注册表呈现。数据入口与原弹窗完全一致 //(api.* 不变),只换呈现位置;插件面板/声明工作区经 UIRenderer 内联渲染。 -import { useEffect, useRef, useState } from 'react' -import { api, type RecoveryProposal } from '../api' +import { useEffect, useState } from 'react' +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 { - recoveryAttemptKey, recoveryProposalErrorMessage, recoveryProposalHint, recoveryProposalNotice, recoveryProposalRejected, - recoveryStatusLabel, type RecoveryAttempt, -} from '../recovery' -import { accountActionPolicy, canAppointAdmins } from '../accountAuthority' -import { AdminAppointmentButton, CreateAdminOption, IdentityChip } from './AccountUsersPanel' +import { canAppointAdmins } from '../accountAuthority' +import { CreateAdminOption, IdentityChip } from './AccountUsersPanel' -// ---- 用户管理:页面建号可设置/生成初始密码,管理员重置生成临时密码; -// 保留恢复提案与 Key 管理作为既有非登录页恢复流程。 ---- +// ---- 用户管理:页面建号可设置/生成初始密码;忘记密码的唯一处置是 +// 管理员生成一次性登录密码,用户登录后必须立即改密。 ---- interface AdminUserRow { userId: string; owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number } export function UsersPanel() { @@ -29,27 +25,12 @@ export function UsersPanel() { const [name, setName] = useState('') const [initialPassword, setInitialPassword] = useState('') const [createAsAdmin, setCreateAsAdmin] = useState(false) - 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 proposalSeq = useRef(0) - // 结果未确定的提案请求:显式重试沿用同一 idempotencyKey,不重复创建。 - const recoveryAttempt = useRef(null) const refresh = async () => { try { setUsers(await api.listAdminUsers()) } catch (e) { setErr(String(e)) } } - const refreshProposals = async (userId: string) => { - const seq = proposalSeq.current - try { - const statuses = await api.listAdminRecoveryProposals(userId) - if (seq === proposalSeq.current) setProposals(statuses) - } catch (e) { if (seq === proposalSeq.current) setErr(String(e)) } - } useEffect(() => { refresh() }, []) const create = async () => { @@ -64,86 +45,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 setAdmin = async (u: AdminUserRow, isAdmin: boolean) => { - setErr(''); setNotice(''); setBusy(true) - try { - await api.adminSetUserAdmin(u.userId, u.stateVersion, isAdmin) - setNotice(isAdmin ? `已任命 ${u.owner} 为管理员` : `已撤销 ${u.owner} 的管理员身份`) - await refresh() - } catch (e) { setErr(String(e).includes('409') ? '用户状态已变化,请刷新后重试' : String(e)) } finally { setBusy(false) } - } - - const openProposals = async (u: AdminUserRow) => { - proposalSeq.current++ - setErr(''); setNotice(''); setPropFor(u); setProposals(null) - await refreshProposals(u.userId) - } - const closeProposals = () => { proposalSeq.current++; setPropFor(null); setProposals(null) } - - const propose = async () => { - if (!propFor) return - const target = propFor - const seq = proposalSeq.current - const stillSelected = () => seq === proposalSeq.current - setErr(''); setNotice(''); setBusy(true) - const idempotencyKey = recoveryAttemptKey(recoveryAttempt.current, target.userId, target.stateVersion, () => crypto.randomUUID()) - recoveryAttempt.current = { userId: target.userId, stateVersion: target.stateVersion, key: idempotencyKey } - try { - const created = await api.createAdminRecoveryProposal(target.userId, target.stateVersion, idempotencyKey) - recoveryAttempt.current = null - const statuses = await api.listAdminRecoveryProposals(target.userId).catch(() => null) - if (!stillSelected()) return - if (statuses) setProposals(statuses) - setNotice(recoveryProposalNotice(target.owner, (statuses?.find(p => p.id === created.id) ?? created).status)) - } catch (e) { - const message = String(e) - if (message.includes('409')) { - recoveryAttempt.current = null - await resetPassword(target) - return - } - if (!stillSelected()) return - if (recoveryProposalRejected(message)) { - recoveryAttempt.current = null - setErr(recoveryProposalErrorMessage(message)) - return - } - // 响应丢失或服务端异常时提案可能已提交:先重读元数据,再允许沿用同一 key 显式重试。 - try { - const statuses = await api.listAdminRecoveryProposals(target.userId) - if (!stillSelected()) return - setProposals(statuses) - if (statuses.length > 0) { - setNotice(`提案响应未收到;状态已刷新。最近记录:${recoveryStatusLabel[statuses[0].status]}。请核对下方记录;再次提交会沿用同一请求标识。`) - } else { - setErr('提案结果未确定;状态已刷新。再次提交会沿用同一请求标识。') - } - } catch { - if (stillSelected()) setErr('提案结果未知且状态暂时无法读取;请稍后核对,不会自动重复创建。') - } - } finally { setBusy(false) } - } - - const revokeProposal = async (p: RecoveryProposal) => { - if (!propFor) return - setErr(''); setNotice(''); setBusy(true) - try { - await api.adminRevokeRecoveryProposal(propFor.userId, p.id, p.version) - await refreshProposals(propFor.userId) - setNotice('提案已撤销') - } catch (e) { setErr(String(e)) } finally { setBusy(false) } - } - return (
{me?.isAdmin && ( @@ -160,64 +71,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 => { - const policy = accountActionPolicy(me, u) - return - - - - - - - - })} - {users !== null && users.length === 0 && } - -
用户名显示名角色状态密码设置于
{u.owner}{u.name || '—'}{u.isSuperAdmin ? : u.isAdmin ? ADMIN : USER}{u.mustChangePassword ? 待改密 : u.hasPassword ? '正常' : '未设密码'}{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}{me?.isAdmin && - {policy.resetPassword ? <> - - - : {policy.protectedReason}} - setAdmin(u, isAdmin)} /> - }
暂无账号
- )} + {me?.isAdmin &&

用户忘记密码时,点击该用户行的「生成一次性登录密码」:用户登录后必须立即设置新密码。

} + + + + {(users ?? []).map(u => ( + + + + + + + + + ))} + {users !== null && users.length === 0 && } + +
用户名显示名角色状态密码设置于
{u.owner}{u.name || '—'}{u.isSuperAdmin ? : 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 4337fd4..0000000 --- a/src/recovery.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - recoveryAttemptKey, recoveryProposalErrorMessage, recoveryProposalHint, recoveryProposalNotice, recoveryProposalRejected, 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('recoveryProposalNotice', () => { - it('按服务端当前状态给出结果:待确认、已确认与终态(重复响应只回状态)', () => { - expect(recoveryProposalNotice('alice', 'pending')).toContain('本人确认') - expect(recoveryProposalNotice('alice', 'pending')).toContain('管理员不会收到设密能力') - expect(recoveryProposalNotice('alice', 'confirmed')).toContain('已由本人确认') - expect(recoveryProposalNotice('alice', 'confirmed')).toContain('管理员不会收到设密能力') - for (const status of ['completed', 'revoked', 'expired'] as const) { - const notice = recoveryProposalNotice('alice', status) - expect(notice).toContain(recoveryStatusLabel[status]) - expect(notice).toContain('核对下方记录') - expect(notice).not.toContain('请该用户') - } - }) -}) - -describe('recoveryAttemptKey', () => { - const fresh = () => 'new-key' - it('同一账号同一状态版本的重试沿用未确定结果的 key', () => { - expect(recoveryAttemptKey({ userId: 'usr_1', stateVersion: 3, key: 'old-key' }, 'usr_1', 3, fresh)).toBe('old-key') - }) - it('无未确定请求、换账号或状态版本变化时生成新 key', () => { - expect(recoveryAttemptKey(null, 'usr_1', 3, fresh)).toBe('new-key') - expect(recoveryAttemptKey({ userId: 'usr_2', stateVersion: 3, key: 'old-key' }, 'usr_1', 3, fresh)).toBe('new-key') - expect(recoveryAttemptKey({ userId: 'usr_1', stateVersion: 2, key: 'old-key' }, 'usr_1', 3, fresh)).toBe('new-key') - }) -}) - -describe('recoveryProposalRejected', () => { - it('4xx 为明确拒绝;网络失败与 5xx 结果未确定,需要先重读状态', () => { - expect(recoveryProposalRejected('Error: 403: admin required')).toBe(true) - expect(recoveryProposalRejected('Error: 404: not found')).toBe(true) - expect(recoveryProposalRejected('Error: 502: bad gateway')).toBe(false) - expect(recoveryProposalRejected('TypeError: Failed to fetch')).toBe(false) - }) -}) - -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 497637f..0000000 --- a/src/recovery.ts +++ /dev/null @@ -1,50 +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 '' -} - -/** 建立提案后的结果提示:以服务端返回或重读的当前状态为准。同一 idempotencyKey - * 的重复响应只回状态(可能已是终态),管理员任何时候都拿不到设密能力。 */ -export function recoveryProposalNotice(owner: string, status: RecoveryProposal['status']): string { - if (status === 'pending') return `已为 ${owner} 建立恢复提案:请该用户在「设置 → 账号」由本人确认;确认后一次性 Key 只在其会话内出现,管理员不会收到设密能力` - if (status === 'confirmed') return `${owner} 的恢复提案已由本人确认。只有目标本人可以取得设密能力;管理员不会收到设密能力` - return `${owner} 的恢复提案${recoveryStatusLabel[status]}。如需再次恢复,请核对下方记录` -} - -export interface RecoveryAttempt { userId: string; stateVersion: number; key: string } - -/** 提案请求标识:同一账号、同一状态版本的显式重试沿用上次未确定结果的 key, - * 让响应丢失后的重试落到同一提案而不是重复创建;账号或状态版本变化才换新 key。 */ -export function recoveryAttemptKey(previous: RecoveryAttempt | null, userId: string, stateVersion: number, fresh: () => string): string { - return previous?.userId === userId && previous.stateVersion === stateVersion ? previous.key : fresh() -} - -/** 4xx 是服务端明确拒绝(提案未创建);网络失败或 5xx 时提案可能已提交,结果未确定。 */ -export function recoveryProposalRejected(raw: string): boolean { - return /\b4\d\d:/.test(raw) -} - -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/account-users-panel.test.ts b/test/account-users-panel.test.ts index 4085e92..9d4c260 100644 --- a/test/account-users-panel.test.ts +++ b/test/account-users-panel.test.ts @@ -2,9 +2,9 @@ import { describe, expect, it } from 'vitest' import { credentialLabel } from '../src/components/AccountUsersPanel' describe('account directory credential state', () => { - it('shows pending trusted-owner initialization before password state', () => { - expect(credentialLabel({ initializationState: 'pending', passwordSet: false })).toBe('待可信 owner 初始化') + it('shows whether password is ready without legacy initialization labels', () => { + expect(credentialLabel({ initializationState: 'pending', passwordSet: false })).toBe('待设置密码') expect(credentialLabel({ initializationState: 'initialized', passwordSet: true })).toBe('已设置密码') - expect(credentialLabel({ initializationState: 'legacy', passwordSet: false })).toBe('未设置密码') + expect(credentialLabel({ initializationState: 'legacy', passwordSet: false })).toBe('待设置密码') }) }) diff --git a/test/loginpage.test.ts b/test/loginpage.test.ts index afaa887..6bc6b81 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 } from '../src/components/LoginPage' describe('humanLoginError(登录)', () => { it('401 统一为用户名或密码不正确', () => { @@ -31,17 +31,6 @@ describe('humanChangePasswordError(改密门)', () => { }) }) -describe('humanRedeemError(可信激活与本人恢复能力兑换)', () => { - it('409 已使用 / 401 无效过期 / 429 退避各有文案', () => { - 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('未知错误剥状态码前缀透传', () => { - expect(humanRedeemError('400: bad')).toBe('bad') - }) -}) - describe('humanChangePasswordError(登录恢复新增文案)', () => { it('并发凭据变化给出重新登录指引', () => { expect(humanChangePasswordError('401: credentials changed concurrently; retry login')).toBe('登录状态已变化(密码在其他地方被更新),请重新登录') diff --git a/test/one-time-password.test.ts b/test/one-time-password.test.ts new file mode 100644 index 0000000..7990404 --- /dev/null +++ b/test/one-time-password.test.ts @@ -0,0 +1,56 @@ +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('旧恢复与 key 契约已移除', () => { + it('src 不再调用恢复提案、登录 key 兑换与管理端点', () => { + const hits = walk(SRC).filter(p => /recovery-proposals|RecoveryProposal|recovery-grants\/redeem|redeemLoginKey|adminListLoginKeys|adminRevokeLoginKey/.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).toContain('使用后必须强制改密') + expect(panel.match(/aria-label="一次性密码"/g)).toHaveLength(1) + }) +}) + +describe('登录与个人设置入口', () => { + const login = read('components', 'LoginPage.tsx') + const loginPage = login.slice(login.indexOf('export function LoginPage'), login.indexOf('export function requiresCurrentPassword')) + const settings = read('components', 'MyAccountSettings.tsx') + + it('登录页只保留用户名 + 密码入口', () => { + expect(loginPage.match(/ { + expect(settings).toContain('请联系管理员生成一次性登录密码') + expect(settings).toContain('强制改密') + expect(settings).not.toMatch(/由本人确认|重新生成设密能力|立即兑换/) + }) +})