From 9fc7da2ace8578d87d2601adcbf831e0b955aa68 Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 03:42:23 +0000 Subject: [PATCH] Wire login recovery flow: redeem key entry and restricted set-password gate Why: The 2026-10-13 login contract makes username/password the default login, replaces the browser ask_ tab with a one-time recovery key entry, requires the forced set-password gate to drop the current password field, and routes admin password recovery through admin-issued login keys instead of direct password resets. Evidence: vitest 218 passed, npm run build OK, check-knip 43<=43, design-guard green (SELF, local run in task worktree). Impact: LoginPage gains a redeem-only key tab (no key persistence); ChangePasswordGate shows server-confirmed owner plus new/confirm only and returns to the login page after success (reloginRequired); App gates on sessionScope=set_password_only; UsersPanel replaces reset password with issue/revoke login key UI showing plaintext once. Refs: Dev task feature/svc-login-recovery-flow; backend companion commit 467718a. --- e2e/changepw-form.playwright.ts | 11 ++- src/App.tsx | 15 ++-- src/api.ts | 17 +++-- src/components/LoginPage.tsx | 113 +++++++++++++++++-------------- src/components/adminSections.tsx | 93 +++++++++++++++++++------ test/loginpage.test.ts | 19 +++++- 6 files changed, 186 insertions(+), 82 deletions(-) diff --git a/e2e/changepw-form.playwright.ts b/e2e/changepw-form.playwright.ts index d186727..2f9f947 100644 --- a/e2e/changepw-form.playwright.ts +++ b/e2e/changepw-form.playwright.ts @@ -20,11 +20,16 @@ async function login(page: Page) { const gate = page.getByText('修改密码') const gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false) if (gated) { + // 受限设密门(2026-10-13 合同):只有新密码/确认两框,无当前密码输入。 const pwInputs = page.locator('.lg-card input[type="password"]') - await pwInputs.nth(0).fill(PASSWORD) + await pwInputs.nth(0).fill(PASSWORD2) await pwInputs.nth(1).fill(PASSWORD2) - await pwInputs.nth(2).fill(PASSWORD2) - await page.getByRole('button', { name: '设置新密码并进入' }).click() + await page.getByRole('button', { name: '设置新密码', exact: true }).click() + // 设密成功后服务端销毁会话:完成页唯一出口是回密码登录页重新登录。 + await page.getByRole('button', { name: '返回登录' }).click() + await page.getByPlaceholder('由管理员分配').fill(OWNER) + await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2) + await page.getByRole('button', { name: '登录', exact: true }).click() } else { await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2) await page.getByRole('button', { name: '登录', exact: true }).click() diff --git a/src/App.tsx b/src/App.tsx index 6cf56a9..94a8180 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -91,11 +91,14 @@ export function App() { try { const r = await auth.fetch('/api/health') if (r.status === 401) { setAuthState('login'); return } - // 已持有有效会话 cookie(或密钥)时直接放行;会话携带 must_change 时先进改密门。 - // 改密接口只认会话登录:API key 凭证进改密门是死路(提交必 401), - // key 持有者清 key 回登录页,改走密码登录完成改密。 + // 已持有有效会话 cookie(或密钥)时直接放行。设密门依据 sessionScope 判定 + //(2026-10-13 合同):恢复 Key 兑换与 must-change 首登都建立 + // set_password_only 受限会话,仅看 mustChangePassword 会漏掉已有密码的 + // 恢复会话。改密接口只认会话登录:API key 凭证进改密门是死路(提交必 + // 401),key 持有者清 key 回登录页,改走密码登录完成改密。 const me = await api.me().catch(() => null) - if (me?.mustChangePassword) { + const gated = me?.sessionScope === 'set_password_only' || me?.mustChangePassword + if (gated) { if (me.authVia === 'key') { auth.clear(); setAuthState('login'); return } setMustChangeOwner(me.owner); setAuthState('mustchange'); return } @@ -173,9 +176,11 @@ export function App() { onMustChange={owner => { setMustChangeOwner(owner); setAuthState('mustchange') }} /> } if (authState === 'mustchange') { + // 受限会话设密成功后服务端销毁全部会话并返回 reloginRequired=true: + // 唯一后继动作是回密码登录页,不得因历史 onDone 误放行进工作台。 return { await store.refreshMe().catch(() => undefined); setAuthState('ok') }} + onDone={() => { api.logout().catch(() => undefined); setAuthState('login') }} onCancel={() => { api.logout().catch(() => undefined); setAuthState('login') }} /> } if (authState !== 'ok') { diff --git a/src/api.ts b/src/api.ts index 5ee4594..e416742 100644 --- a/src/api.ts +++ b/src/api.ts @@ -42,20 +42,29 @@ 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; isAdmin?: boolean; mustChangePassword?: boolean }>(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)), // ---- 认证与会话(管理员发放制,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()}`) - return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean }> + return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string }> + }), + // 一次性登录 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()}`) + return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string }> }), logout: () => auth.fetch(`${BASE}/api/auth/logout`, { method: 'POST' }), changePassword: (current: string, next: string) => auth.fetch(`${BASE}/api/auth/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ current, next }) }).then(async r => { if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`) - return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean }> + 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)), createAdminUser: (owner: string, name: string, password: string, isAdmin: boolean) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, name, password, isAdmin }) }).then(r => json<{ owner: string; mustChangePassword: boolean }>(r)), - resetAdminUserPassword: (owner: string, password: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) }).then(r => json<{ owner: string; mustChangePassword: boolean }>(r)), + // ---- 管理员:一次性登录 Key(忘记密码恢复主入口;旧"直接重置密码"接口退出常规 UI)---- + adminIssueLoginKey: (owner: string, ttlMinutes?: number) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ purpose: 'recover', ...(ttlMinutes ? { ttlMinutes } : {}) }) }).then(r => json<{ id: string; owner: string; purpose: string; status: string; expiresAt: string; key: string }>(r)), + 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)), 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(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(r)), diff --git a/src/components/LoginPage.tsx b/src/components/LoginPage.tsx index 0ba4a5c..6c51c64 100644 --- a/src/components/LoginPage.tsx +++ b/src/components/LoginPage.tsx @@ -1,6 +1,6 @@ -// 登录与改密(管理员发放制,R2): -// LoginPage —— 用户名+密码(主)/ API 密钥(兼容程序化客户端)双入口; -// ChangePasswordGate —— 初始/重置密码后的首登强制改密(不完成不放行)。 +// 登录与改密(管理员发放制,R2 + 2026-10-13 登录恢复合同): +// LoginPage —— 用户名+密码(默认入口)/ 一次性登录 Key(忘记密码恢复,只输 Key); +// ChangePasswordGate —— 受限会话强制设密(恢复/首登同形:只输新密码,完成前不放行)。 import { useState } from 'react' import { auth } from '../auth' import { api } from '../api' @@ -28,9 +28,11 @@ function BrandPanel() { ) } -/** 登录页:认证开启(PG 档/–-auth on)时的唯一入口。 */ +/** 登录页:认证开启(PG 档/–-auth on)时的唯一入口。 + * 长期 API 密钥(ask_)不再作为浏览器日常登录入口——程序化客户端直接带 + * Authorization 头;浏览器保存长期密钥属于持久凭据暴露面。 */ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; onMustChange: (owner: string) => void }) { - const [tab, setTab] = useState<'password' | 'apikey'>('password') + const [tab, setTab] = useState<'password' | 'loginkey'>('password') const [owner, setOwner] = useState('') const [password, setPassword] = useState('') const [key, setKey] = useState('') @@ -43,8 +45,9 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on const me = await api.login(owner.trim(), password) // 会话凭证是 HttpOnly cookie;清残留 Bearer,避免其遮蔽有效会话。 auth.beginSession() - if (me.mustChangePassword) { - // 初始/重置密码:先完成强制改密再放行。 + // 受限会话(must-change 首登)或正常会话都由后端 sessionScope 声明; + // 受限会话先进设密门,完成前不放行。 + if (me.sessionScope === 'set_password_only' || me.mustChangePassword) { onMustChange(me.owner) return } @@ -56,24 +59,17 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on const submitKey = async () => { setErr(''); setBusy(true) - auth.setKey(key) try { - const r = await auth.fetch('/api/health') - if (r.ok) { - // 账号处于初始/重置密码状态时,key 完成不了强制改密(改密只认会话登录): - // 不放行进死路改密门,引导改走账号密码登录。 - const me = await api.me().catch(() => null) - if (me?.mustChangePassword) { - auth.clear() - setErr('该账号使用初始/重置密码,请先用账号密码登录完成改密') - return - } - onAuthed(); return + // 只提交 Key:目标账户由服务端绑定记录确定,客户端不能指定身份。 + const me = await api.redeemLoginKey(key.trim()) + auth.beginSession() + if (me.sessionScope === 'set_password_only' || me.mustChangePassword) { + onMustChange(me.owner) + return } - throw new Error(String(r.status)) - } catch { - auth.clear() - setErr('密钥无效或已撤销,请重试') + onAuthed() + } catch (e) { + setErr(humanRedeemError(String(e))) } finally { setBusy(false) } } @@ -84,10 +80,10 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
{ e.preventDefault(); tab === 'password' ? submitPassword() : submitKey() }}>
Agent-System

