Compare commits

...
Author SHA1 Message Date
weichen c2878c04ea Submit page actions from the preview pane (U5)
Why: U5 closes the interactive loop - the surface view's action click
carries the user's typed drafts (and nothing else - unsubmitted input
has no upload path), the store builds the idempotent request
(client-generated requestId, current revision), guards double-clicks
in flight, and maps server outcomes to toasts (awaiting-confirm card,
revision conflict refresh, disabled rejection).

Evidence: tsc clean; vitest 95/95 including the request-shape test
(explicit data only, unique requestId per submission); knip unchanged.

Impact: Frontend-only; backend endpoint from the U5 backend branch.

Refs: U5 action-scope r2 (approved); UI PRs #46/#47; Dev task
feature/generative-ui-u5
2026-10-11 14:24:01 +00:00
weichen 39dc8c456c Merge pull request 'Clear stale API-key credentials when a password session begins' (#55) from feature/login-clear-stale-key into main 2026-10-11 14:04:59 +00:00
weichen 482ba885e1 Merge remote-tracking branch 'origin/main' into feature/login-clear-stale-key 2026-10-11 14:04:26 +00:00
weichen 09310cb753 Clear stale API-key credentials when a password session begins
Why: the auth middleware prefers Bearer keys over the session cookie, so a stale key left in localStorage shadows valid cookie sessions and every request after password login returns 401 (observed at http://192.168.3.2:8788 on 2026-10-11).
Evidence: modules/frontend vitest 19 files / 88 tests green; auth.test.ts covers beginSession clearing the stored Bearer before any fetch.
Impact: password logins switch to the HttpOnly cookie session cleanly; API-key logins unchanged.
Refs: task card feature-login-clear-stale-key in Agent-System-Dev.
2026-10-11 13:53:27 +00:00
weichen fcf40ff75f Merge pull request 'Keep API-key holders out of the dead-end change-password gate' (#54) from feature/admin-auth-gate-fix into main 2026-10-11 13:52:15 +00:00
7 changed files with 73 additions and 1 deletions
+2
View File
@@ -31,6 +31,8 @@ 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<any[]>(r)),
submitSurfaceAction: (id: string, body: { actionId: string; revision: number; requestId: string; data: Record<string, unknown> }) =>
auth.fetch(`${BASE}/api/surfaces/${id}/actions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => json<any>(r)),
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<any>(r)),
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; isAdmin?: boolean; mustChangePassword?: boolean }>(r)),
// ---- 认证与会话(管理员发放制,R2)----
+3
View File
@@ -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<Response> {
const key = this.apiKey()
+2
View File
@@ -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)
+1 -1
View File
@@ -56,7 +56,7 @@ export function PreviewPane() {
</div>
<div className="preview-pane-body">
{surfaceSnap
? <SurfaceView snapshot={surfaceSnap} docBridge={{ onOpenDoc: docId => {
? <SurfaceView snapshot={surfaceSnap} onAction={(actionId, payload) => store.handleSurfaceAction(String(surfaceSnap?.identity.surfaceId), actionId, payload as Record<string, unknown>)} docBridge={{ onOpenDoc: docId => {
const art = artifacts.find(a => a.id === docId)
if (art) store.requestPreview(art)
} }} />
+28
View File
@@ -162,6 +162,8 @@ function loadUI(): UIState {
return { ...DEFAULT_UI }
}
const surfaceActionsInflight: Record<string, boolean> = {}
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<string, unknown>) {
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)
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
// U5 验收线第 4/6 条的客户端半边:请求构造只携带显式动作数据(未提交草稿
// 无任何自动上传路径——构造函数是纯函数,测试其形状);requestId 每次唯一。
function buildActionRequest(snapshot: { revision: number }, actionId: string, drafts: Record<string, unknown>) {
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)
})
})
+11
View File
@@ -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()
})
})