diff --git a/src/App.tsx b/src/App.tsx index 2addeab..b253de0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -52,8 +52,13 @@ export function App() { const r = await auth.fetch('/api/health') if (r.status === 401) { setAuthState('login'); return } // 已持有有效会话 cookie(或密钥)时直接放行;会话携带 must_change 时先进改密门。 + // 改密接口只认会话登录:API key 凭证进改密门是死路(提交必 401), + // key 持有者清 key 回登录页,改走密码登录完成改密。 const me = await api.me().catch(() => null) - if (me?.mustChangePassword) { setMustChangeOwner(me.owner); setAuthState('mustchange'); return } + if (me?.mustChangePassword) { + if (me.authVia === 'key') { auth.clear(); setAuthState('login'); return } + setMustChangeOwner(me.owner); setAuthState('mustchange'); return + } setAuthState('ok') } catch { setAuthState('ok') // 网络异常不是认证问题;放行交给业务层报错 diff --git a/src/api.ts b/src/api.ts index 8534172..d734a2d 100644 --- a/src/api.ts +++ b/src/api.ts @@ -32,7 +32,7 @@ export const api = { rewindFiles: (id: string, messageId: string) => auth.fetch(`${BASE}/api/conversations/${id}/rewind`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messageId }) }).then(r => json<{ restored?: string[]; skipped?: string[] }>(r)), surfaceList: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/surfaces`).then(r => json(r)), 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; isAdmin?: boolean; mustChangePassword?: boolean }>(r)), + me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; isAdmin?: boolean; mustChangePassword?: 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()}`) diff --git a/src/components/LoginPage.tsx b/src/components/LoginPage.tsx index 4dac605..0ba4a5c 100644 --- a/src/components/LoginPage.tsx +++ b/src/components/LoginPage.tsx @@ -59,7 +59,17 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on auth.setKey(key) try { const r = await auth.fetch('/api/health') - if (r.ok) { onAuthed(); return } + if (r.ok) { + // 账号处于初始/重置密码状态时,key 完成不了强制改密(改密只认会话登录): + // 不放行进死路改密门,引导改走账号密码登录。 + const me = await api.me().catch(() => null) + if (me?.mustChangePassword) { + auth.clear() + setErr('该账号使用初始/重置密码,请先用账号密码登录完成改密') + return + } + onAuthed(); return + } throw new Error(String(r.status)) } catch { auth.clear() @@ -120,7 +130,7 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string; await api.changePassword(current, next) onDone() } catch (e) { - setErr(humanLoginError(String(e)).replace('401: ', '')) + setErr(humanChangePasswordError(String(e))) } finally { setBusy(false) } } @@ -147,10 +157,23 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string; ) } -/** 错误文案:把 HTTP 状态码翻成人话。 */ +/** 登录错误文案:把 HTTP 状态码翻成人话。 */ export function humanLoginError(raw: string): string { if (raw.includes('401')) return '用户名或密码不正确' if (raw.includes('429')) return '失败次数过多,请稍后再试' if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)' return raw.replace(/^\d{3}:\s*/, '') } + +/** 改密门错误文案:改密的 401 有多种真实原因(当前密码错/无会话/会话过期/失效 key), + * 不能沿用登录的「用户名或密码不正确」一刀切——那会把可自愈的凭证问题伪装成密码错误。 + * 后端文案是现行 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('invalid or revoked API key')) return '本地残留的 API 密钥已失效,请退出登录后重试' + if (raw.includes('429')) return '失败次数过多,请稍后再试' + if (raw.includes('401')) return '认证失败,请重新登录' + return raw.replace(/^\d{3}:\s*/, '') +} diff --git a/test/loginpage.test.ts b/test/loginpage.test.ts new file mode 100644 index 0000000..7822794 --- /dev/null +++ b/test/loginpage.test.ts @@ -0,0 +1,32 @@ +// 登录/改密门错误文案:登录与改密的 401 语义不同,映射分开锁定 +// (后端错误串是现行 HTTP 契约的一部分,这里逐字对拍防漂移)。 +import { describe, expect, it } from 'vitest' +import { humanChangePasswordError, humanLoginError } from '../src/components/LoginPage' + +describe('humanLoginError(登录)', () => { + it('401 统一为用户名或密码不正确', () => { + expect(humanLoginError('401: invalid owner or password')).toBe('用户名或密码不正确') + }) + it('429/412 各有文案,其余剥状态码前缀', () => { + expect(humanLoginError('429: too many failed attempts; retry later')).toBe('失败次数过多,请稍后再试') + expect(humanLoginError('412: auth disabled (embedded single-tenant mode)')).toBe('本服务未开启登录(嵌入式单租户档)') + expect(humanLoginError('400: bad')).toBe('bad') + }) +}) + +describe('humanChangePasswordError(改密门)', () => { + it('当前密码错误不再伪装成登录失败', () => { + expect(humanChangePasswordError('401: current password incorrect')).toBe('当前密码不正确') + }) + it('无会话(API key 凭证)给出可行动指引', () => { + expect(humanChangePasswordError('401: session login required (API keys do not own passwords)')).toBe('请先用账号密码登录再改密(API 密钥不能修改密码)') + }) + it('会话过期与失效 key 各有文案', () => { + expect(humanChangePasswordError('401: session expired')).toBe('会话已过期,请重新登录') + expect(humanChangePasswordError('401: unauthorized: invalid or revoked API key')).toBe('本地残留的 API 密钥已失效,请退出登录后重试') + }) + it('未知 401 回落通用文案,其余错误剥前缀透传', () => { + expect(humanChangePasswordError('401: whatever')).toBe('认证失败,请重新登录') + expect(humanChangePasswordError('400: password must be 8-256 characters')).toBe('password must be 8-256 characters') + }) +})