From 9d0ae331c7cb752976d2760a48c71ff9c00470eb Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 15 Oct 2026 02:28:03 +0000 Subject: [PATCH] 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 --- src/api.ts | 1 + src/components/AccountUsersPanel.tsx | 80 +++++++--- src/components/RolePermissionsPanel.tsx | 185 ++++++++++++++++++------ src/rolePermissions.ts | 14 ++ test/role-permissions.test.ts | 11 +- 5 files changed, 227 insertions(+), 64 deletions(-) diff --git a/src/api.ts b/src/api.ts index 09407cb..863162d 100644 --- a/src/api.ts +++ b/src/api.ts @@ -59,6 +59,7 @@ export const api = { const query = params.toString() return auth.fetch(`${BASE}/api/admin/roles${query ? `?${query}` : ''}`).then(r => json(r)) }, + getRole: (roleId: string) => auth.fetch(`${BASE}/api/admin/roles/${encodeURIComponent(roleId)}`).then(r => json(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(r)), diff --git a/src/components/AccountUsersPanel.tsx b/src/components/AccountUsersPanel.tsx index 3f6a84d..b95436a 100644 --- a/src/components/AccountUsersPanel.tsx +++ b/src/components/AccountUsersPanel.tsx @@ -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(null) const [selected, setSelected] = useState(null) const [roleView, setRoleView] = useState(null) const [roles, setRoles] = useState([]) + const [roleQuery, setRoleQuery] = useState('') + const [roleCursor, setRoleCursor] = useState('') + const [roleTrail, setRoleTrail] = useState([]) + const [nextRoleCursor, setNextRoleCursor] = useState('') + const [roleLoading, setRoleLoading] = useState(false) const [catalog, setCatalog] = useState([]) const [grants, setGrants] = useState([]) 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[] = [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 ?
{selected.owner}{selected.userId}
-

{selected.enabled ? '有效账号' : '停用账号'} · 当前角色 {roleView?.role?.name ?? '未分配'}

+

{selected.enabled ? '有效账号' : '停用账号'} · 当前角色 {canViewRoles ? (roleView?.role?.name ?? '未分配') : '无角色查看权限'}

{allowed('system.users.edit') && }
- {allowed('system.roles.assign') && <> setSelectedRoleId(event.target.value)}> {roles.map(role => )} } {allowed('system.users.disable') && }
- {allowed('system.permissions.manage') && + {canAssignRoles &&
+ { setRoleQuery(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }} /> + + +
} + {canViewGrants &&
{catalog.filter(item => item.scope === 'system').map(item => )}
- + {canManageGrants && }
} + {!canViewGrants && allowed('system.permissions.manage') &&

需要同时具备系统权限查看权,才能安全编辑现有授权

}
: <>
{ setQuery(event.target.value); setCursor(''); setTrail([]) }} /> diff --git a/src/components/RolePermissionsPanel.tsx b/src/components/RolePermissionsPanel.tsx index b704c1b..b5825bc 100644 --- a/src/components/RolePermissionsPanel.tsx +++ b/src/components/RolePermissionsPanel.tsx @@ -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(null) const [catalog, setCatalog] = useState([]) 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([]) + 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 = () =>
{pageItems.map(item => )}{filtered.map(role => - + {pageItems.map(item => { const value = role.roleId === selectedId && draft ? draft.values[item.actionId] : roleValue(role, item.actionId) return })} )} @@ -174,56 +262,68 @@ export function RolePermissionsPanel() { return
-
+ {canManageRoles &&
setNewName(event.target.value)} /> setNewDescription(event.target.value)} /> - +
-
+
}
- setQuery(event.target.value)} /> - { setQuery(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }} /> + - { setStatus(event.target.value as typeof status); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }}> + {canViewPermissions && }
- + {canViewPermissions && } + + + {view === 'matrix' && canManagePermissions && <> + + + + + }
{notice &&
{notice}
} {err &&
{err}
} {view === 'matrix' ? matrix() :
{selected && draft ? <>
- setName(event.target.value)} /> - setDescription(event.target.value)} /> - - + setName(event.target.value)} /> + setDescription(event.target.value)} /> + {canManageRoles && } + {canManageRoles && }
-
角色{item.label}
· {role.bindingCount} 人 · {role.bindingCount} 人 - {item.protected ? 系统授权 : } + {item.protected ? 系统授权 : <> + + + }
+ {canViewPermissions ? <>
权限默认允许角色上限说明
{pageItems.map(item => { const value = draft.values[item.actionId] ?? { allowed: false, capAllowed: false } return - - })}
权限默认允许角色上限说明
{item.label}
{item.actionId}
editValue(item.actionId, 'allowed', event.target.checked)} /> editValue(item.actionId, 'capAllowed', event.target.checked)} /> {item.protected ? '受保护系统授权,不能由普通角色授予' : item.description}
@@ -231,7 +331,8 @@ export function RolePermissionsPanel() { {changes.length === 0 ?

没有权限变化

:
    {changes.map(change =>
  • {change.actionId}:默认 {change.allowed ? '允许' : '拒绝'},上限 {change.capAllowed ? '允许' : '拒绝'}
  • )}
} - + {canManagePermissions && } + :

当前账号没有查看角色权限目录的系统权限

} :

请选择角色

}
} diff --git a/src/rolePermissions.ts b/src/rolePermissions.ts index 53521c2..999106e 100644 --- a/src/rolePermissions.ts +++ b/src/rolePermissions.ts @@ -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, +): 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() const changes: RolePermissionChange[] = [] diff --git a/test/role-permissions.test.ts b/test/role-permissions.test.ts index 64f2701..fabfb76 100644 --- a/test/role-permissions.test.ts +++ b/test/role-permissions.test.ts @@ -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 }) + }) })