Files
Agent-System-Frontend/src/App.tsx
T
weichen dc18d6c747 Keep API-key holders out of the dead-end change-password gate
Why: entering the forced-change gate with an API-key credential always
fails 401 (passwords are session-owned), and the gate mapped every 401
to "Incorrect username or password", masking the real reason and hiding
the self-service exit; admins got locked out before reaching user
management.

Evidence: npx vitest run green (20 files / 93 tests incl. new
test/loginpage.test.ts); tsc -b and check-knip green.

Impact: App boot routes key+mustChange to the login page (key cleared);
the API-key tab advises password login for must-change accounts; the
gate maps backend error strings to distinct actionable messages; api.me
type gains authVia.

Refs: Dev task feature/admin-auth-gate-fix.
2026-10-11 13:45:56 +00:00

191 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from 'react'
import { auth } from './auth'
import { api } from './api'
import { LoginPage, ChangePasswordGate } from './components/LoginPage'
import { ChangePasswordModal } from './components/AdminUsersModal'
import { AdminZone } from './components/AdminZone'
import { Icon } from '@ui'
import { store, useStore } from './store'
import { TaskSidebar } from './components/TaskSidebar'
import { Conversation } from './components/Conversation'
import { previewTabsKey } from './previewtabs'
import { Workbench } from './components/Workbench'
import { Workspaces, workspaceShortcuts } from './components/Workspaces'
import { AgentTracker } from './components/AgentTracker'
import { SettingsModal } from './components/SettingsModal'
import { ProjectSettingsModal } from './components/ProjectSettingsModal'
import { TopBar } from './components/TopBar'
import { workspaceEntries } from './components/Workspaces'
// 布局(三栏工作台):左(项目与会话)/ 中(对话)/ 第三栏(工作|文件双入口)。
// 文件预览不再是独立第四栏——预览在第三栏「文件」入口内,展开态铺满主区。
const LEFT_MIN = 220, LEFT_MAX = 420, LEFT_OVERSHOOT = 40
const RIGHT_MIN = 280, RIGHT_MAX = 560
const PREVIEW_MIN = 420, PREVIEW_MAX = 1100
export function App() {
useEffect(() => {
const h = (e: KeyboardEvent) => workspaceShortcuts(e)
window.addEventListener('keydown', h, true)
return () => window.removeEventListener('keydown', h, true)
}, [])
const ui = useStore(s => s.ui)
// 预览键与 requestPreview 的落点一致(previewTabsKey):空态(未选会话)点开
// 工件也要能进文件入口,否则点击只剩工件弹层关闭、毫无反馈。
const previewOpen = useStore(s => (s.previewTabs[previewTabsKey(s.currentConvId)]?.length ?? 0) > 0)
const rightTab = useStore(s => s.ui.rightTab)
const toast = useStore(s => s.toast)
const trackerRunId = useStore(s => s.ui.trackerRunId)
// 文件入口 + 预览标签在场时,第三栏用「阅读档」宽度(420–1100),不受工作档上限约束。
const filesWide = rightTab === 'files' && previewOpen
const rightPanelWidth = filesWide ? ui.previewWidth : ui.rightWidth
const previewExpanded = filesWide && ui.previewExpanded
const pendingBadge = useStore(s => s.cards.filter(c => c.status === 'action_required').length)
// authState:checking → login(登录)→ mustchange(首登强制改密)→ ok(放行)。
const [authState, setAuthState] = useState<'checking' | 'login' | 'mustchange' | 'ok'>('checking')
const [mustChangeOwner, setMustChangeOwner] = useState('')
useEffect(() => {
;(async () => {
try {
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) {
if (me.authVia === 'key') { auth.clear(); setAuthState('login'); return }
setMustChangeOwner(me.owner); setAuthState('mustchange'); return
}
setAuthState('ok')
} catch {
setAuthState('ok') // 网络异常不是认证问题;放行交给业务层报错
}
})()
}, [])
useEffect(() => {
if (authState !== 'ok') return
store.refreshProjects()
store.refreshProfiles()
store.refreshPlugins()
}, [authState])
const drag = useRef<{ side: 'left' | 'right'; startX: number; startW: number; overshoot: boolean; min: number; max: number; key: 'leftWidth' | 'rightWidth' | 'previewWidth' } | null>(null)
const onDividerDown = (side: 'left' | 'right') => (e: React.MouseEvent) => {
e.preventDefault()
// 右侧分隔条按当前入口选宽度档:文件+预览在场走阅读档,否则工作档。
const wide = side === 'right' && rightTab === 'files' && previewOpen
const key = side === 'left' ? 'leftWidth' : wide ? 'previewWidth' : 'rightWidth'
drag.current = {
side, startX: e.clientX, startW: ui[key], overshoot: false,
min: side === 'left' ? LEFT_MIN : wide ? PREVIEW_MIN : RIGHT_MIN,
max: side === 'left' ? LEFT_MAX : wide ? PREVIEW_MAX : RIGHT_MAX,
key,
}
const move = (ev: MouseEvent) => {
const d = drag.current
if (!d) return
if (d.side === 'left') {
const w = d.startW + (ev.clientX - d.startX)
if (w < d.min - LEFT_OVERSHOOT) { d.overshoot = true; return }
d.overshoot = false
store.setUI({ leftCollapsed: false, leftWidth: Math.min(d.max, Math.max(d.min, w)) })
} else {
const w = d.startW - (ev.clientX - d.startX)
if (w < d.min) return
store.setUI({ rightCollapsed: false, [d.key]: Math.min(d.max, w) } as Partial<typeof ui>)
}
}
const up = () => {
const d = drag.current
if (d?.side === 'left' && d.overshoot) store.setUI({ leftCollapsed: true })
drag.current = null
window.removeEventListener('mousemove', move)
window.removeEventListener('mouseup', up)
}
window.addEventListener('mousemove', move)
window.addEventListener('mouseup', up)
}
if (authState === 'login') {
return <LoginPage
onAuthed={() => setAuthState('ok')}
onMustChange={owner => { setMustChangeOwner(owner); setAuthState('mustchange') }} />
}
if (authState === 'mustchange') {
return <ChangePasswordGate
owner={mustChangeOwner}
onDone={async () => { await store.refreshMe().catch(() => undefined); setAuthState('ok') }}
onCancel={() => { api.logout().catch(() => undefined); setAuthState('login') }} />
}
if (authState !== 'ok') {
return <div className="login-gate"><div className="login-card"><p>连接服务中…</p></div></div>
}
const tools = workspaceEntries()
return (
<div className="app">
<TopBar />
<div className="app-body">
{!ui.leftCollapsed ? (
<>
<div className="left-nav" style={{ width: ui.leftWidth }}>
<TaskSidebar />
</div>
<div className="divider" onMouseDown={onDividerDown('left')} onDoubleClick={store.toggleLeft} title="拖动调整宽度;继续向左超过最小宽度后松开可收起;双击切换" />
</>
) : (
<div className="left-nav collapsed" style={{ width: 52 }}>
<div className="icon-only">
<button title="展开侧栏(⌘B)" onClick={store.toggleLeft}><Icon name="panel-left" /></button>
<button title="新建任务(⌘N)" onClick={() => store.createTask()}><Icon name="plus" /></button>
<button title="收件箱" onClick={() => store.focusPending()}><Icon name="inbox" />
{pendingBadge > 0 && <span className="rail-badge" data-testid="rail-badge">{pendingBadge}</span>}
</button>
{tools.map(entry => (
<button key={entry.key} title={`${entry.title}(${entry.shortcut})`} onClick={() => store.openWorkspace(entry.key)}>{entry.icon}</button>
))}
<button title="设置" onClick={() => store.openSettings(true)}><Icon name="settings" /></button>
</div>
</div>
)}
<div className="main" hidden={previewExpanded}>
<Conversation />
</div>
{!ui.rightCollapsed ? (
<>
<div className="divider" onMouseDown={onDividerDown('right')} onDoubleClick={store.toggleRight} title="拖动调整宽度;双击收起" />
<div className={`right-panel mk-panel-grow${previewExpanded ? ' expanded' : ''}`}
style={previewExpanded ? undefined : { width: rightPanelWidth }}>
<Workbench />
</div>
</>
) : (
<div className="right-panel collapsed" style={{ width: 44 }}>
<div className="icon-only">
<button title="展开右栏" onClick={store.toggleRight}>◧
{pendingBadge > 0 && <span className="rail-badge" data-testid="rail-badge">{pendingBadge}</span>}
</button>
<button title="文件(搜索、预览、编辑、下载)" onClick={() => store.openFiles()}><Icon name="folder" size={16} /></button>
</div>
</div>
)}
</div>
<Workspaces />
{trackerRunId && <AgentTracker />}
{ui.settingsOpen && <SettingsModal />}
{ui.projectSettingsId && <ProjectSettingsModal />}
{ui.adminZoneOpen && <AdminZone />}
{ui.changePwOpen && <ChangePasswordModal onClose={() => store.setUI({ changePwOpen: false })} />}
{toast && <div className="toast">{toast}</div>}
</div>
)
}