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
163 lines
8.0 KiB
TypeScript
163 lines
8.0 KiB
TypeScript
// 管理员用户管理弹层 + 主动改密弹窗(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>
|
||
)
|
||
}
|