登录

-

账号由管理员发放;首次登录需修改初始密码

+

账号由管理员发放;忘记密码请联系管理员签发一次性登录 Key

- +
{tab === 'password' ? ( <> @@ -100,11 +96,11 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on ) : ( <> - - setKey(e.target.value)} autoFocus placeholder="ask_…" /> + + setKey(e.target.value)} autoFocus placeholder="alk_…" /> - -

密钥由服务端创建:agent-system apikey create --owner <name>。程序化客户端也可直接带 Authorization 头。

+ +

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

)} @@ -113,13 +109,15 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on ) } -/** 强制改密门:初始密码/管理员重置后的首次登录必须完成改密才可进入系统。 */ +/** 强制设密门:恢复 Key 兑换或初始密码首登建立的受限会话,必须完成设密。 + * 免当前密码(R5):身份与授权均由服务端受限会话确认,页面不出现当前密码输入框; + * 完成前不能进入工作台,完成后的唯一出口是回密码登录页。 */ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string; onDone: () => void; onCancel?: () => void }) { - 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) const submit = async () => { setErr('') @@ -127,8 +125,8 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string; if (next.length < 8) { setErr('新密码至少 8 位'); return } setBusy(true) try { - await api.changePassword(current, next) - onDone() + await api.changePassword('', next) + setDone(true) } catch (e) { setErr(humanChangePasswordError(String(e))) } finally { setBusy(false) } @@ -138,20 +136,27 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
-
{ e.preventDefault(); submit() }}> -
Agent-System
-

