Why: avatars, cards, and settings are base capabilities now (user decision 2026-09-30); the host must eat its own dogfood so plugins get the identical surfaces. Evidence: - Local icons.tsx removed; Icon/Avatar/BrandMark/primitives/Settings Shell imported from @ui; message stream gains avatar columns (user initial from /api/me name, assistant brand mark); SettingsModal rebuilt on SettingsShell (side-nav sections with badges, EmptyState for plugins, iconified actions); AdminUsersModal rows show shared avatars. - tsconfig paths pin react/react-dom types to the frontend copy — the source-composed ui package otherwise resolves a second @types/react through its own node_modules during make frontend (npm ci), which breaks type identity (surfaced by SVGProps crossing the boundary); vite dedupe already guarantees one runtime copy. - Headless on the real binary: avatar columns render, settings shell switches sections with badges, admin rows show avatars, zero console errors; build + vitest green. Impact: visual/structural only; no API or state changes. Refs: Agent-System-Documents proposal 2026-09-30-ui-redesign-and-auth.md
155 lines
7.3 KiB
TypeScript
155 lines
7.3 KiB
TypeScript
// 登录与改密(管理员发放制,R2):
|
|
// LoginPage —— 用户名+密码(主)/ API 密钥(兼容程序化客户端)双入口;
|
|
// ChangePasswordGate —— 初始/重置密码后的首登强制改密(不完成不放行)。
|
|
import { useState } from 'react'
|
|
import { auth } from '../auth'
|
|
import { api } from '../api'
|
|
import { Icon, BrandMark } from '@ui'
|
|
|
|
function Err({ msg }: { msg: string }) {
|
|
if (!msg) return null
|
|
return <div className="lg-err" role="alert"><Icon name="alert" size={14} />{msg}</div>
|
|
}
|
|
|
|
/** 宽屏右侧品牌面板(叙事 + 特性清单);窄屏隐藏。 */
|
|
function BrandPanel() {
|
|
return (
|
|
<aside className="lg-panel">
|
|
<h2>把研发记忆,<br />变成组织的第二大脑</h2>
|
|
<p>文档、决策与知识的沉淀、检索与生成——面向团队的自托管智能体工作台。</p>
|
|
<ul>
|
|
<li><Icon name="message" size={15} />项目化多任务工作区,收件箱驱动待办</li>
|
|
<li><Icon name="shield" size={15} />管理员发放账号,密码策略与密钥分级</li>
|
|
<li><Icon name="search" size={15} />知识库混合检索(全文 + 向量)</li>
|
|
<li><Icon name="package" size={15} />文档生成流水线与工件管理</li>
|
|
</ul>
|
|
<div className="lg-foot"><span>自托管 · 多租户</span><span>数据不出域</span></div>
|
|
</aside>
|
|
)
|
|
}
|
|
|
|
/** 登录页:认证开启(PG 档/–-auth on)时的唯一入口。 */
|
|
export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; onMustChange: (owner: string) => void }) {
|
|
const [tab, setTab] = useState<'password' | 'apikey'>('password')
|
|
const [owner, setOwner] = useState('')
|
|
const [password, setPassword] = useState('')
|
|
const [key, setKey] = useState('')
|
|
const [err, setErr] = useState('')
|
|
const [busy, setBusy] = useState(false)
|
|
|
|
const submitPassword = async () => {
|
|
setErr(''); setBusy(true)
|
|
try {
|
|
const me = await api.login(owner.trim(), password)
|
|
if (me.mustChangePassword) {
|
|
// 初始/重置密码:先完成强制改密再放行。
|
|
onMustChange(me.owner)
|
|
return
|
|
}
|
|
onAuthed()
|
|
} catch (e) {
|
|
setErr(humanLoginError(String(e)))
|
|
} finally { setBusy(false) }
|
|
}
|
|
|
|
const submitKey = async () => {
|
|
setErr(''); setBusy(true)
|
|
auth.setKey(key)
|
|
try {
|
|
const r = await auth.fetch('/api/health')
|
|
if (r.ok) { onAuthed(); return }
|
|
throw new Error(String(r.status))
|
|
} catch {
|
|
auth.clear()
|
|
setErr('密钥无效或已撤销,请重试')
|
|
} finally { setBusy(false) }
|
|
}
|
|
|
|
return (
|
|
<div className="lg-wrap">
|
|
<BrandPanel />
|
|
<div className="lg-formside">
|
|
<form className="lg-card" onSubmit={e => { e.preventDefault(); tab === 'password' ? submitPassword() : submitKey() }}>
|
|
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
|
|
<h2 className="lg-title">登录</h2>
|
|
<p className="lg-sub">账号由管理员发放;首次登录需修改初始密码</p>
|
|
<div className="lg-tabs" role="tablist">
|
|
<button type="button" role="tab" aria-selected={tab === 'password'} className={`lg-tab ${tab === 'password' ? 'on' : ''}`} onClick={() => setTab('password')}>账号密码</button>
|
|
<button type="button" role="tab" aria-selected={tab === 'apikey'} className={`lg-tab ${tab === 'apikey' ? 'on' : ''}`} onClick={() => setTab('apikey')}>API 密钥</button>
|
|
</div>
|
|
{tab === 'password' ? (
|
|
<>
|
|
<label className="lg-label">用户名</label>
|
|
<input className="lg-input" value={owner} onChange={e => setOwner(e.target.value)} autoFocus autoComplete="username" placeholder="由管理员分配" />
|
|
<label className="lg-label">密码</label>
|
|
<input className="lg-input" type="password" value={password} onChange={e => setPassword(e.target.value)} autoComplete="current-password" placeholder="初始密码或常用密码" />
|
|
<Err msg={err} />
|
|
<button className="lg-submit" type="submit" disabled={busy || !owner.trim() || !password}>{busy ? '验证中…' : '登录'}</button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<label className="lg-label">API 访问密钥</label>
|
|
<input className="lg-input lg-mono" value={key} onChange={e => setKey(e.target.value)} autoFocus placeholder="ask_…" />
|
|
<Err msg={err} />
|
|
<button className="lg-submit" type="submit" disabled={busy || !key}>{busy ? '验证中…' : '进入'}</button>
|
|
<p className="lg-hint">密钥由服务端创建:<code>agent-system apikey create --owner <name></code>。程序化客户端也可直接带 Authorization 头。</p>
|
|
</>
|
|
)}
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** 强制改密门:初始密码/管理员重置后的首次登录必须完成改密才可进入系统。 */
|
|
export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string; onDone: () => void; onCancel?: () => void }) {
|
|
const [current, setCurrent] = useState('')
|
|
const [next, setNext] = useState('')
|
|
const [confirm, setConfirm] = useState('')
|
|
const [err, setErr] = useState('')
|
|
const [busy, setBusy] = useState(false)
|
|
|
|
const submit = async () => {
|
|
setErr('')
|
|
if (next !== confirm) { setErr('两次输入的新密码不一致'); return }
|
|
if (next.length < 8) { setErr('新密码至少 8 位'); return }
|
|
setBusy(true)
|
|
try {
|
|
await api.changePassword(current, next)
|
|
onDone()
|
|
} catch (e) {
|
|
setErr(humanLoginError(String(e)).replace('401: ', ''))
|
|
} finally { setBusy(false) }
|
|
}
|
|
|
|
return (
|
|
<div className="lg-wrap">
|
|
<BrandPanel />
|
|
<div className="lg-formside">
|
|
<form className="lg-card" onSubmit={e => { e.preventDefault(); submit() }}>
|
|
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
|
|
<h2 className="lg-title">修改密码</h2>
|
|
<p className="lg-sub">账号 <b>{owner}</b> 使用初始/重置密码首次登录,须设置新密码后继续</p>
|
|
<label className="lg-label">当前密码(初始/重置密码)</label>
|
|
<input className="lg-input" type="password" value={current} onChange={e => setCurrent(e.target.value)} autoFocus autoComplete="current-password" />
|
|
<label className="lg-label">新密码(≥8 位)</label>
|
|
<input className="lg-input" type="password" value={next} onChange={e => setNext(e.target.value)} autoComplete="new-password" />
|
|
<label className="lg-label">确认新密码</label>
|
|
<input className="lg-input" type="password" value={confirm} onChange={e => setConfirm(e.target.value)} autoComplete="new-password" />
|
|
<Err msg={err} />
|
|
<button className="lg-submit" type="submit" disabled={busy || !current || !next || !confirm}>{busy ? '提交中…' : '设置新密码并进入'}</button>
|
|
{onCancel && <button type="button" className="lg-cancel" onClick={onCancel}>返回登录</button>}
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** 错误文案:把 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*/, '')
|
|
}
|