Files
Agent-System-Frontend/src/components/AccountUsersPanel.tsx
T
agent-exec-transport 1fe321240a
governance / governance (pull_request) Successful in 13s
ci / test-and-build (pull_request) Successful in 46s
agent-exec transport 91fa500d0ed8f7daca02bd46ba843a9a/b201c6aced99
2026-10-20 10:48:43 +00:00

364 lines
24 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 {
api, type AccountProfile, type ManagedAccountRecord, type PermissionDescriptor, type RoleRecord, type UserRoleView,
} from '../api'
import { useStore } from '../store'
import { Collapsible, Icon } from '@ui'
import { hasSystemAction } from './RolePermissionsPanel'
import { accountActionPolicy, accountIdentityKind, canAppointAdmins } from '../accountAuthority'
import {
PROJECT_CREATE_ACTION, buildCapabilityGroups, projectCreateSources, rebaseGrantDraft, type CapabilityGroup, type GroupLevel,
} from '../rolePermissions'
type IdentityFlags = { isAdmin?: boolean; isSuperAdmin?: boolean }
const emptyProfile = (): AccountProfile => ({ name: '', phone: '', email: '', staffNo: '', department: '', notes: '' })
const fields: { key: keyof AccountProfile; label: string }[] = [
{ key: 'name', label: '姓名' }, { key: 'phone', label: '电话' }, { key: 'email', label: '邮箱' },
{ key: 'staffNo', label: '工号' }, { key: 'department', label: '部门' }, { key: 'notes', label: '备注' },
]
export const credentialLabel = (account: Pick<ManagedAccountRecord, 'initializationState' | 'passwordSet'>) =>
account.passwordSet ? '已设置密码' : '待设置密码'
function ProfileInputs({ profile, onChange, prefix }: { profile: AccountProfile; onChange: (value: AccountProfile) => void; prefix: string }) {
return <div className="ad-create">{fields.map(field => <div className="ad-row" key={field.key}>
<label className="lg-label" htmlFor={`${prefix}-${field.key}`}>{field.label}(可空)</label>
<input id={`${prefix}-${field.key}`} className="lg-input" value={profile[field.key]}
onChange={event => onChange({ ...profile, [field.key]: event.target.value })} />
</div>)}</div>
}
export function IdentityChip({ account }: { account: IdentityFlags }) {
const kind = accountIdentityKind(account)
if (kind === 'super') return <span className="ad-chip ad-chip-super">超级管理员</span>
if (kind === 'admin') return <span className="ad-chip ad-chip-admin">管理员</span>
return <span className="ad-chip">普通用户</span>
}
export function CreateAdminOption({ me, checked, disabled, onChange }: { me: IdentityFlags | null; checked: boolean; disabled: boolean; onChange: (value: boolean) => void }) {
if (!canAppointAdmins(me)) return <p className="ad-hint">仅超级管理员可创建或任命管理员;此处创建普通账号</p>
return <label className="ad-check"><input type="checkbox" checked={checked} disabled={disabled}
onChange={event => onChange(event.target.checked)} />创建为管理员账号</label>
}
export function AdminAppointmentButton({ me, account, busy, onChange }: { me: IdentityFlags | null; account: IdentityFlags; busy: boolean; onChange: (isAdmin: boolean) => void }) {
const change = accountActionPolicy(me, account).adminChange
if (!change) return null
return <button className="ad-mini" disabled={busy} onClick={() => onChange(change === 'promote')}>{change === 'promote' ? '设为管理员' : '撤销管理员'}</button>
}
function grantLevel(grants: string[], group: CapabilityGroup): GroupLevel {
const held = group.actions.filter(id => grants.includes(id)).length
return held === 0 ? 'off' : held === group.actions.length ? 'on' : 'mixed'
}
export function toggleGrantGroup(grants: string[], group: CapabilityGroup, checked: boolean): string[] {
return checked ? [...new Set([...grants, ...group.actions])].sort() : grants.filter(id => !group.actions.includes(id))
}
/** 明确系统授权按业务组授予;创建项目组说明联动形成的本人项目管理资格。 */
export function GrantGroupEditor({ catalog, grants, disabled, onChange }: {
catalog: PermissionDescriptor[]
grants: string[]
disabled: boolean
onChange: (grants: string[]) => void
}) {
const groups = buildCapabilityGroups(catalog.filter(item => item.scope === 'system'))
return <div className="rp-grants">{groups.map(group => {
const state = grantLevel(grants, group)
return <label className="ad-check" key={group.id} title={`内部动作 ${group.actions.join('、')}`}>
<input type="checkbox" aria-label={`授予 ${group.label}`} checked={state === 'on'} disabled={disabled}
onChange={event => onChange(toggleGrantGroup(grants, group, event.target.checked))} />
{group.label}{state === 'mixed' ? '(部分)' : ''}
{group.actions.includes(PROJECT_CREATE_ACTION) && <span className="ad-dim">:联动本人创建项目的设置、成员、项目权限与文档职责管理,不涉及他人项目</span>}
</label>
})}</div>
}
/** 授权差异预览:受影响来源为明确系统授权,影响人数为当前账号。 */
export function GrantChangePreview({ baseline, grants, labels, owner }: {
baseline: string[]; grants: string[]; labels: Record<string, string>; owner: string
}) {
const added = grants.filter(id => !baseline.includes(id))
const removed = baseline.filter(id => !grants.includes(id))
if (added.length === 0 && removed.length === 0) return <p className="ad-hint">系统授权没有变化</p>
const names = (ids: string[]) => ids.map(id => labels[id] ?? id).join('、')
return <div>
<p className="ad-hint" role="note">影响来源:明确系统授权 · 影响 1 名用户({owner})</p>
<ul className="rp-diff">
{added.length > 0 && <li>新增:{names(added)}{added.includes(PROJECT_CREATE_ACTION) ? '(联动本人创建项目的管理资格)' : ''}</li>}
{removed.length > 0 && <li>撤销:{names(removed)}{removed.includes(PROJECT_CREATE_ACTION) ? '(仅停止未来创建,已创建项目的管理资格不被反向删除)' : ''}</li>}
</ul>
</div>
}
/** 账号的创建能力来源与联动说明(角色默认与明确授权可并存)。 */
export function ProjectCreateSourceNote({ role, grants }: {
role: { name: string; permissions?: { actionId: string; allowed: boolean }[] } | null | undefined; grants: string[]
}) {
const sources = projectCreateSources(role, grants)
if (sources.length === 0) return <p className="ad-hint">未获得创建项目能力:被加入项目后由成员关系提供基础协作</p>
return <p className="ad-hint">创建项目来源:{sources.join(' + ')} · 联动形成本人创建项目的设置、成员、项目权限与文档职责管理资格</p>
}
/** getUserRole 的角色摘要不带 permissions:用已加载角色列表里同一 roleId 的条目补齐,
* 列表里也没有时按无角色默认权限处理(不新增请求,也不改写已有权限)。 */
export function roleWithPermissions(role: UserRoleView['role'], roles: RoleRecord[]): UserRoleView['role'] {
if (!role || role.permissions) return role
return roles.find(item => item.roleId === role.roleId) ?? role
}
export function AccountUsersPanel() {
const me = useStore(state => state.me)
const allowed = (action: string) => !!me?.isAdmin || hasSystemAction(me?.systemActions, action)
const canViewRoles = allowed('system.roles.view')
const canAssignRoles = canViewRoles && allowed('system.roles.assign')
const canViewGrants = allowed('system.permissions.view')
const canManageGrants = canViewGrants && allowed('system.permissions.manage')
const [accounts, setAccounts] = useState<ManagedAccountRecord[] | null>(null)
const [selected, setSelected] = useState<ManagedAccountRecord | null>(null)
const [roleView, setRoleView] = useState<UserRoleView | null>(null)
const [roles, setRoles] = useState<RoleRecord[]>([])
const [roleQuery, setRoleQuery] = useState('')
const [roleCursor, setRoleCursor] = useState('')
const [roleTrail, setRoleTrail] = useState<string[]>([])
const [nextRoleCursor, setNextRoleCursor] = useState('')
const [roleLoading, setRoleLoading] = useState(false)
const [catalog, setCatalog] = useState<PermissionDescriptor[]>([])
const [grants, setGrants] = useState<string[]>([])
const [grantBaseline, setGrantBaseline] = useState<string[]>([])
const [authorityEpoch, setAuthorityEpoch] = useState(0)
const [draft, setDraft] = useState<AccountProfile>(emptyProfile)
const [newOwner, setNewOwner] = useState('')
const [newProfile, setNewProfile] = useState<AccountProfile>(emptyProfile)
const [newIsAdmin, setNewIsAdmin] = useState(false)
const [query, setQuery] = useState('')
const [mode, setMode] = useState<'exact' | 'fuzzy'>('fuzzy')
const [field, setField] = useState('default')
const [status, setStatus] = useState<'all' | 'enabled' | 'disabled'>('all')
const [cursor, setCursor] = useState('')
const [trail, setTrail] = useState<string[]>([])
const [nextCursor, setNextCursor] = useState('')
const [selectedRoleId, setSelectedRoleId] = useState('')
const [busy, setBusy] = useState(false)
const [notice, setNotice] = useState('')
const [err, setErr] = useState('')
const sequence = useRef(0)
const roleSequence = useRef(0)
const labels = Object.fromEntries(catalog.map(item => [item.actionId, item.label]))
const search = async (pageCursor = cursor, afterWrite = false) => {
const request = ++sequence.current
try {
const page = await api.searchAdminUsers({ query: query.trim(), mode, status,
fields: field === 'default' ? ['owner', 'name', 'userId'] : [field], cursor: pageCursor, limit: 20 })
if (request !== sequence.current) return
setAccounts(page.accounts ?? []); setNextCursor(page.nextCursor ?? '')
} catch (error) {
if (request === sequence.current) setErr(afterWrite ? `操作已完成,但用户列表暂未刷新:${String(error)}` : String(error))
}
}
const resetPaging = () => { setCursor(''); setTrail([]) }
useEffect(() => {
const timer = window.setTimeout(() => { void search() }, 180)
return () => { window.clearTimeout(timer); sequence.current++ }
}, [query, mode, field, status, cursor])
const open = async (account: ManagedAccountRecord) => {
const request = ++sequence.current
roleSequence.current++
setRoleQuery(''); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(false)
setSelected(null); setRoleView(null); setErr(''); setNotice('')
const detailResult = await Promise.allSettled([api.adminGetUser(account.userId)])
if (request !== sequence.current) return
if (detailResult[0].status === 'rejected') { setErr(String(detailResult[0].reason)); return }
const detail = detailResult[0].value
setSelected(detail); setDraft(detail.profile)
const [roleResult, catalogResult, grantsResult] = await Promise.allSettled([
canViewRoles ? api.getUserRole(account.userId) : Promise.resolve(null),
canViewGrants ? api.permissionCatalog() : Promise.resolve(null),
canViewGrants ? api.getSystemGrants(account.userId) : Promise.resolve(null),
])
if (request !== sequence.current) return
if (roleResult.status === 'fulfilled' && roleResult.value) {
const currentRole = roleResult.value as UserRoleView
setRoleView(currentRole); setSelectedRoleId(currentRole.role?.roleId ?? '')
} else { setRoleView(null); setSelectedRoleId('') }
if (catalogResult.status === 'fulfilled' && catalogResult.value) setCatalog(catalogResult.value)
else setCatalog([])
if (grantsResult.status === 'fulfilled' && grantsResult.value) {
const actions = [...(grantsResult.value.actions ?? [])].sort()
setGrants(actions); setGrantBaseline(actions); setAuthorityEpoch(grantsResult.value.authorityEpoch)
} else { setGrants([]); setGrantBaseline([]); setAuthorityEpoch(0) }
}
useEffect(() => {
if (!selected || !canViewRoles) { setRoles([]); setRoleLoading(false); return }
const request = ++roleSequence.current
setRoleLoading(true); setNextRoleCursor('')
const timer = window.setTimeout(() => {
void api.listRoles({ query: roleQuery.trim(), mode: 'fuzzy', status: 'enabled', cursor: roleCursor, limit: 50 })
.then(page => {
if (request !== roleSequence.current) return
setRoles(page.roles ?? []); setNextRoleCursor(page.nextCursor ?? ''); setRoleLoading(false)
})
.catch(error => { if (request === roleSequence.current) { setErr(String(error)); setRoleLoading(false) } })
}, 180)
return () => { window.clearTimeout(timer) }
}, [selected?.userId, roleQuery, roleCursor, canViewRoles])
// 写入成功会推进目录版本,旧页游标随之失效:从第一页重读,刷新失败不改写成功结果。
const refreshFirst = async () => {
resetPaging(); setNextCursor('')
await search('', true)
}
const create = async () => {
if (!newOwner.trim()) return
setBusy(true); setErr(''); setNotice('')
try {
const account = await api.createManagedUser(newOwner.trim(), newProfile, canAppointAdmins(me) && newIsAdmin)
setNewOwner(''); setNewProfile(emptyProfile()); setNewIsAdmin(false)
await open(account)
setNotice(`已创建 ${account.owner}${account.isAdmin ? '(管理员)' : ''};账号资料可空,角色另行分配`)
await refreshFirst()
} catch (error) { setErr(String(error).includes('409') ? '登录名已存在' : String(error)) }
finally { setBusy(false) }
}
const saveProfile = async () => {
if (!selected) return
const patch: Partial<AccountProfile> = {}
for (const item of fields) if (draft[item.key] !== selected.profile[item.key]) patch[item.key] = draft[item.key]
if (!Object.keys(patch).length) { setNotice('资料没有变化'); return }
setBusy(true); setErr(''); setNotice('')
try {
const updated = await api.adminPatchProfile(selected.userId, selected.profileVersion, patch)
setSelected(updated); setDraft(updated.profile); await refreshFirst(); setNotice('用户资料已保存')
} catch (error) { setErr(String(error).includes('409') ? '资料版本冲突,请重新打开用户' : String(error)) }
finally { setBusy(false) }
}
const assignRole = async () => {
if (!selected || !selectedRoleId) return
setBusy(true); setErr(''); setNotice('')
try {
await api.assignUserRole(selected.userId, selectedRoleId, selected.stateVersion, crypto.randomUUID())
await open(selected); await refreshFirst(); setNotice('用户角色已替换')
} catch (error) { setErr(String(error).includes('409') ? '用户状态已变化,请重新打开用户' : String(error)) }
finally { setBusy(false) }
}
const saveGrants = async () => {
if (!selected) return
setBusy(true); setErr(''); setNotice('')
const userId = selected.userId
try {
const result = await api.replaceSystemGrants(userId, grants, authorityEpoch, crypto.randomUUID())
setAuthorityEpoch(result.authorityEpoch); setGrantBaseline(grants)
const expanded = result.linkedExpansion ?? []
setNotice(expanded.length ? `系统权限已保存;已联动补齐 ${expanded.map(id => labels[id] ?? id).join('、')}` : '系统权限已保存')
} catch (error) {
const raw = String(error)
if (raw.includes('final permission manager')) setErr('不能撤销最后一个有效权限管理者')
else if (raw.includes('409')) {
// 版本冲突:读取最新授权并重放本地增删,草稿不丢。
try {
const fresh = await api.getSystemGrants(userId)
const server = [...(fresh.actions ?? [])].sort()
setGrants(rebaseGrantDraft(grantBaseline, grants, server)); setGrantBaseline(server); setAuthorityEpoch(fresh.authorityEpoch)
setErr('系统权限版本冲突;已基于最新授权重放本地修改,请核对后再次保存')
} catch (refreshError) { setErr(`${String(refreshError)};本地草稿已保留`) }
} else setErr(`${raw.replace(/^Error:\s*/, '')};本地草稿已保留`)
}
finally { setBusy(false) }
}
const toggleState = async () => {
if (!selected) return
setBusy(true); setErr(''); setNotice('')
try {
const updated = await api.adminSetUserEnabled(selected.userId, selected.stateVersion, !selected.enabled)
setSelected(updated); setNotice(updated.enabled ? '账号已启用;旧会话和 Key 不会恢复' : '账号已停用,旧会话、Key 与进行中的恢复提案已撤销')
await refreshFirst()
} catch (error) { setErr(String(error).includes('409') ? '状态版本或最后管理员保护发生冲突,请重新打开用户' : String(error)) }
finally { setBusy(false) }
}
const setAdmin = async (isAdmin: boolean) => {
if (!selected) return
setBusy(true); setErr(''); setNotice('')
try {
const updated = await api.adminSetUserAdmin(selected.userId, selected.stateVersion, isAdmin)
setSelected(updated); await refreshFirst(); setNotice(isAdmin ? '已任命为管理员' : '已撤销管理员')
} catch (error) { setErr(String(error).includes('409') ? '用户状态已变化,请重新打开用户' : String(error)) }
finally { setBusy(false) }
}
const policy = selected ? accountActionPolicy(me, selected) : null
if (!allowed('system.users.view')) return <p className="ad-hint">当前账号没有查看用户的系统权限</p>
return <div>
{allowed('system.users.create') && <div className="ad-create">
<div className="ad-row"><input className="lg-input" aria-label="新用户登录名" placeholder="登录名" value={newOwner} onChange={event => setNewOwner(event.target.value)} />
<button className="lg-submit ad-create-btn" disabled={busy || !newOwner.trim()} onClick={create}>创建用户</button></div>
<CreateAdminOption me={me} checked={newIsAdmin} disabled={busy} onChange={setNewIsAdmin} />
<Collapsible id="new-user-profile" trigger="可选资料:姓名、电话、邮箱、工号、部门、备注">
<ProfileInputs profile={newProfile} onChange={setNewProfile} prefix="new-user" />
</Collapsible>
</div>}
{notice && <div className="ad-notice" role="status"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
{selected && policy ? <div className="rp-editor">
<div className="ad-row"><button className="ad-mini" onClick={() => { sequence.current++; setSelected(null) }}>返回列表</button>
<b>{selected.owner}</b><IdentityChip account={selected} /><span className="ad-dim ad-mono">{selected.userId}</span></div>
<p className="ad-hint">{selected.enabled ? '有效账号' : '停用账号'} · {credentialLabel(selected)} · 资料版本 {selected.profileVersion} · 当前角色 {canViewRoles ? (roleView?.role?.name ?? '未分配') : '无角色查看权限'}</p>
{policy.protectedReason && <p className="ad-hint" role="note">{policy.protectedReason}</p>}
{allowed('system.users.edit') && policy.maintain && <Collapsible id={`user-profile-${selected.userId}`} trigger="编辑可选资料">
<ProfileInputs profile={draft} onChange={setDraft} prefix="user-profile" />
<button className="lg-submit ad-create-btn" disabled={busy} onClick={saveProfile}>保存资料</button>
</Collapsible>}
<div className="ad-row">
{canAssignRoles && policy.maintain && <><select className="lg-input" aria-label="用户生效角色" value={selectedRoleId} onChange={event => setSelectedRoleId(event.target.value)}>
<option value="">请选择角色</option>{roles.map(role => <option key={role.roleId} value={role.roleId}>{role.name}</option>)}
</select><button className="ad-mini" disabled={busy || !selectedRoleId || selectedRoleId === roleView?.role?.roleId} onClick={assignRole}>替换角色</button></>}
{allowed('system.users.disable') && policy.toggleEnabled && <button className="ad-mini" disabled={busy} onClick={toggleState}>{selected.enabled ? '停用账号' : '启用账号'}</button>}
<AdminAppointmentButton me={me} account={selected} busy={busy} onChange={setAdmin} />
</div>
{canAssignRoles && policy.maintain && <div className="ad-row">
<input className="lg-input" aria-label="搜索可分配角色" placeholder="搜索有效角色" value={roleQuery}
onChange={event => { setRoleQuery(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }} />
<button className="ad-mini" disabled={roleLoading || !roleTrail.length} onClick={() => { setRoleCursor(roleTrail[roleTrail.length - 1]); setRoleTrail(roleTrail.slice(0, -1)) }}>上一页角色</button>
<button className="ad-mini" disabled={roleLoading || !nextRoleCursor} onClick={() => { setRoleTrail([...roleTrail, roleCursor]); setRoleCursor(nextRoleCursor) }}>下一页角色</button>
</div>}
{(canViewRoles || canViewGrants) && <ProjectCreateSourceNote role={roleWithPermissions(roleView?.role, roles)} grants={grantBaseline} />}
{canViewGrants && <Collapsible id={`system-grants-${selected.userId}`} trigger="明确系统权限">
<GrantGroupEditor catalog={catalog} grants={grants} disabled={!canManageGrants || !policy.maintain || busy} onChange={setGrants} />
<GrantChangePreview baseline={grantBaseline} grants={grants} labels={labels} owner={selected.owner} />
{canManageGrants && policy.maintain && <button className="lg-submit ad-create-btn" disabled={busy} onClick={saveGrants}>保存系统权限</button>}
</Collapsible>}
{!canViewGrants && allowed('system.permissions.manage') && <p className="ad-hint">需要同时具备系统权限查看权,才能安全编辑现有授权</p>}
</div> : <>
<div className="ad-row rp-filters">
<input className="lg-input" aria-label="搜索用户" placeholder="搜索用户资料" value={query} onChange={event => { setQuery(event.target.value); resetPaging() }} />
<select className="lg-input" aria-label="用户匹配方式" value={mode} onChange={event => { setMode(event.target.value as typeof mode); resetPaging() }}><option value="fuzzy">模糊匹配</option><option value="exact">精确匹配</option></select>
<select className="lg-input" aria-label="用户搜索字段" value={field} onChange={event => { setField(event.target.value); resetPaging() }}>
<option value="default">账号/姓名/ID</option><option value="owner">登录名</option><option value="name">姓名</option><option value="userId">userId</option>
<option value="phone">电话</option><option value="email">邮箱</option><option value="staffNo">工号</option><option value="department">部门</option><option value="notes">备注</option>
</select>
<select className="lg-input" aria-label="用户状态" value={status} onChange={event => { setStatus(event.target.value as typeof status); resetPaging() }}><option value="all">全部状态</option><option value="enabled">有效</option><option value="disabled">停用</option></select>
</div>
<table className="ad-table"><thead><tr><th>登录名</th><th>姓名</th><th>工号</th><th>身份</th><th>状态</th><th>首次凭据</th><th></th></tr></thead>
<tbody>{(accounts ?? []).map(account => <tr key={account.userId}><td className="ad-mono">{account.owner}</td><td>{account.profile.name || '—'}</td>
<td>{account.profile.staffNo || '—'}</td><td><IdentityChip account={account} /></td><td>{account.enabled ? '有效' : '停用'}</td>
<td>{credentialLabel(account)}</td><td><button className="ad-mini" onClick={() => open(account)}>查看与管理</button></td></tr>)}
{accounts !== null && accounts.length === 0 && <tr><td colSpan={7} className="ad-dim">没有匹配用户</td></tr>}
{accounts === null && <tr><td colSpan={7} className="ad-dim">加载中…</td></tr>}</tbody></table>
<div className="ad-row"><button className="ad-mini" disabled={!trail.length} onClick={() => { setCursor(trail[trail.length - 1]); setTrail(trail.slice(0, -1)) }}>上一页</button>
<button className="ad-mini" disabled={!nextCursor} onClick={() => { setTrail([...trail, cursor]); setCursor(nextCursor) }}>下一页</button></div>
</>}
</div>
}