修改密码

-

账号 {owner} 使用初始/重置密码首次登录,须设置新密码后继续

- - setCurrent(e.target.value)} autoFocus autoComplete="current-password" /> - - setNext(e.target.value)} autoComplete="new-password" /> - - setConfirm(e.target.value)} autoComplete="new-password" /> - - - {onCancel && } - + {done ? ( +
{ e.preventDefault(); onDone() }}> +
Agent-System
+

密码已更新

+

请使用新密码重新登录账号 {owner}

+ +
+ ) : ( +
{ e.preventDefault(); submit() }}> +
Agent-System
+

设置新密码

+

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

+ + setNext(e.target.value)} autoFocus autoComplete="new-password" /> + + setConfirm(e.target.value)} autoComplete="new-password" /> + + + {onCancel && } + + )}
) @@ -165,13 +170,23 @@ export function humanLoginError(raw: string): string { return raw.replace(/^\d{3}:\s*/, '') } -/** 改密门错误文案:改密的 401 有多种真实原因(当前密码错/无会话/会话过期/失效 key), - * 不能沿用登录的「用户名或密码不正确」一刀切——那会把可自愈的凭证问题伪装成密码错误。 +/** 一次性登录 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 契约的一部分,这里按其区分并用测试锁定。 */ export function humanChangePasswordError(raw: string): string { if (raw.includes('current password incorrect')) return '当前密码不正确' if (raw.includes('session login required')) return '请先用账号密码登录再改密(API 密钥不能修改密码)' if (raw.includes('session expired')) return '会话已过期,请重新登录' + if (raw.includes('credentials changed concurrently')) return '登录状态已变化(密码在其他地方被更新),请重新登录' if (raw.includes('invalid or revoked API key')) return '本地残留的 API 密钥已失效,请退出登录后重试' if (raw.includes('429')) return '失败次数过多,请稍后再试' if (raw.includes('401')) return '认证失败,请重新登录' diff --git a/src/components/adminSections.tsx b/src/components/adminSections.tsx index 15faddf..973047c 100644 --- a/src/components/adminSections.tsx +++ b/src/components/adminSections.tsx @@ -9,8 +9,17 @@ import { Btn, EmptyState, Icon, SettingRow, SettingsGroup, UIRenderer, type DocB import { docBridge } from './Workbench' import { randomPassword } from '../passwords' -// ---- 用户管理(原 AdminUsersModal 主体,R2 管理员发放制)---- +// ---- 用户管理(原 AdminUsersModal 主体,R2 管理员发放制 + 2026-10-13 恢复 Key)---- interface AdminUserRow { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string } +interface LoginKeyRow { id: string; owner: string; purpose: string; status: string; expiresAt: string; createdAt: string; createdBy?: string } + +/** Key 状态徽标:复用既有 chip 体系,不新增颜色。 */ +function keyStatusChip(status: string) { + if (status === 'unused') return 待使用 + if (status === 'consumed') return 已使用 + if (status === 'expired') return 已过期 + return 已撤销 +} export function UsersPanel() { const me = useStore(s => s.me) @@ -22,14 +31,24 @@ export function UsersPanel() { const [pw, setPw] = useState('') const [asAdmin, setAsAdmin] = useState(false) const [busy, setBusy] = useState(false) - const [resetFor, setResetFor] = useState(null) - const [resetPw, setResetPw] = useState('') + // 登录 Key 面板:keyFor 打开指定用户的签发/状态视图;issued 明文仅签发响应持有一次。 + const [keyFor, setKeyFor] = useState(null) + const [issued, setIssued] = useState<{ key: string; expiresAt: string } | null>(null) + const [keys, setKeys] = useState(null) const refresh = async () => { try { setUsers(await api.listAdminUsers()) } catch (e) { setErr(String(e)) } } + const refreshKeys = async (owner: string) => { + try { setKeys(await api.adminListLoginKeys(owner)) } catch (e) { setErr(String(e)) } + } useEffect(() => { refresh() }, []) + const openKeyPanel = async (owner: string) => { + setErr(''); setNotice(''); setIssued(null); setKeyFor(owner); setKeys(null) + await refreshKeys(owner) + } + const create = async () => { setErr(''); setNotice('') if (pw.length < 8) { setErr('初始密码至少 8 位'); return } @@ -42,16 +61,27 @@ export function UsersPanel() { } catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) } } - const reset = async () => { - if (!resetFor) return - setErr('') - if (resetPw.length < 8) { setErr('新密码至少 8 位'); return } - setBusy(true) + const issue = async () => { + if (!keyFor) return + setErr(''); setNotice(''); setBusy(true) try { - await api.resetAdminUserPassword(resetFor, resetPw) - setNotice(`已重置 ${resetFor} 的密码:其下次登录须改密(现有会话已吊销)`) - setResetFor(null); setResetPw('') - await refresh() + const out = await api.adminIssueLoginKey(keyFor) + setIssued({ key: out.key, expiresAt: out.expiresAt }) + await refreshKeys(keyFor) + } catch (e) { setErr(String(e)) } finally { setBusy(false) } + } + + const copyKey = async () => { + if (!issued) return + try { await navigator.clipboard.writeText(issued.key); setNotice('已复制到剪贴板,请安全交付给该用户') } catch { setErr('复制失败,请手动选择复制') } + } + + const revoke = async (id: string) => { + if (!keyFor) return + setErr(''); setBusy(true) + try { + await api.adminRevokeLoginKey(keyFor, id) + await refreshKeys(keyFor) } catch (e) { setErr(String(e)) } finally { setBusy(false) } } @@ -69,18 +99,41 @@ export function UsersPanel() { -

创建后该用户首次登录必须修改此初始密码。

+

创建后该用户首次登录必须修改此初始密码;忘记密码时为其生成一次性登录 Key。

)} {notice &&
{notice}
} {err &&
{err}
} - {resetFor ? ( + {keyFor ? (
- 重置 {resetFor} 的密码: - setResetPw(e.target.value)} /> - - - +
+ {keyFor} 的一次性登录 Key(忘记密码恢复) + + +
+ {issued && ( +
+ e.currentTarget.select()} /> + +
+ )} + {issued &&

明文仅此一次显示,关闭后不可再取;单次有效,{new Date(issued.expiresAt).toLocaleString()} 过期。请经安全渠道交付本人。

} + {keys !== null && keys.length > 0 && ( + + + + {keys.map(k => ( + + + + + + + + ))} + +
签发时间用途状态过期时间
{new Date(k.createdAt).toLocaleString()}{k.purpose === 'recover' ? '恢复' : '激活'}{keyStatusChip(k.status)}{new Date(k.expiresAt).toLocaleString()}{k.status === 'unused' && }
+ )}
) : ( @@ -94,7 +147,7 @@ export function UsersPanel() { ))} diff --git a/test/loginpage.test.ts b/test/loginpage.test.ts index 7822794..1aa008c 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 } from '../src/components/LoginPage' +import { humanChangePasswordError, humanLoginError, humanRedeemError } from '../src/components/LoginPage' describe('humanLoginError(登录)', () => { it('401 统一为用户名或密码不正确', () => { @@ -30,3 +30,20 @@ describe('humanChangePasswordError(改密门)', () => { expect(humanChangePasswordError('400: password must be 8-256 characters')).toBe('password must be 8-256 characters') }) }) + +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('humanChangePasswordError(登录恢复新增文案)', () => { + it('并发凭据变化给出重新登录指引', () => { + expect(humanChangePasswordError('401: credentials changed concurrently; retry login')).toBe('登录状态已变化(密码在其他地方被更新),请重新登录') + }) +})
{u.mustChangePassword ? 待改密 : u.hasPassword ? '正常' : '未设密码'} {u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'} {me?.isAdmin && ( - + )}