Why: the forced password gate still submitted an empty current password after the backend began requiring it (current_required), locking every account seeded with an initial password out of the workbench, and the admin user panel had no client for the owner-confirmed recovery proposal contract that replaced one-time key issuing (HTTP 410). Evidence: npm test 300 passed; tsc -b && vite build ok; knip issues 54 <= baseline 55 (SELF). Impact: mode-aware ChangePasswordGate with current-password input, admin recovery proposal panel, owner-side account settings section, recovery proposal client methods; no backend changes. Refs: backend 8dfc223, b9c0a34; frontend #94
346 lines
18 KiB
TypeScript
346 lines
18 KiB
TypeScript
// 系统管理分区面板(重设计方案 阶段②):普通用户界面不再暴露的系统能力
|
||
// 全部收进这里,由 AdminZone 以分区注册表呈现。数据入口与原弹窗完全一致
|
||
//(api.* 不变),只换呈现位置;插件面板/声明工作区经 UIRenderer 内联渲染。
|
||
import { useEffect, useState } from 'react'
|
||
import { api, type RecoveryProposal } from '../api'
|
||
import { store, useStore } from '../store'
|
||
import type { AgentProfile } from '../contracts'
|
||
import { Btn, EmptyState, Icon, SettingRow, SettingsGroup, UIRenderer, type DocBridge } from '@ui'
|
||
import { SettingInput, flatten, unflatten } from './SettingsModal'
|
||
import type { SettingDesc } from '../contracts'
|
||
import { docBridge } from './Workbench'
|
||
import { recoveryProposalErrorMessage, recoveryProposalHint, recoveryStatusLabel } from '../recovery'
|
||
|
||
// ---- 用户管理(原 AdminUsersModal 主体,R2 管理员发放制):建号仅创建待初始化
|
||
// 账号(HTTP 契约不含密码与管理员位),初始凭证由服务器本地 CLI 发放激活 Key;
|
||
// 恢复走属主确认制提案:管理员提案 → 用户在「设置 → 账号」本人确认 → 一次性 Key
|
||
// 只在属主会话内出现(直接签发 Key 的 HTTP 入口已被 410 替换)。----
|
||
interface AdminUserRow { userId: string; owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number }
|
||
|
||
export function UsersPanel() {
|
||
const me = useStore(s => s.me)
|
||
const [users, setUsers] = useState<AdminUserRow[] | null>(null)
|
||
const [err, setErr] = useState('')
|
||
const [notice, setNotice] = useState('')
|
||
const [owner, setOwner] = useState('')
|
||
const [name, setName] = useState('')
|
||
const [busy, setBusy] = useState(false)
|
||
// 恢复提案面板:propFor 打开指定用户的提案视图(创建 / 状态 / 撤销)。
|
||
const [propFor, setPropFor] = useState<AdminUserRow | null>(null)
|
||
const [proposals, setProposals] = useState<RecoveryProposal[] | null>(null)
|
||
|
||
const refresh = async () => {
|
||
try { setUsers(await api.listAdminUsers()) } catch (e) { setErr(String(e)) }
|
||
}
|
||
const refreshProposals = async (userId: string) => {
|
||
try { setProposals(await api.listAdminRecoveryProposals(userId)) } catch (e) { setErr(String(e)) }
|
||
}
|
||
useEffect(() => { refresh() }, [])
|
||
|
||
const create = async () => {
|
||
setErr(''); setNotice('')
|
||
setBusy(true)
|
||
try {
|
||
const out = await api.createAdminUser(owner.trim(), name.trim())
|
||
setNotice(`已创建 ${out.owner}(待初始化):请在服务器执行 user initialize-account 发放激活 Key`)
|
||
setOwner(''); setName('')
|
||
await refresh()
|
||
} catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) }
|
||
}
|
||
|
||
const openProposals = async (u: AdminUserRow) => {
|
||
setErr(''); setNotice(''); setPropFor(u); setProposals(null)
|
||
await refreshProposals(u.userId)
|
||
}
|
||
|
||
const propose = async () => {
|
||
if (!propFor) return
|
||
setErr(''); setNotice(''); setBusy(true)
|
||
try {
|
||
await api.createAdminRecoveryProposal(propFor.userId, propFor.stateVersion, crypto.randomUUID())
|
||
setNotice(`已为 ${propFor.owner} 建立恢复提案:请该用户在「设置 → 账号」由本人确认;确认后一次性 Key 只在其会话内出现`)
|
||
await refreshProposals(propFor.userId)
|
||
} catch (e) { setErr(recoveryProposalErrorMessage(String(e))) } finally { setBusy(false) }
|
||
}
|
||
|
||
const revokeProposal = async (p: RecoveryProposal) => {
|
||
if (!propFor) return
|
||
setErr(''); setBusy(true)
|
||
try {
|
||
await api.adminRevokeRecoveryProposal(propFor.userId, p.id, p.version)
|
||
await refreshProposals(propFor.userId)
|
||
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
{me?.isAdmin && (
|
||
<div className="ad-create">
|
||
<div className="ad-row">
|
||
<input className="lg-input" placeholder="用户名(登录名)" value={owner} onChange={e => setOwner(e.target.value)} />
|
||
<input className="lg-input" placeholder="显示名(可空)" value={name} onChange={e => setName(e.target.value)} />
|
||
<button className="lg-submit ad-create-btn" disabled={busy || !owner.trim()} onClick={create}>创建账号</button>
|
||
</div>
|
||
<p className="ad-hint">账号创建后处于待初始化状态:在服务器执行 user initialize-account 发放一次性激活 Key,用户经兑换入口设置密码后登录</p>
|
||
</div>
|
||
)}
|
||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||
{propFor ? (
|
||
<div className="ad-reset">
|
||
<div className="ad-row">
|
||
<span><b>{propFor.owner}</b> 的恢复提案(属主确认制:管理员提案,本人确认后获一次性 Key)</span>
|
||
<button className="lg-submit ad-create-btn" disabled={busy} onClick={propose}>发起恢复提案</button>
|
||
<button type="button" className="ad-mini" onClick={() => { setPropFor(null); setProposals(null) }}>关闭</button>
|
||
</div>
|
||
<p className="ad-hint">前提:该用户当前在任一设备保持完整密码登录(会话证明)。已锁死且无会话的账号无法在线恢复,请走服务器本地处置。</p>
|
||
{proposals !== null && proposals.length > 0 && (
|
||
<table className="ad-table">
|
||
<thead><tr><th>提案</th><th>状态</th><th>到期时间</th><th>发起人</th><th></th></tr></thead>
|
||
<tbody>
|
||
{proposals.map(p => (
|
||
<tr key={p.id}>
|
||
<td className="ad-mono">{p.id}</td>
|
||
<td>{recoveryStatusLabel[p.status]}{recoveryProposalHint(p.status) && <span className="ad-dim"> · {recoveryProposalHint(p.status)}</span>}</td>
|
||
<td className="ad-dim">{new Date(p.expiresAt).toLocaleString()}</td>
|
||
<td className="ad-dim">{p.createdBy}</td>
|
||
<td>{(p.status === 'pending' || p.status === 'confirmed') && (
|
||
<button className="ad-mini" disabled={busy} onClick={() => revokeProposal(p)}>撤销</button>
|
||
)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
{proposals !== null && proposals.length === 0 && <p className="ad-hint">该用户当前没有恢复提案。</p>}
|
||
</div>
|
||
) : (
|
||
<table className="ad-table">
|
||
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>密码设置于</th><th></th></tr></thead>
|
||
<tbody>
|
||
{(users ?? []).map(u => (
|
||
<tr key={u.owner}>
|
||
<td className="ad-user-cell"><span className="ad-mono">{u.owner}</span></td>
|
||
<td>{u.name || '—'}</td>
|
||
<td>{u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
|
||
<td>{u.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
|
||
<td className="ad-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
|
||
<td>{me?.isAdmin && (
|
||
<button className="ad-mini" onClick={() => openProposals(u)}>恢复提案</button>
|
||
)}</td>
|
||
</tr>
|
||
))}
|
||
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---- 智能体画像(原 SettingsModal ProfilesSection:数据化导入导出 + 编辑)----
|
||
export function ProfilesSection({ profiles }: { profiles: AgentProfile[] }) {
|
||
const [editing, setEditing] = useState<AgentProfile | null>(null)
|
||
const [text, setText] = useState('')
|
||
return (
|
||
<div>
|
||
<div className="doc-toolbar">
|
||
<Btn variant="primary" icon="plus" onClick={() => { setEditing(newProfile()); setText('') }}>新建画像</Btn>
|
||
<label className="btn ghost" style={{ cursor: 'pointer' }}>
|
||
导入 JSON<input type="file" accept=".json" style={{ display: 'none' }} onChange={async e => {
|
||
const f = e.target.files?.[0]
|
||
if (!f) return
|
||
try {
|
||
const p = JSON.parse(await f.text())
|
||
p.id = p.id || 'prof_imported'
|
||
p.builtin = false
|
||
await api.saveProfile(p)
|
||
store.refreshProfiles()
|
||
store.toast('画像已导入')
|
||
} catch (err) { store.toast(`导入失败:${err}`) }
|
||
}} />
|
||
</label>
|
||
</div>
|
||
{profiles.map(p => (
|
||
<SettingRow key={p.id}
|
||
label={<>{p.name} <span style={{ color: 'var(--text-faint)', fontSize: 'var(--fs-ui-xs)' }}>{p.id}{p.builtin ? ' · 内置' : ''}</span></>}
|
||
desc={p.description}>
|
||
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
|
||
<Btn variant="ghost" icon="download" onClick={() => {
|
||
const blob = new Blob([JSON.stringify(p, null, 2)], { type: 'application/json' })
|
||
const a = document.createElement('a')
|
||
a.href = URL.createObjectURL(blob); a.download = `${p.id}.json`; a.click()
|
||
}}>导出</Btn>
|
||
{!p.builtin && <Btn variant="ghost" onClick={() => { setEditing(p); setText(p.systemPrompt) }}>编辑</Btn>}
|
||
{!p.builtin && <Btn variant="ghost" icon="trash" onClick={async () => { await api.deleteProfile(p.id); store.refreshProfiles() }}>删除</Btn>}
|
||
</div>
|
||
</SettingRow>
|
||
))}
|
||
{editing && (
|
||
<SettingsGroup title={`编辑画像:${editing.name}`}>
|
||
<div className="ui-form">
|
||
<label>名称<input value={editing.name} onChange={e => setEditing({ ...editing, name: e.target.value })} /></label>
|
||
<label>系统提示<textarea className="search-input" rows={4} style={{ height: 'auto' }} value={text} onChange={e => setText(e.target.value)} /></label>
|
||
<label>允许工具(逗号分隔,* 全部)<input value={editing.tools.allow.join(',')} onChange={e => setEditing({ ...editing, tools: { ...editing.tools, allow: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })} /></label>
|
||
<label>执行前确认(前缀. 结尾表示族)<input value={editing.tools.confirm?.join(',') ?? ''} onChange={e => setEditing({ ...editing, tools: { ...editing.tools, confirm: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })} /></label>
|
||
<label>拒绝工具<input value={editing.tools.deny?.join(',') ?? ''} onChange={e => setEditing({ ...editing, tools: { ...editing.tools, deny: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })} /></label>
|
||
</div>
|
||
<Btn variant="primary" icon="check" onClick={async () => {
|
||
try {
|
||
await api.saveProfile({ ...editing, builtin: false, source: 'user', systemPrompt: text })
|
||
await store.refreshProfiles()
|
||
setEditing(null)
|
||
store.toast('画像已保存')
|
||
} catch (e) { store.toast(`保存失败:${e}`) }
|
||
}}>保存画像</Btn>
|
||
</SettingsGroup>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function newProfile(): AgentProfile {
|
||
return {
|
||
id: '', name: '新画像', systemPrompt: '', model: {}, tools: { allow: ['*'] },
|
||
context: { maxHistoryMessages: 40 }, hitl: { askOnAmbiguity: true }, budget: {},
|
||
} as AgentProfile
|
||
}
|
||
|
||
// ---- 插件管理(原 SettingsModal PluginsSection:清单 + 热重载)----
|
||
export function PluginsSection() {
|
||
const [plugins, setPlugins] = useState<any[]>([])
|
||
const refresh = () => api.listPlugins().then(setPlugins).catch(() => undefined)
|
||
useEffect(() => { refresh() }, [])
|
||
return (
|
||
<div>
|
||
<div className="doc-toolbar">
|
||
<Btn variant="ghost" icon="undo" onClick={async () => {
|
||
await api.reloadPlugins()
|
||
await refresh()
|
||
store.refreshPlugins()
|
||
store.toast('插件已热重载')
|
||
}}>热重载</Btn>
|
||
<span style={{ color: 'var(--text-faint)', fontSize: 'var(--fs-ui-sm)' }}>声明资产(工作流/意图/提示词/面板)热加载;装卸不影响核心</span>
|
||
</div>
|
||
{plugins.length === 0 && <EmptyState icon="puzzle">暂无已装载插件<br />(插件目录在 设置 → 常规 → 插件目录;示例见 Dev 仓 plugins/example)</EmptyState>}
|
||
{plugins.map(p => (
|
||
<SettingRow key={p.manifest.id}
|
||
label={p.manifest.name + (p.manifest.version ? ` v${p.manifest.version}` : '')}
|
||
desc={[
|
||
`工具 ${p.manifest.provides.tools?.length ?? 0}`,
|
||
`命令 ${p.manifest.provides.commands?.length ?? 0}`,
|
||
`面板 ${p.panels?.length ?? 0}`,
|
||
`意图 ${p.intents?.length ?? p.manifest.provides.intents?.length ?? 0}`,
|
||
`工作流 ${p.workflows?.length ?? 0}`,
|
||
].join(' · ')}>
|
||
<span style={{ display: 'inline-flex', gap: 6, alignItems: 'center' }}>
|
||
{p.manifest.backend?.mode === 'inproc' && <span className="effect-badge">内置</span>}
|
||
<span className="effect-badge">api {p.manifest.apiVersion}</span>
|
||
</span>
|
||
</SettingRow>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---- 插件工作区(插件声明面板 + 服务端声明工作区;声明体经 UIRenderer 内联渲染)----
|
||
export function PluginWorkspacesPanel() {
|
||
const pluginPanels = useStore(s => s.pluginPanels)
|
||
const serverWorkspaces = useStore(s => s.serverWorkspaces)
|
||
const [selected, setSelected] = useState<string | null>(null)
|
||
|
||
const entries = [
|
||
...pluginPanels.map(p => ({ key: `plugin:${p.id}`, title: p.title, icon: '🧩', body: p.body ?? [] })),
|
||
...serverWorkspaces.map(w => ({ key: `ws:${w.id}`, title: w.title, icon: w.icon || '🗂', body: (w.body ?? []) as any[] })),
|
||
]
|
||
const current = entries.find(e => e.key === selected) ?? entries[0]
|
||
if (entries.length === 0) {
|
||
return <EmptyState icon="grid">暂无插件声明工作区<br />(插件可在 manifest 里声明 panels / workspaces)</EmptyState>
|
||
}
|
||
return (
|
||
<div>
|
||
<div className="doc-toolbar" style={{ flexWrap: 'wrap' }}>
|
||
{entries.map(e => (
|
||
<button key={e.key} className={`kind-pill ${current?.key === e.key ? 'on' : ''}`} onClick={() => setSelected(e.key)}>
|
||
{e.icon} {e.title}
|
||
</button>
|
||
))}
|
||
<span style={{ flex: 1 }} />
|
||
<Btn variant="ghost" icon="undo" onClick={() => store.refreshPlugins()}>刷新</Btn>
|
||
</div>
|
||
{current && <UIRenderer nodes={current.body} docBridge={docBridge as DocBridge} />}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---- 模型与服务配置(P2 系统受管项的唯一写入面)----
|
||
|
||
/**
|
||
* SystemSettingsPanel:消费 GET/PUT /api/settings/system。
|
||
* - 仅渲染 schema 中 scope=system 的受管项(模型、密钥、Tavily、遥测、运行基础设施);
|
||
* 非受管项属于用户/项目偏好,不在本面板出现。
|
||
* - 保存后重新 GET 以服务端结果回填(保存成功 ≠ 本地草稿即真相);掩码占位符
|
||
* 原样传回,由服务端视为未修改。
|
||
* - 权限不足(非管理员)由分区门控与 API 403 双层拦截,403 时如实提示。
|
||
*/
|
||
export function SystemSettingsPanel() {
|
||
const [schema, setSchema] = useState<SettingDesc[] | null>(null)
|
||
const [draft, setDraft] = useState<Record<string, any>>({})
|
||
const [loaded, setLoaded] = useState(false)
|
||
const [forbidden, setForbidden] = useState(false)
|
||
const [err, setErr] = useState('')
|
||
const [notice, setNotice] = useState('')
|
||
const [busy, setBusy] = useState(false)
|
||
|
||
const load = async () => {
|
||
setErr(''); setForbidden(false)
|
||
try {
|
||
const out = await api.getSystemSettings()
|
||
setSchema(out.schema.filter(d => d.scope === 'system'))
|
||
setDraft(flatten(out.system))
|
||
setLoaded(true)
|
||
} catch (e) {
|
||
if (String(e).includes('403')) { setForbidden(true); setLoaded(true) }
|
||
else setErr(String(e))
|
||
}
|
||
}
|
||
useEffect(() => { load() }, [])
|
||
|
||
const groups: Record<string, SettingDesc[]> = {}
|
||
for (const d of schema ?? []) (groups[d.group] ??= []).push(d)
|
||
|
||
const save = async () => {
|
||
setErr(''); setNotice(''); setBusy(true)
|
||
try {
|
||
await api.putSystemSettings(unflatten(draft))
|
||
setNotice('已保存;按各项标注的生效时机生效(需重启的项在重启后生效)')
|
||
await load()
|
||
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||
}
|
||
|
||
if (!loaded) return <EmptyState icon="settings">加载系统设置…</EmptyState>
|
||
if (forbidden) return <EmptyState icon="shield">系统设置仅管理员可配置</EmptyState>
|
||
|
||
return (
|
||
<div>
|
||
<p className="ad-hint" style={{ marginBottom: 14 }}>
|
||
模型、密钥与运行基础设施为系统受管配置:仅此面板(管理员)可写,用户与项目设置中的同名项不再生效。
|
||
</p>
|
||
{Object.entries(groups).map(([group, descs]) => (
|
||
<SettingsGroup key={group} title={group === 'models' ? '模型' : group === 'tools' ? '工具服务' : group === 'telemetry' ? '遥测' : group === 'runtime' ? '运行基础设施' : group}>
|
||
{descs.map(d => (
|
||
<SettingRow key={d.key} label={d.label} desc={d.description} effect={d.effect}>
|
||
<SettingInput desc={d} value={draft[d.key]} onChange={v => setDraft(prev => ({ ...prev, [d.key]: v }))} />
|
||
</SettingRow>
|
||
))}
|
||
</SettingsGroup>
|
||
))}
|
||
<div className="ad-row" style={{ marginTop: 12 }}>
|
||
<Btn variant="primary" icon="check" disabled={busy} onClick={save}>保存系统配置</Btn>
|
||
</div>
|
||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||
</div>
|
||
)
|
||
}
|