Harden role permission administration flows

Why: Fine-grained viewers and managers need independent data loading, safe conflict recovery, and complete paged role access.

Evidence: Frontend 281 tests, production build, and knip baseline passed; independent final review found no remaining findings.

Impact: Adds action-specific controls, server-side role filters and paging, matrix cap editing, and sequence-safe draft rebasing.

Refs: feature/iam-u4-role-authority
This commit is contained in:
weichen
2026-10-15 02:28:03 +00:00
parent 1fc9dabc13
commit 9d0ae331c7
5 changed files with 227 additions and 64 deletions
+1
View File
@@ -59,6 +59,7 @@ export const api = {
const query = params.toString()
return auth.fetch(`${BASE}/api/admin/roles${query ? `?${query}` : ''}`).then(r => json<RoleListPage>(r))
},
getRole: (roleId: string) => auth.fetch(`${BASE}/api/admin/roles/${encodeURIComponent(roleId)}`).then(r => json<RoleRecord>(r)),
createRole: (name: string, description: string) => auth.fetch(`${BASE}/api/admin/roles`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, description }),
}).then(r => json<RoleRecord>(r)),
+59 -21
View File
@@ -23,10 +23,19 @@ function ProfileInputs({ profile, onChange, prefix }: { profile: AccountProfile;
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<AccountRecord[] | null>(null)
const [selected, setSelected] = useState<AccountRecord | 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 [authorityEpoch, setAuthorityEpoch] = useState(0)
@@ -45,6 +54,7 @@ export function AccountUsersPanel() {
const [notice, setNotice] = useState('')
const [err, setErr] = useState('')
const sequence = useRef(0)
const roleSequence = useRef(0)
const search = async (pageCursor = cursor) => {
const request = ++sequence.current
@@ -63,25 +73,46 @@ export function AccountUsersPanel() {
const open = async (account: AccountRecord) => {
const request = ++sequence.current
roleSequence.current++
setRoleQuery(''); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(false)
setSelected(null); setRoleView(null); setErr(''); setNotice('')
try {
const tasks: Promise<unknown>[] = [api.adminGetUser(account.userId), api.getUserRole(account.userId), api.listRoles({ status: 'enabled', limit: 100 })]
if (allowed('system.permissions.view')) tasks.push(api.permissionCatalog(), api.getSystemGrants(account.userId))
const result = await Promise.all(tasks)
if (request !== sequence.current) return
const detail = result[0] as AccountRecord
const currentRole = result[1] as UserRoleView
setSelected(detail); setDraft(detail.profile); setRoleView(currentRole)
setSelectedRoleId(currentRole.role?.roleId ?? '')
setRoles((result[2] as { roles: RoleRecord[] }).roles ?? [])
if (result.length > 3) {
setCatalog(result[3] as PermissionDescriptor[])
const system = result[4] as { actions: string[]; authorityEpoch: number }
setGrants(system.actions ?? []); setAuthorityEpoch(system.authorityEpoch)
} else { setCatalog([]); setGrants([]); setAuthorityEpoch(0) }
} catch (error) { if (request === sequence.current) setErr(String(error)) }
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) {
setGrants(grantsResult.value.actions ?? []); setAuthorityEpoch(grantsResult.value.authorityEpoch)
} else { setGrants([]); 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 () => {
setCursor(''); setTrail([]); setNextCursor('')
await search('')
@@ -155,24 +186,31 @@ export function AccountUsersPanel() {
{selected ? <div className="rp-editor">
<div className="ad-row"><button className="ad-mini" onClick={() => { sequence.current++; setSelected(null) }}>返回列表</button>
<b>{selected.owner}</b><span className="ad-dim ad-mono">{selected.userId}</span></div>
<p className="ad-hint">{selected.enabled ? '有效账号' : '停用账号'} · 当前角色 {roleView?.role?.name ?? '未分配'}</p>
<p className="ad-hint">{selected.enabled ? '有效账号' : '停用账号'} · 当前角色 {canViewRoles ? (roleView?.role?.name ?? '未分配') : '无角色查看权限'}</p>
{allowed('system.users.edit') && <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">
{allowed('system.roles.assign') && <><select className="lg-input" aria-label="用户生效角色" value={selectedRoleId} onChange={event => setSelectedRoleId(event.target.value)}>
{canAssignRoles && <><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') && <button className="ad-mini" disabled={busy} onClick={toggleState}>{selected.enabled ? '停用账号' : '启用账号'}</button>}
</div>
{allowed('system.permissions.manage') && <Collapsible id={`system-grants-${selected.userId}`} trigger="明确系统权限">
{canAssignRoles && <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>}
{canViewGrants && <Collapsible id={`system-grants-${selected.userId}`} trigger="明确系统权限">
<div className="rp-grants">{catalog.filter(item => item.scope === 'system').map(item => <label className="ad-check" key={item.actionId}>
<input type="checkbox" checked={grants.includes(item.actionId)} onChange={event => setGrants(current => event.target.checked
<input type="checkbox" checked={grants.includes(item.actionId)} disabled={!canManageGrants || busy} onChange={event => setGrants(current => event.target.checked
? [...new Set([...current, item.actionId])] : current.filter(value => value !== item.actionId))} />{item.label}<span className="ad-dim ad-mono">{item.actionId}</span>
</label>)}</div>
<button className="lg-submit ad-create-btn" disabled={busy} onClick={saveGrants}>保存系统权限</button>
{canManageGrants && <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); setCursor(''); setTrail([]) }} />
+143 -42
View File
@@ -1,10 +1,11 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { api, type PermissionDescriptor, type RoleRecord } from '../api'
import {
createRolePermissionDraft, updateRolePermission, validateRolePermissionDraft, visiblePatch,
createRolePermissionDraft, rebaseRoleDraft, updateRolePermission, validateRolePermissionDraft, visiblePatch,
type RolePermissionDraft, type RolePermissionValue,
} from '../rolePermissions'
import { Collapsible, Icon } from '@ui'
import { useStore } from '../store'
export function filterRoles(
roles: RoleRecord[], query: string, mode: 'exact' | 'fuzzy', status: 'all' | 'enabled' | 'disabled',
@@ -46,6 +47,11 @@ function roleValue(role: RoleRecord, actionId: string): RolePermissionValue {
}
export function RolePermissionsPanel() {
const me = useStore(state => state.me)
const allowed = (action: string) => !!me?.isAdmin || hasSystemAction(me?.systemActions, action)
const canManageRoles = allowed('system.roles.manage')
const canViewPermissions = allowed('system.permissions.view')
const canManagePermissions = canViewPermissions && allowed('system.permissions.manage')
const [roles, setRoles] = useState<RoleRecord[] | null>(null)
const [catalog, setCatalog] = useState<PermissionDescriptor[]>([])
const [selectedId, setSelectedId] = useState('')
@@ -56,38 +62,54 @@ export function RolePermissionsPanel() {
const [mode, setMode] = useState<'exact' | 'fuzzy'>('fuzzy')
const [status, setStatus] = useState<'all' | 'enabled' | 'disabled'>('all')
const [group, setGroup] = useState('')
const [actionFilter, setActionFilter] = useState('')
const [roleCursor, setRoleCursor] = useState('')
const [roleTrail, setRoleTrail] = useState<string[]>([])
const [nextRoleCursor, setNextRoleCursor] = useState('')
const [page, setPage] = useState(0)
const [view, setView] = useState<'single' | 'matrix'>('single')
const [newName, setNewName] = useState('')
const [newDescription, setNewDescription] = useState('')
const [busy, setBusy] = useState(false)
const [roleLoading, setRoleLoading] = useState(false)
const [notice, setNotice] = useState('')
const [err, setErr] = useState('')
const requestSequence = useRef(0)
const selectedIdRef = useRef('')
selectedIdRef.current = selectedId
const selected = roles?.find(role => role.roleId === selectedId) ?? null
const groups = useMemo(() => [...new Set(catalog.map(item => item.group))], [catalog])
const filtered = useMemo(() => filterRoles(roles ?? [], query, mode, status), [roles, query, mode, status])
const filtered = roles ?? []
const pageItems = useMemo(() => groupPermissionPage(catalog, group, page, 6), [catalog, group, page])
const editablePageItems = pageItems.filter(item => !item.protected)
const changes = draft ? visiblePatch(draft, catalog.filter(item => !item.protected).map(item => item.actionId)) : []
const load = async () => {
const load = async (pageCursor = roleCursor, refreshCatalog = false, preferredId = selectedId,
filters = { query, mode, status, action: actionFilter }) => {
const sequence = ++requestSequence.current
setRoleLoading(true)
setErr('')
try {
const [rolePage, descriptors] = await Promise.all([
api.listRoles({ limit: 100 }), api.permissionCatalog(),
])
if (!shouldAcceptRoleResponse(requestSequence.current, sequence)) return
setRoles(rolePage.roles ?? [])
setCatalog(descriptors)
const nextSelected = rolePage.roles.find(role => role.roleId === selectedId) ?? rolePage.roles[0] ?? null
if (nextSelected) selectRole(nextSelected, descriptors, sequence)
else { setSelectedId(''); setDraft(null) }
} catch (error) {
if (shouldAcceptRoleResponse(requestSequence.current, sequence)) setErr(String(error))
const [roleResult, catalogResult] = await Promise.allSettled([
api.listRoles({ query: filters.query.trim(), mode: filters.mode, status: filters.status,
action: filters.action || undefined, cursor: pageCursor, limit: 50 }),
canViewPermissions && (refreshCatalog || catalog.length === 0) ? api.permissionCatalog() : Promise.resolve(catalog),
])
if (!shouldAcceptRoleResponse(requestSequence.current, sequence)) return
setRoleLoading(false)
const descriptors = catalogResult.status === 'fulfilled' ? catalogResult.value : catalog
if (catalogResult.status === 'fulfilled') setCatalog(descriptors)
if (roleResult.status === 'rejected') {
setErr(String(roleResult.reason))
return
}
if (catalogResult.status === 'rejected') setErr(`权限目录加载失败:${catalogResult.reason}`)
const rolePage = roleResult.value
setRoles(rolePage.roles ?? [])
setNextRoleCursor(rolePage.nextCursor ?? '')
const nextSelected = rolePage.roles.find(role => role.roleId === preferredId) ?? rolePage.roles[0] ?? null
if (nextSelected) selectRole(nextSelected, descriptors, sequence)
else { setSelectedId(''); setDraft(null) }
}
const selectRole = (role: RoleRecord, descriptors = catalog, sequence?: number) => {
@@ -101,48 +123,99 @@ export function RolePermissionsPanel() {
setErr('')
}
useEffect(() => { void load(); return () => { requestSequence.current++ } }, [])
useEffect(() => {
setNextRoleCursor(''); setRoleLoading(true)
const timer = window.setTimeout(() => { void load(roleCursor) }, 180)
return () => { window.clearTimeout(timer); requestSequence.current++ }
}, [query, mode, status, actionFilter, roleCursor, canViewPermissions])
const create = async () => {
if (!newName.trim()) return
requestSequence.current++; setRoleLoading(false)
setBusy(true); setErr(''); setNotice('')
try {
const role = await api.createRole(newName.trim(), newDescription.trim())
setNewName(''); setNewDescription('')
await load()
setQuery(''); setStatus('all'); setActionFilter(''); setRoleCursor(''); setRoleTrail([])
setRoles(current => [role, ...(current ?? []).filter(item => item.roleId !== role.roleId)])
selectRole(role, catalog, ++requestSequence.current)
setNotice(`已创建角色 ${role.name}`)
await load('', true, role.roleId, { query: '', mode, status: 'all', action: '' })
setNotice(`已创建角色 ${role.name}`)
} catch (error) { setErr(String(error)) } finally { setBusy(false) }
}
const saveMetadata = async () => {
if (!selected) return
const roleId = selected.roleId
const operationSequence = ++requestSequence.current
setRoleLoading(false)
setBusy(true); setErr(''); setNotice('')
try {
await api.patchRole(selected.roleId, selected.roleVersion, { name: name.trim(), description: description.trim() })
await load(); setNotice('角色资料已保存')
} catch (error) { setErr(String(error).includes('409') ? '角色已被其他操作修改,请刷新后重试' : String(error)) }
} catch (error) {
if (String(error).includes('409')) {
try {
const fresh = await api.getRole(roleId)
if (requestSequence.current !== operationSequence || selectedIdRef.current !== roleId) return
setRoles(current => current?.some(role => role.roleId === fresh.roleId)
? current.map(role => role.roleId === fresh.roleId ? fresh : role) : [fresh, ...(current ?? [])])
setErr('角色资料版本冲突;已读取最新版本并保留本地名称与描述,请核对后重试')
} catch (refreshError) { setErr(String(refreshError)) }
} else setErr(String(error))
}
finally { setBusy(false) }
}
const setEnabled = async () => {
if (!selected) return
const roleId = selected.roleId
const operationSequence = ++requestSequence.current
setRoleLoading(false)
setBusy(true); setErr(''); setNotice('')
try {
await api.patchRole(selected.roleId, selected.roleVersion, { enabled: !selected.enabled })
await load(); setNotice(selected.enabled ? '角色已停用,现有绑定不再授予权限' : '角色已启用')
} catch (error) { setErr(String(error)) } finally { setBusy(false) }
} catch (error) {
if (String(error).includes('409')) {
try {
const fresh = await api.getRole(roleId)
if (requestSequence.current !== operationSequence || selectedIdRef.current !== roleId) return
setRoles(current => current?.some(role => role.roleId === fresh.roleId)
? current.map(role => role.roleId === fresh.roleId ? fresh : role) : [fresh, ...(current ?? [])])
setErr('角色状态版本冲突;已刷新当前状态,请重新确认')
} catch (refreshError) { setErr(String(refreshError)) }
} else setErr(String(error))
} finally { setBusy(false) }
}
const rebasePermissionConflict = async (roleId: string, currentDraft: RolePermissionDraft, operationSequence: number) => {
const fresh = await api.getRole(roleId)
if (requestSequence.current !== operationSequence || selectedIdRef.current !== roleId) return
setRoles(current => current?.some(role => role.roleId === roleId)
? current.map(role => role.roleId === roleId ? fresh : role) : [fresh, ...(current ?? [])])
setDraft(rebaseRoleDraft(currentDraft, roleValues(fresh, catalog)))
setErr('权限版本冲突;已基于最新版本重放本地草稿,请核对后再次保存')
}
const savePermissions = async () => {
if (!selected || !draft) return
const roleId = selected.roleId
const operationSequence = ++requestSequence.current
setRoleLoading(false)
const invalid = validateRolePermissionDraft(draft)
if (invalid) { setErr(`${invalid.actionId} 的默认权限不能超过角色上限`); return }
const full = Object.entries(draft.values).map(([actionId, value]) => ({ actionId, ...value }))
setBusy(true); setErr(''); setNotice('')
try {
await api.replaceRolePermissions(selected.roleId, selected.roleVersion, full, crypto.randomUUID())
await load(); setNotice('角色权限已保存')
} catch (error) { setErr(String(error).includes('409') ? '权限版本冲突,草稿已保留,请核对后重试' : String(error)) }
await load(roleCursor, true, selected.roleId); setNotice('角色权限已保存')
} catch (error) {
if (String(error).includes('409')) {
try { await rebasePermissionConflict(roleId, draft, operationSequence) } catch (refreshError) { setErr(String(refreshError)) }
} else setErr(String(error))
}
finally { setBusy(false) }
}
@@ -155,18 +228,33 @@ export function RolePermissionsPanel() {
setDraft(updateRolePermission(draft, actionId, value))
}
const editVisible = (field: 'allowed' | 'capAllowed', checked: boolean) => {
if (!draft) return
let next = draft
for (const item of editablePageItems) {
const current = next.values[item.actionId] ?? { allowed: false, capAllowed: false }
const value = field === 'allowed'
? { allowed: checked, capAllowed: checked ? true : current.capAllowed }
: { allowed: checked ? current.allowed : false, capAllowed: checked }
next = updateRolePermission(next, item.actionId, value)
}
setDraft(next)
}
const matrix = () => <div className="rp-matrix-wrap">
<table className="ad-table rp-matrix">
<thead><tr><th>角色</th>{pageItems.map(item => <th key={item.actionId}>{item.label}</th>)}</tr></thead>
<tbody>{filtered.map(role => <tr key={role.roleId} className={role.roleId === selectedId ? 'rp-selected' : ''}>
<td><button className="ad-mini" onClick={() => selectRole(role)}>{role.name}</button><span className="ad-dim"> · {role.bindingCount} 人</span></td>
<td><button className="ad-mini" disabled={busy || roleLoading} onClick={() => selectRole(role)}>{role.name}</button><span className="ad-dim"> · {role.bindingCount} 人</span></td>
{pageItems.map(item => {
const value = role.roleId === selectedId && draft ? draft.values[item.actionId] : roleValue(role, item.actionId)
return <td key={item.actionId}>
{item.protected ? <span className="ad-dim">系统授权</span> : <label className="ad-check">
<input type="checkbox" aria-label={`${role.name} ${item.label}`} checked={value?.allowed ?? false}
disabled={busy || role.roleId !== selectedId} onChange={event => editValue(item.actionId, 'allowed', event.target.checked)} />允许
</label>}
{item.protected ? <span className="ad-dim">系统授权</span> : <>
<label className="ad-check"><input type="checkbox" aria-label={`${role.name} ${item.label} 默认允许`} checked={value?.allowed ?? false}
disabled={busy || roleLoading || !canManagePermissions || role.roleId !== selectedId} onChange={event => editValue(item.actionId, 'allowed', event.target.checked)} />默认</label>
<label className="ad-check"><input type="checkbox" aria-label={`${role.name} ${item.label} 角色上限`} checked={value?.capAllowed ?? false}
disabled={busy || roleLoading || !canManagePermissions || role.roleId !== selectedId} onChange={event => editValue(item.actionId, 'capAllowed', event.target.checked)} />上限</label>
</>}
</td>
})}
</tr>)}</tbody>
@@ -174,56 +262,68 @@ export function RolePermissionsPanel() {
</div>
return <div className="rp-panel">
<div className="ad-create">
{canManageRoles && <div className="ad-create">
<div className="ad-row">
<input className="lg-input" aria-label="新角色名称" placeholder="角色名称" value={newName} onChange={event => setNewName(event.target.value)} />
<input className="lg-input" aria-label="新角色描述" placeholder="描述(可空)" value={newDescription} onChange={event => setNewDescription(event.target.value)} />
<button className="lg-submit ad-create-btn" disabled={busy || !newName.trim()} onClick={create}>创建角色</button>
<button className="lg-submit ad-create-btn" disabled={busy || roleLoading || !newName.trim()} onClick={create}>创建角色</button>
</div>
</div>
</div>}
<Collapsible id="role-permission-filters" trigger="搜索与过滤">
<div className="ad-row rp-filters">
<input className="lg-input" aria-label="搜索角色" placeholder="角色名称、描述或 ID" value={query} onChange={event => setQuery(event.target.value)} />
<select className="lg-input" aria-label="角色匹配方式" value={mode} onChange={event => setMode(event.target.value as typeof mode)}>
<input className="lg-input" aria-label="搜索角色" placeholder="角色名称、描述或 ID" value={query} disabled={busy} onChange={event => { setQuery(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }} />
<select className="lg-input" aria-label="角色匹配方式" value={mode} disabled={busy} onChange={event => { setMode(event.target.value as typeof mode); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }}>
<option value="fuzzy">模糊匹配</option><option value="exact">精确匹配</option>
</select>
<select className="lg-input" aria-label="角色状态" value={status} onChange={event => setStatus(event.target.value as typeof status)}>
<select className="lg-input" aria-label="角色状态" value={status} disabled={busy} onChange={event => { setStatus(event.target.value as typeof status); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }}>
<option value="all">全部状态</option><option value="enabled">启用</option><option value="disabled">停用</option>
</select>
<select className="lg-input" aria-label="权限分组" value={group} onChange={event => { setGroup(event.target.value); setPage(0) }}>
<option value="">全部权限</option>{groups.map(value => <option key={value}>{value}</option>)}
</select>
{canViewPermissions && <select className="lg-input" aria-label="按权限过滤角色" value={actionFilter} disabled={busy}
onChange={event => { setActionFilter(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }}>
<option value="">全部角色权限</option>{catalog.filter(item => !item.protected).map(item => <option key={item.actionId} value={item.actionId}>{item.label}</option>)}
</select>}
</div>
</Collapsible>
<div className="ad-row rp-view-toggle">
<button className={`ad-mini ${view === 'single' ? 'on' : ''}`} onClick={() => setView('single')}>单角色配置</button>
<button className={`ad-mini ${view === 'matrix' ? 'on' : ''}`} onClick={() => setView('matrix')}>角色矩阵</button>
{canViewPermissions && <button className={`ad-mini ${view === 'matrix' ? 'on' : ''}`} onClick={() => setView('matrix')}>角色矩阵</button>}
<button className="ad-mini" disabled={page === 0} onClick={() => setPage(value => value - 1)}>上一组</button>
<button className="ad-mini" disabled={(page + 1) * 6 >= catalog.filter(item => !group || item.group === group).length} onClick={() => setPage(value => value + 1)}>下一组</button>
<button className="ad-mini" disabled={busy || roleLoading || !roleTrail.length} onClick={() => { setRoleCursor(roleTrail[roleTrail.length - 1]); setRoleTrail(roleTrail.slice(0, -1)) }}>上一页角色</button>
<button className="ad-mini" disabled={busy || roleLoading || !nextRoleCursor} onClick={() => { setRoleTrail([...roleTrail, roleCursor]); setRoleCursor(nextRoleCursor) }}>下一页角色</button>
{view === 'matrix' && canManagePermissions && <>
<button className="ad-mini" disabled={busy || roleLoading || !editablePageItems.length} onClick={() => editVisible('allowed', true)}>当前页默认全选</button>
<button className="ad-mini" disabled={busy || roleLoading || !editablePageItems.length} onClick={() => editVisible('allowed', false)}>当前页默认清除</button>
<button className="ad-mini" disabled={busy || roleLoading || !editablePageItems.length} onClick={() => editVisible('capAllowed', true)}>当前页上限全开</button>
<button className="ad-mini" disabled={busy || roleLoading || !editablePageItems.length} onClick={() => editVisible('capAllowed', false)}>当前页上限关闭</button>
</>}
</div>
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
{view === 'matrix' ? matrix() : <div className="rp-single">
<aside className="rp-role-list">
{(filtered).map(role => <button key={role.roleId} className={`az-nav-item ${role.roleId === selectedId ? 'on' : ''}`} onClick={() => selectRole(role)}>
{(filtered).map(role => <button key={role.roleId} disabled={busy || roleLoading} className={`az-nav-item ${role.roleId === selectedId ? 'on' : ''}`} onClick={() => selectRole(role)}>
<span>{role.name}</span><span className="ad-dim">{role.bindingCount} 人</span>
</button>)}
</aside>
<section className="rp-editor">
{selected && draft ? <>
<div className="ad-row">
<input className="lg-input" aria-label="角色名称" value={name} onChange={event => setName(event.target.value)} />
<input className="lg-input" aria-label="角色描述" value={description} onChange={event => setDescription(event.target.value)} />
<button className="ad-mini" disabled={busy} onClick={saveMetadata}>保存资料</button>
<button className="ad-mini" disabled={busy} onClick={setEnabled}>{selected.enabled ? '停用角色' : '启用角色'}</button>
<input className="lg-input" aria-label="角色名称" value={name} disabled={!canManageRoles || busy || roleLoading} onChange={event => setName(event.target.value)} />
<input className="lg-input" aria-label="角色描述" value={description} disabled={!canManageRoles || busy || roleLoading} onChange={event => setDescription(event.target.value)} />
{canManageRoles && <button className="ad-mini" disabled={busy || roleLoading} onClick={saveMetadata}>保存资料</button>}
{canManageRoles && <button className="ad-mini" disabled={busy || roleLoading} onClick={setEnabled}>{selected.enabled ? '停用角色' : '启用角色'}</button>}
</div>
<table className="ad-table"><thead><tr><th>权限</th><th>默认允许</th><th>角色上限</th><th>说明</th></tr></thead>
{canViewPermissions ? <><table className="ad-table"><thead><tr><th>权限</th><th>默认允许</th><th>角色上限</th><th>说明</th></tr></thead>
<tbody>{pageItems.map(item => {
const value = draft.values[item.actionId] ?? { allowed: false, capAllowed: false }
return <tr key={item.actionId}><td>{item.label}<div className="ad-dim ad-mono">{item.actionId}</div></td>
<td><input type="checkbox" aria-label={`${item.label} 默认允许`} checked={value.allowed} disabled={busy || item.protected}
<td><input type="checkbox" aria-label={`${item.label} 默认允许`} checked={value.allowed} disabled={busy || roleLoading || !canManagePermissions || item.protected}
onChange={event => editValue(item.actionId, 'allowed', event.target.checked)} /></td>
<td><input type="checkbox" aria-label={`${item.label} 角色上限`} checked={value.capAllowed} disabled={busy || item.protected}
<td><input type="checkbox" aria-label={`${item.label} 角色上限`} checked={value.capAllowed} disabled={busy || roleLoading || !canManagePermissions || item.protected}
onChange={event => editValue(item.actionId, 'capAllowed', event.target.checked)} /></td>
<td className="ad-dim">{item.protected ? '受保护系统授权,不能由普通角色授予' : item.description}</td></tr>
})}</tbody></table>
@@ -231,7 +331,8 @@ export function RolePermissionsPanel() {
{changes.length === 0 ? <p className="ad-hint">没有权限变化</p> : <ul className="rp-diff">{changes.map(change =>
<li key={change.actionId}><span className="ad-mono">{change.actionId}</span>:默认 {change.allowed ? '允许' : '拒绝'},上限 {change.capAllowed ? '允许' : '拒绝'}</li>)}</ul>}
</Collapsible>
<button className="lg-submit ad-create-btn" disabled={busy || changes.length === 0 || editablePageItems.length === 0} onClick={savePermissions}>保存权限</button>
{canManagePermissions && <button className="lg-submit ad-create-btn" disabled={busy || roleLoading || changes.length === 0 || editablePageItems.length === 0} onClick={savePermissions}>保存权限</button>}
</> : <p className="ad-hint">当前账号没有查看角色权限目录的系统权限</p>}
</> : <p className="ad-hint">请选择角色</p>}
</section>
</div>}
+14
View File
@@ -32,6 +32,20 @@ export function mergeRoleDraft(
return createRolePermissionDraft(serverValues, responseGeneration)
}
// Rebase local edits on a newer server baseline after an optimistic conflict.
// Only fields changed from the old baseline are replayed.
export function rebaseRoleDraft(
current: RolePermissionDraft,
serverValues: Record<string, RolePermissionValue>,
): RolePermissionDraft {
const values = copy(serverValues)
for (const [actionId, local] of Object.entries(current.values)) {
const old = current.baseline[actionId]
if (!old || old.allowed !== local.allowed || old.capAllowed !== local.capAllowed) values[actionId] = { ...local }
}
return { baseline: copy(serverValues), values, generation: current.generation + 1 }
}
export function visiblePatch(draft: RolePermissionDraft, visibleActionIds: string[]): RolePermissionChange[] {
const seen = new Set<string>()
const changes: RolePermissionChange[] = []
+10 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
createRolePermissionDraft, mergeRoleDraft, updateRolePermission, validateRolePermissionDraft, visiblePatch,
createRolePermissionDraft, mergeRoleDraft, rebaseRoleDraft, updateRolePermission, validateRolePermissionDraft, visiblePatch,
type RolePermissionValue,
} from '../src/rolePermissions'
@@ -38,4 +38,13 @@ describe('role permission draft', () => {
const invalid = updateRolePermission(createRolePermissionDraft(initial), 'document.read', { allowed: true, capAllowed: false })
expect(validateRolePermissionDraft(invalid)).toEqual({ actionId: 'document.read', reason: 'default-exceeds-cap' })
})
it('rebases local changes on a newer role version without losing the draft', () => {
const edited = updateRolePermission(createRolePermissionDraft(initial), 'project.create', { allowed: true, capAllowed: true })
const server = { ...initial, 'document.read': { allowed: false, capAllowed: true } }
const rebased = rebaseRoleDraft(edited, server)
expect(rebased.baseline['document.read']).toEqual({ allowed: false, capAllowed: true })
expect(rebased.values['project.create']).toEqual({ allowed: true, capAllowed: true })
expect(rebased.values['document.read']).toEqual({ allowed: false, capAllowed: true })
})
})