Files
Agent-System-Frontend/src/components/AdminUsersModal.tsx
T
weichen 59ffe044b5 Consume the UI capability platform: shared icons/avatars, settings shell
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
2026-09-30 14:22:37 +00:00

163 lines
8.0 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.
// 管理员用户管理弹层 + 主动改密弹窗(R2 管理员发放制)。
// AdminUsersModal:建号(用户名/显示名/初始密码/管理员位)、重置密码、列表状态。
// ChangePasswordModal:登录者主动改密(与强制改密共用 API)。
import { useEffect, useState } from 'react'
import { api } from '../api'
import { store, useStore } from '../store'
import { Icon, Avatar } from '@ui'
import { randomPassword } from '../passwords'
interface AdminUserRow { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }
function Shell({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
return (
<div className="overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="modal ad-card" role="dialog" aria-label={title} style={{ maxWidth: 640 }}>
<div className="modal-header">
<b>{title}</b>
<span className="spacer" />
<button className="ad-mini" onClick={onClose} aria-label="关闭"><Icon name="x" size={13} /></button>
</div>
<div className="ad-body">{children}</div>
</div>
</div>
)
}
export function AdminUsersModal({ onClose }: { onClose: () => void }) {
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 [pw, setPw] = useState('')
const [asAdmin, setAsAdmin] = useState(false)
const [busy, setBusy] = useState(false)
const [resetFor, setResetFor] = useState<string | null>(null)
const [resetPw, setResetPw] = useState('')
const refresh = async () => {
try { setUsers(await api.listAdminUsers()) } catch (e) { setErr(String(e)) }
}
useEffect(() => { refresh() }, [])
const create = async () => {
setErr(''); setNotice('')
if (pw.length < 8) { setErr('初始密码至少 8 位'); return }
setBusy(true)
try {
await api.createAdminUser(owner.trim(), name.trim(), pw, asAdmin)
setNotice(`已创建 ${owner.trim()}:首次登录须改密`)
setOwner(''); setName(''); setPw('')
await refresh()
} catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) }
}
const reset = async () => {
if (!resetFor) return
setErr('')
if (resetPw.length < 8) { setErr('新密码至少 8 位'); return }
setBusy(true)
try {
await api.resetAdminUserPassword(resetFor, resetPw)
setNotice(`已重置 ${resetFor} 的密码:其下次登录须改密(现有会话已吊销)`)
setResetFor(null); setResetPw('')
await refresh()
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
return (
<Shell title={`用户管理${me?.isAdmin ? '' : '(只读)'}`} onClose={onClose}>
{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)} />
</div>
<div className="ad-row">
<input className="lg-input lg-mono" placeholder="初始密码(≥8 位)" value={pw} onChange={e => setPw(e.target.value)} />
<button type="button" className="ad-mini" title="生成随机密码" onClick={() => setPw(randomPassword())}>随机</button>
<label className="ad-check"><input type="checkbox" checked={asAdmin} onChange={e => setAsAdmin(e.target.checked)} />管理员</label>
<button className="lg-submit ad-create-btn" disabled={busy || !owner.trim() || !pw} onClick={create}>创建账号</button>
</div>
<p className="ad-hint">创建后该用户首次登录必须修改此初始密码。</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>}
{resetFor ? (
<div className="ad-reset">
<span>重置 <b>{resetFor}</b> 的密码:</span>
<input className="lg-input lg-mono" placeholder="新密码(≥8 位)" value={resetPw} onChange={e => setResetPw(e.target.value)} />
<button type="button" className="ad-mini" title="生成随机密码" onClick={() => setResetPw(randomPassword())}>随机</button>
<button className="lg-submit ad-create-btn" disabled={busy || !resetPw} onClick={reset}>确认重置</button>
<button type="button" className="ad-mini" onClick={() => { setResetFor(null); setResetPw('') }}>取消</button>
</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"><Avatar name={u.name || u.owner} size="sm" /><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={() => { setResetFor(u.owner); setResetPw('') }}>重置密码</button>
)}</td>
</tr>
))}
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
</tbody>
</table>
)}
</Shell>
)
}
/** 主动改密(账户菜单入口):当前密码 + 新密码 + 确认。 */
export function ChangePasswordModal({ onClose }: { onClose: () => void }) {
const me = useStore(s => s.me)
const [current, setCurrent] = useState('')
const [next, setNext] = useState('')
const [confirm, setConfirm] = useState('')
const [err, setErr] = useState('')
const [ok, setOk] = useState(false)
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)
setOk(true)
store.toast('密码已更新')
setTimeout(onClose, 900)
} catch (e) {
setErr(String(e).includes('401') ? '当前密码不正确' : String(e))
} finally { setBusy(false) }
}
return (
<Shell title="修改密码" onClose={onClose}>
{ok ? <div className="ad-notice">密码已更新,其他设备的会话已注销。</div> : (
<div className="ad-changepw">
<label className="lg-label">当前密码({me?.owner ?? ''})</label>
<input className="lg-input" type="password" value={current} onChange={e => setCurrent(e.target.value)} autoFocus />
<label className="lg-label">新密码(≥8 位)</label>
<input className="lg-input" type="password" value={next} onChange={e => setNext(e.target.value)} />
<label className="lg-label">确认新密码</label>
<input className="lg-input" type="password" value={confirm} onChange={e => setConfirm(e.target.value)} />
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
<button className="lg-submit" disabled={busy || !current || !next || !confirm} onClick={submit}>{busy ? '提交中…' : '更新密码'}</button>
</div>
)}
</Shell>
)
}