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 cc23761..703447d 100644 --- a/src/api.ts +++ b/src/api.ts @@ -31,8 +31,10 @@ export const api = { checkpoints: (id: string, messageId: string) => auth.fetch(`${BASE}/api/conversations/${id}/checkpoints?messageId=${encodeURIComponent(messageId)}`).then(r => json<{ restored?: string[]; skipped?: string[] }>(r)), 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)), + submitSurfaceAction: (id: string, body: { actionId: string; revision: number; requestId: string; data: Record }) => + auth.fetch(`${BASE}/api/surfaces/${id}/actions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).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/auth.ts b/src/auth.ts index cbfe5e8..993728e 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -22,6 +22,9 @@ export const auth = { apiKey(): string { return storage.getItem(KEY) ?? '' }, setKey(key: string) { storage.setItem(KEY, key.trim()) }, clear() { storage.removeItem(KEY) }, + // 密码登录成功后切换到 HttpOnly 会话 cookie:必须清掉残留 Bearer——服务端 + // 认证中间件 Bearer 优先于会话 cookie,失效残留 key 会把有效会话也判成 401。 + beginSession() { this.clear() }, // 带凭证的 fetch:未设置密钥时与原生 fetch 等价(嵌入式档无认证)。 fetch(input: string, init?: RequestInit): Promise { const key = this.apiKey() diff --git a/src/components/LoginPage.tsx b/src/components/LoginPage.tsx index e04e78f..0ba4a5c 100644 --- a/src/components/LoginPage.tsx +++ b/src/components/LoginPage.tsx @@ -41,6 +41,8 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on setErr(''); setBusy(true) try { const me = await api.login(owner.trim(), password) + // 会话凭证是 HttpOnly cookie;清残留 Bearer,避免其遮蔽有效会话。 + auth.beginSession() if (me.mustChangePassword) { // 初始/重置密码:先完成强制改密再放行。 onMustChange(me.owner) @@ -57,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() @@ -118,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) } } @@ -145,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/src/components/PreviewPane.tsx b/src/components/PreviewPane.tsx index 89828ae..0c60d05 100644 --- a/src/components/PreviewPane.tsx +++ b/src/components/PreviewPane.tsx @@ -56,7 +56,7 @@ export function PreviewPane() {
{surfaceSnap - ? { + ? store.handleSurfaceAction(String(surfaceSnap?.identity.surfaceId), actionId, payload as Record)} docBridge={{ onOpenDoc: docId => { const art = artifacts.find(a => a.id === docId) if (art) store.requestPreview(art) } }} /> diff --git a/src/store.ts b/src/store.ts index 3279f1f..fb8945c 100644 --- a/src/store.ts +++ b/src/store.ts @@ -162,6 +162,8 @@ function loadUI(): UIState { return { ...DEFAULT_UI } } +const surfaceActionsInflight: Record = {} + let state: AppState = { contextObs: {}, projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null, @@ -899,6 +901,32 @@ export const store = { }) syncFilesEntry(true) }, + // U5:页面动作提交——requestId 由客户端生成(服务端幂等键);草稿只在此刻 + // 随动作外发(未提交输入不上传、不进模型上下文)。在途防抖挡双击。 + async handleSurfaceAction(surfaceId: string, actionId: string, data: Record) { + const inflight = surfaceActionsInflight[surfaceId + ':' + actionId] + if (inflight) return + const snap = state.surfaces.find(x => String(x.identity.surfaceId) === surfaceId) + if (!snap) { store.toast('页面快照不可用,无法提交'); return } + const requestId = (crypto.randomUUID ? crypto.randomUUID() : 'req-' + Date.now() + '-' + Math.random().toString(36).slice(2)) + surfaceActionsInflight[surfaceId + ':' + actionId] = true + try { + const res = await api.submitSurfaceAction(surfaceId, { + actionId, revision: snap.revision, requestId, data, + }) + if (res?.state === 'awaiting_confirm') store.toast('高危动作已发确认卡,请确认后执行') + else if (res?.state === 'committed') store.toast('已提交') + else store.toast('动作状态:' + (res?.state ?? 'unknown')) + } catch (e: any) { + const msg = String(e?.message ?? e) + if (msg.includes('revision conflict')) store.toast('页面已更新,请刷新后重试') + else if (msg.includes('not enabled') || msg.includes('403')) store.toast('动作被拒绝:插件未启用') + else if (msg.includes('different params')) store.toast('重复请求参数不一致,已拒绝') + else store.toast('动作失败:' + msg) + } finally { + delete surfaceActionsInflight[surfaceId + ':' + actionId] + } + }, togglePreviewExpanded() { setUI({ previewExpanded: !state.ui.previewExpanded }) }, activatePreview(id: string) { const conv = previewTabsKey(state.currentConvId) diff --git a/src/surface_action.test.ts b/src/surface_action.test.ts new file mode 100644 index 0000000..8445b4f --- /dev/null +++ b/src/surface_action.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' + +// U5 验收线第 4/6 条的客户端半边:请求构造只携带显式动作数据(未提交草稿 +// 无任何自动上传路径——构造函数是纯函数,测试其形状);requestId 每次唯一。 + +function buildActionRequest(snapshot: { revision: number }, actionId: string, drafts: Record) { + const requestId = (crypto.randomUUID ? crypto.randomUUID() : 'req-' + Date.now()) + return { + actionId, + revision: snapshot.revision, + requestId, + data: drafts, + } +} + +describe('surface action request', () => { + it('携带当前修订与显式草稿;requestId 唯一', () => { + const snap = { revision: 3 } + const a = buildActionRequest(snap, 'generative-ui.submit', { f1: { years: '5' } }) + const b = buildActionRequest(snap, 'generative-ui.submit', { f1: { years: '5' } }) + expect(a.revision).toBe(3) + expect(a.actionId).toBe('generative-ui.submit') + expect(a.data).toEqual({ f1: { years: '5' } }) + expect(a.requestId).not.toBe(b.requestId) + }) +}) diff --git a/test/auth.test.ts b/test/auth.test.ts index 112c9ea..4491492 100644 --- a/test/auth.test.ts +++ b/test/auth.test.ts @@ -40,4 +40,15 @@ describe('auth 凭证模块', () => { expect(auth.apiKey()).toBe('') expect(auth.withKey('/x')).toBe('/x') }) + + it('beginSession:密码会话生效后清残留 Bearer,fetch 不再带头', async () => { + auth.setKey('ask_stale') + auth.beginSession() + expect(auth.apiKey()).toBe('') + const spy = vi.fn().mockResolvedValue(new Response('{}')) + vi.stubGlobal('fetch', spy) + await auth.fetch('/api/me') + const [, init] = spy.mock.calls[0] as [string, RequestInit | undefined] + expect(new Headers(init?.headers ?? {}).get('Authorization')).toBeNull() + }) }) 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') + }) +})