agent-exec transport 719afff63d5bb7be93041751b9e839ce/da1c115ab399
This commit is contained in:
+23
-4
@@ -14,8 +14,24 @@ const BASE = ''
|
||||
* Registry.AddAdminZone 声明;入口不做前端权限合并,区内数据能力由组件
|
||||
* 自身命令面 fail-closed。 */
|
||||
export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number }
|
||||
export interface PermissionDescriptor { actionId: string; label: string; description: string; group: string; scope: 'system' | 'project'; protected: boolean }
|
||||
/** category:角色矩阵业务分类(项目与协作/文档工作/项目内容);受保护系统授权为空。 */
|
||||
export interface PermissionDescriptor { actionId: string; label: string; description: string; group: string; category?: string; scope: 'system' | 'project'; protected: boolean }
|
||||
export interface RolePermissionRecord { actionId: string; allowed: boolean; capAllowed: boolean; permissionVersion: number }
|
||||
export interface RolePermissionChangeInput { actionId: string; allowed: boolean; capAllowed: boolean }
|
||||
/** changes 为显式 patch:只含相对基线变化的动作,服务端合并保留未提及行。 */
|
||||
export interface RolePermissionPatchEntry { roleId: string; expectedRoleVersion: number; changes: RolePermissionChangeInput[] }
|
||||
/** before/after 为中文状态名:不允许/按项目授权/默认允许。 */
|
||||
export interface RolePermissionStateChange { actionId: string; label: string; before: string; after: string }
|
||||
export interface RolePermissionPreview {
|
||||
roleId: string; roleName: string; expectedRoleVersion: number; currentRoleVersion: number; versionMatches: boolean
|
||||
changes: RolePermissionStateChange[]; linkedExpansion: string[]; linkedExpansionState?: string; keptLinkedActions: string[]
|
||||
migrationNote?: string; conflicts: { actionId: string; reason: string }[]; error?: string; errorActionId?: string
|
||||
}
|
||||
export interface RolePermissionBatchReceipt {
|
||||
roleId: string; previousRoleVersion: number; roleVersion: number; linkedExpansion?: string[]; keptLinkedActions: string[]
|
||||
migrationNote?: string; changes: RolePermissionStateChange[]
|
||||
}
|
||||
export interface RolePermissionBatchResult { receiptId: string; authorityEpoch: number; receipts: RolePermissionBatchReceipt[] }
|
||||
export interface RoleRecord {
|
||||
roleId: string; name: string; description: string; enabled: boolean; roleVersion: number
|
||||
bindingCount: number; permissions: RolePermissionRecord[]
|
||||
@@ -97,9 +113,12 @@ export const api = {
|
||||
patchRole: (roleId: string, expectedRoleVersion: number, patch: { name?: string; description?: string; enabled?: boolean }) =>
|
||||
auth.fetch(`${BASE}/api/admin/roles/${encodeURIComponent(roleId)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ expectedRoleVersion, ...patch }) }).then(r => json<RoleRecord>(r)),
|
||||
replaceRolePermissions: (roleId: string, expectedRoleVersion: number, changes: { actionId: string; allowed: boolean; capAllowed: boolean }[], idempotencyKey: string) =>
|
||||
auth.fetch(`${BASE}/api/admin/roles/${encodeURIComponent(roleId)}/permissions`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ expectedRoleVersion, changes, idempotencyKey }) }).then(r => json<{ receiptId: string; roleVersion: number; authorityEpoch: number }>(r)),
|
||||
previewRolePermissions: (roles: RolePermissionPatchEntry[]) =>
|
||||
auth.fetch(`${BASE}/api/admin/roles/permissions/preview`, { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ roles }) }).then(r => json<{ previews: RolePermissionPreview[] }>(r)).then(d => d.previews ?? []),
|
||||
batchPatchRolePermissions: (roles: RolePermissionPatchEntry[], idempotencyKey: string) =>
|
||||
auth.fetch(`${BASE}/api/admin/roles/permissions/batch`, { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ idempotencyKey, roles }) }).then(r => json<RolePermissionBatchResult>(r)),
|
||||
getUserRole: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/role`).then(r => json<UserRoleView>(r)),
|
||||
assignUserRole: (userId: string, roleId: string, expectedStateVersion: number, idempotencyKey: string) =>
|
||||
auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/role`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { api, type PermissionDescriptor, type RoleRecord } from '../api'
|
||||
import {
|
||||
createRolePermissionDraft, rebaseRoleDraft, updateRolePermission, validateRolePermissionDraft, visiblePatch,
|
||||
type RolePermissionDraft, type RolePermissionValue,
|
||||
api, type PermissionDescriptor, type RolePermissionBatchResult, type RolePermissionPatchEntry, type RolePermissionPreview, type RoleRecord,
|
||||
} from '../api'
|
||||
import {
|
||||
PROJECT_CREATE_ACTION, PROJECT_CREATE_LINKED_ACTIONS, ROLE_PERMISSION_STATES, categoryActions, createRolePermissionDraft, dirtyRoles,
|
||||
linkedByProjectCreate, permissionCategories, rebaseRoleDraft, rolePatchEntries, stateLabel, stateToValue, updateRolePermission,
|
||||
validateRolePermissionDraft, valueToState, visiblePatch,
|
||||
type RolePermissionDraft, type RolePermissionStateName, type RolePermissionValue,
|
||||
} from '../rolePermissions'
|
||||
import { Collapsible, Icon } from '@ui'
|
||||
import { useStore } from '../store'
|
||||
@@ -10,6 +14,14 @@ import { useStore } from '../store'
|
||||
/** DR9 离开管理区守卫:面板有未保存权限草稿时注册;AdminZone「返回工作台」消费。 */
|
||||
export const roleDraftLeaveGuard: { current: (() => boolean) | null } = { current: null }
|
||||
|
||||
export const NARROW_ROLE_QUERY = '(max-width: 900px)'
|
||||
const LINKED_NOTE = '创建项目时自动具备 · 仅本人创建的项目'
|
||||
|
||||
/** 草稿携带建立基线时的角色快照:其 roleVersion 即批量 patch 的 expectedRoleVersion。 */
|
||||
type RoleDraft = RolePermissionDraft & { role: RoleRecord }
|
||||
type RoleDraftMap = Record<string, RoleDraft>
|
||||
type RolePermissionClient = Pick<typeof api, 'previewRolePermissions' | 'batchPatchRolePermissions' | 'getRole'>
|
||||
|
||||
export function filterRoles(
|
||||
roles: RoleRecord[], query: string, mode: 'exact' | 'fuzzy', status: 'all' | 'enabled' | 'disabled',
|
||||
): RoleRecord[] {
|
||||
@@ -23,11 +35,6 @@ export function filterRoles(
|
||||
})
|
||||
}
|
||||
|
||||
export function groupPermissionPage(catalog: PermissionDescriptor[], group: string, page: number, size: number): PermissionDescriptor[] {
|
||||
const grouped = catalog.filter(item => !group || item.group === group)
|
||||
return grouped.slice(page * size, page * size + size)
|
||||
}
|
||||
|
||||
export function shouldAcceptRoleResponse(activeSequence: number, responseSequence: number): boolean {
|
||||
return activeSequence === responseSequence
|
||||
}
|
||||
@@ -36,6 +43,16 @@ export function hasSystemAction(actions: string[] | undefined, action: string):
|
||||
return actions?.includes(action) ?? false
|
||||
}
|
||||
|
||||
/** 窄屏(≤900px)默认单角色聚焦;仍可手动打开矩阵。 */
|
||||
export function initialRoleView(matches?: (query: string) => boolean): 'single' | 'matrix' {
|
||||
return matches?.(NARROW_ROLE_QUERY) ? 'single' : 'matrix'
|
||||
}
|
||||
|
||||
/** 系统动作(project.create)是二态开关;项目动作是三态。 */
|
||||
export function cellKind(item: PermissionDescriptor): 'toggle' | 'state' {
|
||||
return item.scope === 'system' ? 'toggle' : 'state'
|
||||
}
|
||||
|
||||
function roleValues(role: RoleRecord, catalog: PermissionDescriptor[]): Record<string, RolePermissionValue> {
|
||||
const existing = new Map(role.permissions.map(item => [item.actionId, item]))
|
||||
return Object.fromEntries(catalog.filter(item => !item.protected).map(item => {
|
||||
@@ -49,6 +66,73 @@ function roleValue(role: RoleRecord, actionId: string): RolePermissionValue {
|
||||
return { allowed: value?.allowed ?? false, capAllowed: value?.capAllowed ?? false }
|
||||
}
|
||||
|
||||
export function roleCellValue(drafts: RoleDraftMap, role: RoleRecord, actionId: string): RolePermissionValue {
|
||||
return drafts[role.roleId]?.values[actionId] ?? roleValue(role, actionId)
|
||||
}
|
||||
|
||||
// DR9:任何单元格修改都写进该角色自己的草稿(无需先选中);首次修改时以当时的服务端值为基线。
|
||||
export function editRoleDraft(
|
||||
drafts: RoleDraftMap, role: RoleRecord, catalog: PermissionDescriptor[], actionId: string, value: RolePermissionValue,
|
||||
): RoleDraftMap {
|
||||
const current = drafts[role.roleId] ?? { ...createRolePermissionDraft(roleValues(role, catalog)), role }
|
||||
return { ...drafts, [role.roleId]: { ...updateRolePermission(current, actionId, value), role: current.role } }
|
||||
}
|
||||
|
||||
export function roleLeaveMessage(names: string[]): string {
|
||||
return `以下角色有未保存的权限修改:${names.map(name => `「${name}」`).join('、')}。离开将放弃这些修改,确定离开吗?`
|
||||
}
|
||||
|
||||
function previewBlocksSave(previews: RolePermissionPreview[]): boolean {
|
||||
return previews.some(preview => !!preview.error || (preview.conflicts?.length ?? 0) > 0)
|
||||
}
|
||||
|
||||
export async function previewRoleBatch(client: Pick<RolePermissionClient, 'previewRolePermissions'>, entries: RolePermissionPatchEntry[]) {
|
||||
const previews = await client.previewRolePermissions(entries)
|
||||
return { previews, blocked: previewBlocksSave(previews) }
|
||||
}
|
||||
|
||||
// 原子保存:所有脏角色一次提交;预览存在冲突或错误时拒绝提交。
|
||||
export async function commitRoleBatch(
|
||||
client: Pick<RolePermissionClient, 'batchPatchRolePermissions'>, entries: RolePermissionPatchEntry[],
|
||||
previews: RolePermissionPreview[], idempotencyKey: string,
|
||||
): Promise<RolePermissionBatchResult> {
|
||||
if (!entries.length) throw new Error('没有需要保存的权限修改')
|
||||
if (previewBlocksSave(previews)) throw new Error('预览存在冲突或错误,不能保存')
|
||||
return client.batchPatchRolePermissions(entries, idempotencyKey)
|
||||
}
|
||||
|
||||
// DR9:409 后逐个读取最新角色,只重放各角色相对自身旧基线的本地变化。
|
||||
export async function rebaseRoleDrafts(
|
||||
client: Pick<RolePermissionClient, 'getRole'>, drafts: RoleDraftMap, roleIds: string[], catalog: PermissionDescriptor[],
|
||||
): Promise<{ fresh: RoleRecord[]; drafts: RoleDraftMap }> {
|
||||
const fresh = await Promise.all(roleIds.map(roleId => client.getRole(roleId)))
|
||||
const next = { ...drafts }
|
||||
for (const role of fresh) {
|
||||
const current = drafts[role.roleId]
|
||||
if (current) next[role.roleId] = { ...rebaseRoleDraft(current, roleValues(role, catalog)), role }
|
||||
}
|
||||
return { fresh, drafts: next }
|
||||
}
|
||||
|
||||
function pruneCleanDrafts(drafts: RoleDraftMap, actionIds: string[]): RoleDraftMap {
|
||||
const dirty = new Set(dirtyRoles(drafts, actionIds).map(entry => entry.roleId))
|
||||
if (Object.keys(drafts).every(roleId => dirty.has(roleId))) return drafts
|
||||
return Object.fromEntries(Object.entries(drafts).filter(([roleId]) => dirty.has(roleId)))
|
||||
}
|
||||
|
||||
// 本端资料/状态写入成功且草稿基线即写入前版本时,权限未变,草稿可继承新版本号。
|
||||
function adoptRoleSnapshot(drafts: RoleDraftMap, expectedVersion: number, updated: RoleRecord): RoleDraftMap {
|
||||
const current = drafts[updated.roleId]
|
||||
if (!current || current.role.roleVersion !== expectedVersion) return drafts
|
||||
return { ...drafts, [updated.roleId]: { ...current, role: updated } }
|
||||
}
|
||||
|
||||
function mergeRoles(current: RoleRecord[] | null, fresh: RoleRecord[]): RoleRecord[] {
|
||||
const byId = new Map(fresh.map(role => [role.roleId, role]))
|
||||
const merged = (current ?? []).map(role => byId.get(role.roleId) ?? role)
|
||||
return [...fresh.filter(role => !merged.some(item => item.roleId === role.roleId)), ...merged]
|
||||
}
|
||||
|
||||
export function RolePermissionsPanel() {
|
||||
const me = useStore(state => state.me)
|
||||
const allowed = (action: string) => !!me?.isAdmin || hasSystemAction(me?.systemActions, action)
|
||||
@@ -57,41 +141,52 @@ export function RolePermissionsPanel() {
|
||||
const canManagePermissions = canViewPermissions && allowed('system.permissions.manage')
|
||||
const [roles, setRoles] = useState<RoleRecord[] | null>(null)
|
||||
const [catalog, setCatalog] = useState<PermissionDescriptor[]>([])
|
||||
const [catalogFailed, setCatalogFailed] = useState(false)
|
||||
const [selectedId, setSelectedId] = useState('')
|
||||
const [drafts, setDrafts] = useState<Record<string, RolePermissionDraft>>({})
|
||||
const [drafts, setDrafts] = useState<RoleDraftMap>({})
|
||||
const [confirmDisable, setConfirmDisable] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [query, setQuery] = useState('')
|
||||
const [mode, setMode] = useState<'exact' | 'fuzzy'>('fuzzy')
|
||||
const [status, setStatus] = useState<'all' | 'enabled' | 'disabled'>('all')
|
||||
const [group, setGroup] = useState('')
|
||||
const [category, setCategory] = 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 [view, setView] = useState<'single' | 'matrix'>(() => initialRoleView(
|
||||
typeof window !== 'undefined' && typeof window.matchMedia === 'function' ? media => window.matchMedia(media).matches : undefined))
|
||||
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 [preview, setPreview] = useState<{ previews: RolePermissionPreview[]; confirm: boolean } | null>(null)
|
||||
const requestSequence = useRef(0)
|
||||
const selectedIdRef = useRef('')
|
||||
const savePermsRef = useRef<HTMLButtonElement>(null)
|
||||
const statusRef = useRef<HTMLDivElement>(null)
|
||||
selectedIdRef.current = selectedId
|
||||
|
||||
const selected = roles?.find(role => role.roleId === selectedId) ?? null
|
||||
const draft = selected ? drafts[selected.roleId] ?? null : null
|
||||
const allEditableIds = useMemo(() => catalog.filter(item => !item.protected).map(item => item.actionId), [catalog])
|
||||
const dirty = !!draft && visiblePatch(draft, allEditableIds).length > 0
|
||||
const groups = useMemo(() => [...new Set(catalog.map(item => item.group))], [catalog])
|
||||
const filtered = roles ?? []
|
||||
const pageItems = useMemo(() => groupPermissionPage(catalog, group, page, 6), [catalog, group, page])
|
||||
const editablePageItems = pageItems.filter(item => !item.protected)
|
||||
const descriptorById = useMemo(() => new Map(catalog.map(item => [item.actionId, item])), [catalog])
|
||||
const categories = useMemo(() => permissionCategories(catalog), [catalog])
|
||||
const activeCategory = categories.includes(category) ? category : categories[0] ?? ''
|
||||
const columns = useMemo(() => categoryActions(catalog, activeCategory), [catalog, activeCategory])
|
||||
const dirtyList = useMemo(() => dirtyRoles(drafts, allEditableIds), [drafts, allEditableIds])
|
||||
const dirtyNames = dirtyList.map(entry => roles?.find(role => role.roleId === entry.roleId)?.name ?? drafts[entry.roleId].role.name)
|
||||
const changeCount = dirtyList.reduce((sum, entry) => sum + entry.changes, 0)
|
||||
const changes = draft ? visiblePatch(draft, allEditableIds) : []
|
||||
const filtered = roles ?? []
|
||||
const effectiveView = canViewPermissions ? view : 'single'
|
||||
const editable = canManagePermissions && !catalogFailed && catalog.length > 0
|
||||
const blocked = !!preview && previewBlocksSave(preview.previews)
|
||||
const labelOf = (actionId: string) => descriptorById.get(actionId)?.label ?? actionId
|
||||
const cellDisabled = (role: RoleRecord) => busy || roleLoading || !editable || !role.enabled
|
||||
|
||||
const load = async (pageCursor = roleCursor, refreshCatalog = false, preferredId = selectedId,
|
||||
filters = { query, mode, status, action: actionFilter }) => {
|
||||
@@ -107,6 +202,7 @@ export function RolePermissionsPanel() {
|
||||
setRoleLoading(false)
|
||||
const descriptors = catalogResult.status === 'fulfilled' ? catalogResult.value : catalog
|
||||
if (catalogResult.status === 'fulfilled') setCatalog(descriptors)
|
||||
setCatalogFailed(catalogResult.status === 'rejected')
|
||||
if (roleResult.status === 'rejected') {
|
||||
setErr(String(roleResult.reason))
|
||||
return
|
||||
@@ -114,34 +210,33 @@ export function RolePermissionsPanel() {
|
||||
const rolePage = roleResult.value
|
||||
setRoles(rolePage.roles ?? [])
|
||||
setNextRoleCursor(rolePage.nextCursor ?? '')
|
||||
setDrafts(prev => pruneCleanDrafts(prev, descriptors.filter(item => !item.protected).map(item => item.actionId)))
|
||||
const nextSelected = rolePage.roles.find(role => role.roleId === preferredId) ?? rolePage.roles[0] ?? null
|
||||
if (nextSelected) selectRole(nextSelected, descriptors, sequence)
|
||||
if (nextSelected) selectRole(nextSelected, sequence)
|
||||
else setSelectedId('')
|
||||
// DR10:目录失败是部分失败态,需在角色选中(selectRole 会清空提示)之后仍可见。
|
||||
if (catalogResult.status === 'rejected') setErr(prev => prev || `权限目录加载失败:${catalogResult.reason}`)
|
||||
}
|
||||
|
||||
const selectRole = (role: RoleRecord, descriptors = catalog, sequence?: number) => {
|
||||
const selectRole = (role: RoleRecord, sequence?: number) => {
|
||||
const nextSequence = sequence ?? ++requestSequence.current
|
||||
if (!shouldAcceptRoleResponse(requestSequence.current, nextSequence)) return
|
||||
setSelectedId(role.roleId)
|
||||
setName(role.name)
|
||||
setDescription(role.description)
|
||||
// DR9:草稿按 roleId 隔离保存;翻页、过滤、视图切换回到该角色时保留,
|
||||
// 切换角色永不转移他人草稿。仅首次选中时以服务端值为基线。
|
||||
setDrafts(prev => prev[role.roleId] ? prev
|
||||
: { ...prev, [role.roleId]: createRolePermissionDraft(roleValues(role, descriptors), nextSequence) })
|
||||
setNotice('')
|
||||
setErr('')
|
||||
}
|
||||
|
||||
// DR9:离开管理区须显式决定;带未保存草稿时刷新走浏览器原生确认。
|
||||
// DR9:离开管理区须显式决定(覆盖所有脏角色);带未保存草稿时刷新走浏览器原生确认。
|
||||
const dirtyKey = dirtyNames.join('\u0000')
|
||||
useEffect(() => {
|
||||
if (!dirty || !selected) { roleDraftLeaveGuard.current = null; return }
|
||||
const message = `角色「${selected.name}」有未保存的权限修改,离开将放弃这些修改。确定离开吗?`
|
||||
if (!dirtyNames.length) { roleDraftLeaveGuard.current = null; return }
|
||||
const message = roleLeaveMessage(dirtyNames)
|
||||
roleDraftLeaveGuard.current = () => window.confirm(message)
|
||||
return () => { roleDraftLeaveGuard.current = null }
|
||||
}, [dirty, selected?.roleId, selected?.name])
|
||||
}, [dirtyKey])
|
||||
const dirty = dirtyList.length > 0
|
||||
useEffect(() => {
|
||||
if (!dirty) return
|
||||
const handler = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = '' }
|
||||
@@ -155,6 +250,13 @@ export function RolePermissionsPanel() {
|
||||
return () => { window.clearTimeout(timer); requestSequence.current++ }
|
||||
}, [query, mode, status, actionFilter, roleCursor, canViewPermissions])
|
||||
|
||||
// DR10:保存/冲突/失败后焦点稳定——若焦点因按钮短暂 disabled 或保存区收起掉回 body,则归还。
|
||||
const restoreFocus = () => requestAnimationFrame(() => {
|
||||
if (document.activeElement !== document.body) return
|
||||
const target = savePermsRef.current && !savePermsRef.current.disabled ? savePermsRef.current : statusRef.current
|
||||
target?.focus()
|
||||
})
|
||||
|
||||
const create = async () => {
|
||||
if (!newName.trim()) return
|
||||
requestSequence.current++; setRoleLoading(false)
|
||||
@@ -164,7 +266,7 @@ export function RolePermissionsPanel() {
|
||||
setNewName(''); setNewDescription('')
|
||||
setQuery(''); setStatus('all'); setActionFilter(''); setRoleCursor(''); setRoleTrail([])
|
||||
setRoles(current => [role, ...(current ?? []).filter(item => item.roleId !== role.roleId)])
|
||||
selectRole(role, catalog, ++requestSequence.current)
|
||||
selectRole(role, ++requestSequence.current)
|
||||
setNotice(`已创建角色 ${role.name}`)
|
||||
await load('', true, role.roleId, { query: '', mode, status: 'all', action: '' })
|
||||
setNotice(`已创建角色 ${role.name}`)
|
||||
@@ -178,15 +280,15 @@ export function RolePermissionsPanel() {
|
||||
setRoleLoading(false)
|
||||
setBusy(true); setErr(''); setNotice('')
|
||||
try {
|
||||
await api.patchRole(selected.roleId, selected.roleVersion, { name: name.trim(), description: description.trim() })
|
||||
const updated = await api.patchRole(selected.roleId, selected.roleVersion, { name: name.trim(), description: description.trim() })
|
||||
setDrafts(prev => adoptRoleSnapshot(prev, selected.roleVersion, updated))
|
||||
await load(); setNotice('角色资料已保存')
|
||||
} 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 ?? [])])
|
||||
setRoles(current => mergeRoles(current, [fresh]))
|
||||
setErr('角色资料版本冲突;已读取最新版本并保留本地名称与描述,请核对后重试')
|
||||
} catch (refreshError) { setErr(String(refreshError)) }
|
||||
} else setErr(String(error))
|
||||
@@ -201,105 +303,187 @@ export function RolePermissionsPanel() {
|
||||
setRoleLoading(false)
|
||||
setBusy(true); setErr(''); setNotice('')
|
||||
try {
|
||||
await api.patchRole(selected.roleId, selected.roleVersion, { enabled: !selected.enabled })
|
||||
const updated = await api.patchRole(selected.roleId, selected.roleVersion, { enabled: !selected.enabled })
|
||||
setDrafts(prev => adoptRoleSnapshot(prev, selected.roleVersion, updated))
|
||||
await load(); setNotice(selected.enabled ? '角色已停用,现有绑定不再授予权限' : '角色已启用')
|
||||
} 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 ?? [])])
|
||||
setRoles(current => mergeRoles(current, [fresh]))
|
||||
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 ?? [])])
|
||||
// DR9:409 只重放同一角色的草稿;基于最新服务端值回放本地相对基线的变化。
|
||||
setDrafts(prev => ({ ...prev, [roleId]: rebaseRoleDraft(currentDraft, roleValues(fresh, catalog)) }))
|
||||
setErr('权限版本冲突;已基于最新版本重放本地草稿,请核对后再次保存')
|
||||
const editCell = (role: RoleRecord, actionId: string, value: RolePermissionValue) => {
|
||||
setDrafts(prev => editRoleDraft(prev, role, catalog, actionId, value))
|
||||
setPreview(null)
|
||||
}
|
||||
|
||||
const savePermissions = async () => {
|
||||
if (!selected || !draft) return
|
||||
const roleId = selected.roleId
|
||||
const patchEntries = () => rolePatchEntries(drafts,
|
||||
Object.fromEntries(Object.entries(drafts).map(([roleId, item]) => [roleId, item.role.roleVersion])), allEditableIds)
|
||||
|
||||
// 「查看变化」与「保存」都先取服务端权威预览;confirm=true 时保存按钮变为「确认保存」。
|
||||
const requestPreview = async (confirm: boolean) => {
|
||||
for (const entry of dirtyList) {
|
||||
const invalid = validateRolePermissionDraft(drafts[entry.roleId])
|
||||
if (invalid) { setErr(`角色「${drafts[entry.roleId].role.name}」的 ${labelOf(invalid.actionId)} 默认权限不能超过角色上限`); return }
|
||||
}
|
||||
const entries = patchEntries()
|
||||
if (!entries.length) return
|
||||
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())
|
||||
setDrafts(current => {
|
||||
if (!(roleId in current)) return current
|
||||
const next = { ...current }; delete next[roleId]; return next
|
||||
})
|
||||
await load(roleCursor, true, selected.roleId); setNotice('角色权限已保存')
|
||||
const result = await previewRoleBatch(api, entries)
|
||||
if (requestSequence.current !== operationSequence) return
|
||||
setPreview({ previews: result.previews, confirm })
|
||||
} catch (error) { setErr(String(error)) } finally { setBusy(false); restoreFocus() }
|
||||
}
|
||||
|
||||
const confirmSave = async () => {
|
||||
if (!preview) return
|
||||
const entries = patchEntries()
|
||||
const snapshot = drafts
|
||||
const operationSequence = ++requestSequence.current
|
||||
setRoleLoading(false)
|
||||
setBusy(true); setErr(''); setNotice('')
|
||||
try {
|
||||
const result = await commitRoleBatch(api, entries, preview.previews, crypto.randomUUID())
|
||||
const saved = new Set(entries.map(entry => entry.roleId))
|
||||
setDrafts(current => Object.fromEntries(Object.entries(current).filter(([roleId]) => !saved.has(roleId))))
|
||||
setPreview(null)
|
||||
await load(roleCursor, true, selectedId)
|
||||
setNotice(`已保存 ${result.receipts.length} 个角色的权限设置`)
|
||||
} catch (error) {
|
||||
if (String(error).includes('409')) {
|
||||
try { await rebasePermissionConflict(roleId, draft, operationSequence) } catch (refreshError) { setErr(String(refreshError)) }
|
||||
try {
|
||||
const rebased = await rebaseRoleDrafts(api, snapshot, entries.map(entry => entry.roleId), catalog)
|
||||
if (requestSequence.current !== operationSequence) return
|
||||
setRoles(current => mergeRoles(current, rebased.fresh))
|
||||
setDrafts(current => ({ ...current, ...Object.fromEntries(entries.map(entry => [entry.roleId, rebased.drafts[entry.roleId]])) }))
|
||||
setPreview(null)
|
||||
setErr(`权限版本冲突;已基于最新版本为 ${entries.length} 个角色重放本地草稿,请核对后再次保存`)
|
||||
} catch (refreshError) { setErr(String(refreshError)) }
|
||||
} else setErr(String(error))
|
||||
}
|
||||
finally {
|
||||
setBusy(false)
|
||||
// DR10:保存/冲突/失败后焦点稳定——若焦点因按钮短暂 disabled 掉回 body,则归还。
|
||||
requestAnimationFrame(() => {
|
||||
if (savePermsRef.current && !savePermsRef.current.disabled && document.activeElement === document.body) savePermsRef.current.focus()
|
||||
})
|
||||
}
|
||||
} finally { setBusy(false); restoreFocus() }
|
||||
}
|
||||
|
||||
const editValue = (actionId: string, field: 'allowed' | 'capAllowed', checked: boolean) => {
|
||||
const current = drafts[selectedId]
|
||||
if (!current) return
|
||||
const value0 = current.values[actionId] ?? { allowed: false, capAllowed: false }
|
||||
const value = field === 'allowed'
|
||||
? { allowed: checked, capAllowed: checked ? true : value0.capAllowed }
|
||||
: { allowed: checked ? value0.allowed : false, capAllowed: checked }
|
||||
setDrafts(prev => prev[selectedId] ? { ...prev, [selectedId]: updateRolePermission(prev[selectedId], actionId, value) } : prev)
|
||||
const cancelAll = () => {
|
||||
if (!window.confirm(`确定放弃 ${dirtyNames.map(name => `「${name}」`).join('、')} 的全部未保存权限修改吗?`)) return
|
||||
setDrafts({}); setPreview(null); setErr(''); setNotice('已取消全部未保存的权限修改')
|
||||
}
|
||||
|
||||
const editVisible = (field: 'allowed' | 'capAllowed', checked: boolean) => {
|
||||
const start = drafts[selectedId]
|
||||
if (!start) return
|
||||
let next = start
|
||||
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)
|
||||
}
|
||||
setDrafts(prev => prev[selectedId] ? { ...prev, [selectedId]: next } : prev)
|
||||
const openFocus = (role: RoleRecord) => { selectRole(role); setView('single') }
|
||||
|
||||
const valueLabel = (actionId: string, value: RolePermissionValue | undefined) => descriptorById.get(actionId)?.scope === 'system'
|
||||
? (value?.allowed ? '允许' : '不允许') : stateLabel(valueToState(value))
|
||||
|
||||
const cell = (role: RoleRecord, item: PermissionDescriptor) => {
|
||||
const value = roleCellValue(drafts, role, item.actionId)
|
||||
const label = `${role.name} ${item.label}`
|
||||
const disabled = cellDisabled(role)
|
||||
const roleDraft = drafts[role.roleId]
|
||||
const modified = !!roleDraft && visiblePatch(roleDraft, [item.actionId]).length > 0
|
||||
const linked = linkedByProjectCreate({ [PROJECT_CREATE_ACTION]: roleCellValue(drafts, role, PROJECT_CREATE_ACTION) }, item.actionId)
|
||||
return <div className="rp-cell">
|
||||
{cellKind(item) === 'toggle'
|
||||
? <select className="rp-state" aria-label={label} value={value.allowed ? 'on' : 'off'} disabled={disabled}
|
||||
onChange={event => editCell(role, item.actionId, stateToValue(event.target.value === 'on' ? 'default' : 'denied'))}>
|
||||
<option value="off">不允许</option><option value="on">允许</option>
|
||||
</select>
|
||||
: <select className="rp-state" aria-label={label} value={valueToState(value)} disabled={disabled}
|
||||
onChange={event => editCell(role, item.actionId, stateToValue(event.target.value as RolePermissionStateName))}>
|
||||
{ROLE_PERMISSION_STATES.map(option => <option key={option.state} value={option.state}>{option.label}</option>)}
|
||||
</select>}
|
||||
{modified && <span className="rp-cell-flag">已修改</span>}
|
||||
{linked && <span className="rp-linked"><Icon name="sparkles" size={12} />{LINKED_NOTE}</span>}
|
||||
</div>
|
||||
}
|
||||
|
||||
const matrix = () => <div className="rp-matrix-wrap">
|
||||
const matrix = () => <div className="rp-matrix-wrap" role="region" aria-label={`角色矩阵 · ${activeCategory}`} tabIndex={0}>
|
||||
<table className="ad-table rp-matrix">
|
||||
<thead><tr><th>角色</th>{pageItems.map(item => <th key={item.actionId}>{item.label}</th>)}</tr></thead>
|
||||
<thead><tr><th scope="col">角色</th>{columns.map(item => <th scope="col" key={item.actionId} title={item.description}>{item.label}</th>)}</tr></thead>
|
||||
<tbody>{filtered.map(role => <tr key={role.roleId} className={role.roleId === selectedId ? 'rp-selected' : ''}>
|
||||
<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 || 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>
|
||||
})}
|
||||
<th scope="row"><div className="rp-role-cell">
|
||||
<button className="rp-role-name" disabled={busy || roleLoading} onClick={() => openFocus(role)}>{role.name}</button>
|
||||
{role.description && <span className="rp-role-desc" title={role.description}>{role.description}</span>}
|
||||
<span className="ad-dim">{role.bindingCount} 人 · {role.enabled ? '启用' : '已停用'}{dirtyList.some(entry => entry.roleId === role.roleId) ? ' · 未保存' : ''}</span>
|
||||
{!role.enabled && <span className="rp-disabled-note">停用角色不可编辑权限,请先启用</span>}
|
||||
</div></th>
|
||||
{columns.map(item => <td key={item.actionId}>{cell(role, item)}</td>)}
|
||||
</tr>)}</tbody>
|
||||
</table>
|
||||
{filtered.length === 0 && !roleLoading && <p className="ad-hint rp-empty">没有符合条件的角色</p>}
|
||||
</div>
|
||||
|
||||
const focus = () => <div className="rp-single">
|
||||
<aside className="rp-role-list">
|
||||
{filtered.map(role => <button key={role.roleId} disabled={busy || roleLoading} className={`az-nav-item ${role.roleId === selectedId ? 'on' : ''}`} onClick={() => selectRole(role)}>
|
||||
<span>{role.name}{dirtyList.some(entry => entry.roleId === role.roleId) ? ' · 未保存' : ''}</span><span className="ad-dim">{role.bindingCount} 人</span>
|
||||
</button>)}
|
||||
</aside>
|
||||
<section className="rp-editor">
|
||||
{selected ? <>
|
||||
<div className="ad-row">
|
||||
<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 && (selected.enabled
|
||||
? <button className="ad-mini" disabled={busy || roleLoading} onClick={() => setConfirmDisable(true)}>停用角色</button>
|
||||
: <button className="ad-mini" disabled={busy || roleLoading} onClick={setEnabled}>启用角色</button>)}
|
||||
</div>
|
||||
{confirmDisable && selected.enabled && <div className="rp-disable-confirm" role="alertdialog" aria-label={`确认停用角色 ${selected.name}`}>
|
||||
<span>停用角色「{selected.name}」?该角色现有 {selected.bindingCount} 个用户绑定,停用后这些绑定将不再授予权限。</span>
|
||||
<button className="ad-mini" disabled={busy || roleLoading} onClick={() => { setConfirmDisable(false); void setEnabled() }}>确认停用</button>
|
||||
<button className="ad-mini" disabled={busy} onClick={() => setConfirmDisable(false)}>取消</button>
|
||||
</div>}
|
||||
{canViewPermissions ? <>
|
||||
{!selected.enabled && <p className="rp-disabled-note">角色已停用,启用后才能编辑权限</p>}
|
||||
<table className="ad-table rp-focus-table"><thead><tr><th>权限 · {activeCategory}</th><th>状态</th><th>说明</th></tr></thead>
|
||||
<tbody>{columns.map(item => <tr key={item.actionId}>
|
||||
<td>{item.label}</td><td>{cell(selected, item)}</td><td className="ad-dim">{item.description}</td>
|
||||
</tr>)}</tbody></table>
|
||||
<Collapsible id={`role-tech-${selected.roleId}`} trigger="技术详情">
|
||||
<ul className="rp-diff">
|
||||
<li>角色 ID:<span className="ad-mono">{selected.roleId}</span> · 版本 {selected.roleVersion}</li>
|
||||
{columns.map(item => <li key={item.actionId}>{item.label}:<span className="ad-mono">{item.actionId}</span></li>)}
|
||||
</ul>
|
||||
</Collapsible>
|
||||
<Collapsible id={`role-diff-${selected.roleId}`} trigger={`变更预览(${changes.length})`}>
|
||||
{changes.length === 0 || !draft ? <p className="ad-hint">没有权限变化</p> : <ul className="rp-diff">{changes.map(change =>
|
||||
<li key={change.actionId}>{labelOf(change.actionId)}:{valueLabel(change.actionId, draft.baseline[change.actionId])} → {valueLabel(change.actionId, change)}</li>)}</ul>}
|
||||
</Collapsible>
|
||||
</> : <p className="ad-hint">当前账号没有查看角色权限目录的系统权限</p>}
|
||||
</> : <p className="ad-hint">{roleLoading ? '加载中…' : '请选择角色'}</p>}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
const previewPanel = (current: NonNullable<typeof preview>) => <section className="rp-preview" aria-label="保存前预览">
|
||||
<div className="rp-preview-head">
|
||||
<strong>{current.confirm ? '请确认以下变化后保存' : '待保存的变化'}</strong>
|
||||
<button className="ad-mini" disabled={busy} onClick={() => setPreview(null)}>收起</button>
|
||||
</div>
|
||||
{current.previews.map(item => <div key={item.roleId} className="rp-preview-role">
|
||||
<div className="rp-preview-title">{item.roleName}
|
||||
{!item.versionMatches && <span className="rp-warn-text"> · 角色已被他人修改,保存时将提示冲突并基于最新版本重放</span>}</div>
|
||||
{item.error && <p className="rp-conflict"><Icon name="alert" size={12} />错误{item.errorActionId ? ` · ${labelOf(item.errorActionId)}` : ''}:{item.error}</p>}
|
||||
{(item.changes ?? []).length > 0
|
||||
? <ul className="rp-diff">{item.changes.map(change => <li key={change.actionId}>{change.label}:{change.before} → {change.after}</li>)}</ul>
|
||||
: <p className="ad-hint">没有状态变化</p>}
|
||||
{(item.linkedExpansion ?? []).length > 0 && <p className="rp-linked"><Icon name="sparkles" size={12} />
|
||||
系统将自动补齐:{item.linkedExpansion.map(labelOf).join('、')}{item.linkedExpansionState ? `(${item.linkedExpansionState})` : ''}</p>}
|
||||
{(item.keptLinkedActions ?? []).length > 0 && <p className="ad-hint">关闭创建项目后保留:{item.keptLinkedActions.map(labelOf).join('、')}</p>}
|
||||
{item.migrationNote && <p className="ad-hint">{item.migrationNote}</p>}
|
||||
{(item.conflicts ?? []).map(conflict => <p key={conflict.actionId} className="rp-conflict">
|
||||
<Icon name="alert" size={12} />冲突 · {labelOf(conflict.actionId)}:{conflict.reason}</p>)}
|
||||
</div>)}
|
||||
{blocked && <p className="rp-conflict" role="alert"><Icon name="alert" size={12} />存在冲突或错误,调整后才能保存</p>}
|
||||
</section>
|
||||
|
||||
return <div className="rp-panel">
|
||||
{canManageRoles && <div className="ad-create">
|
||||
<div className="ad-row">
|
||||
@@ -317,9 +501,6 @@ export function RolePermissionsPanel() {
|
||||
<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>)}
|
||||
@@ -327,60 +508,32 @@ export function RolePermissionsPanel() {
|
||||
</div>
|
||||
</Collapsible>
|
||||
<div className="ad-row rp-view-toggle">
|
||||
<button className={`ad-mini ${view === 'single' ? 'on' : ''}`} onClick={() => setView('single')}>单角色配置</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>
|
||||
{canViewPermissions && categories.length > 0 && <div className="rp-category-nav" role="group" aria-label="权限分类">
|
||||
{categories.map(value => <button key={value} className={`ad-mini ${value === activeCategory ? 'on' : ''}`} aria-pressed={value === activeCategory}
|
||||
onClick={() => setCategory(value)}>{value}</button>)}
|
||||
</div>}
|
||||
{canViewPermissions && <button className={`ad-mini ${effectiveView === 'matrix' ? 'on' : ''}`} aria-pressed={effectiveView === 'matrix'} onClick={() => setView('matrix')}>角色矩阵</button>}
|
||||
<button className={`ad-mini ${effectiveView === 'single' ? 'on' : ''}`} aria-pressed={effectiveView === 'single'} onClick={() => setView('single')}>单角色配置</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" role="status"><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} 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} 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 && (selected.enabled
|
||||
? <button className="ad-mini" disabled={busy || roleLoading} onClick={() => setConfirmDisable(true)}>停用角色</button>
|
||||
: <button className="ad-mini" disabled={busy || roleLoading} onClick={setEnabled}>启用角色</button>)}
|
||||
</div>
|
||||
{confirmDisable && selected.enabled && <div className="rp-disable-confirm" role="alertdialog" aria-label={`确认停用角色 ${selected.name}`}>
|
||||
<span>停用角色「{selected.name}」?该角色现有 {selected.bindingCount} 个用户绑定,停用后这些绑定将不再授予权限。</span>
|
||||
<button className="ad-mini" disabled={busy || roleLoading} onClick={() => { setConfirmDisable(false); void setEnabled() }}>确认停用</button>
|
||||
<button className="ad-mini" disabled={busy} onClick={() => setConfirmDisable(false)}>取消</button>
|
||||
</div>}
|
||||
{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 || 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 || 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>
|
||||
<Collapsible id={`role-diff-${selected.roleId}`} trigger={`变更预览(${changes.length})`}>
|
||||
{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>
|
||||
{canManagePermissions && <button ref={savePermsRef} 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>
|
||||
{canViewPermissions && <Collapsible id="role-linked-help" trigger="权限关联说明">
|
||||
<p className="ad-hint">开启「{labelOf(PROJECT_CREATE_ACTION)}」的角色,在本人创建的项目中自动具备{PROJECT_CREATE_LINKED_ACTIONS.map(id => `「${labelOf(id)}」`).join('、')}。
|
||||
这些资格仅对本人创建的项目生效,其他项目仍按项目授权判断。</p>
|
||||
</Collapsible>}
|
||||
<div ref={statusRef} tabIndex={-1} className="rp-status">
|
||||
{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>}
|
||||
</div>
|
||||
{roles === null && roleLoading ? <div className="empty-note">加载中…</div> : effectiveView === 'matrix' ? matrix() : focus()}
|
||||
{preview && previewPanel(preview)}
|
||||
{canManagePermissions && dirty && <div className="rp-savebar" role="region" aria-label="未保存的权限修改">
|
||||
<span className="rp-savebar-summary">已修改 {dirtyList.length} 个角色、{changeCount} 项设置</span>
|
||||
<button className="ad-mini" disabled={busy || roleLoading} onClick={() => void requestPreview(false)}>查看变化</button>
|
||||
<button className="ad-mini" disabled={busy} onClick={cancelAll}>取消全部</button>
|
||||
{preview?.confirm
|
||||
? <button ref={savePermsRef} className="lg-submit ad-create-btn" disabled={busy || roleLoading || blocked} onClick={() => void confirmSave()}>确认保存</button>
|
||||
: <button ref={savePermsRef} className="ad-mini" disabled={busy || roleLoading || !editable} onClick={() => void requestPreview(true)}>保存</button>}
|
||||
</div>}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,58 @@
|
||||
import type { PermissionDescriptor, RolePermissionPatchEntry } from './api'
|
||||
|
||||
export type RolePermissionValue = { allowed: boolean; capAllowed: boolean }
|
||||
type RolePermissionChange = { actionId: string; allowed: boolean; capAllowed: boolean }
|
||||
export type RolePermissionStateName = 'denied' | 'project' | 'default'
|
||||
|
||||
export const ROLE_PERMISSION_STATES: { state: RolePermissionStateName; label: string }[] = [
|
||||
{ state: 'denied', label: '不允许' },
|
||||
{ state: 'project', label: '按项目授权' },
|
||||
{ state: 'default', label: '默认允许' },
|
||||
]
|
||||
|
||||
const ROLE_PERMISSION_CATEGORIES = ['项目与协作', '文档工作', '项目内容'] as const
|
||||
|
||||
export const PROJECT_CREATE_ACTION = 'project.create'
|
||||
/** 开启 project.create 时随之具备的本人创建项目管理资格(与后端 catalog 同序)。 */
|
||||
export const PROJECT_CREATE_LINKED_ACTIONS = [
|
||||
'project.settings', 'project.members.manage', 'project.permissions.manage', 'document.duties.assign',
|
||||
] as const
|
||||
|
||||
export function stateToValue(state: RolePermissionStateName): RolePermissionValue {
|
||||
if (state === 'default') return { allowed: true, capAllowed: true }
|
||||
if (state === 'project') return { allowed: false, capAllowed: true }
|
||||
return { allowed: false, capAllowed: false }
|
||||
}
|
||||
|
||||
export function valueToState(value: RolePermissionValue | undefined): RolePermissionStateName {
|
||||
if (!value?.capAllowed) return 'denied'
|
||||
return value.allowed ? 'default' : 'project'
|
||||
}
|
||||
|
||||
export function stateLabel(state: RolePermissionStateName): string {
|
||||
return ROLE_PERMISSION_STATES.find(item => item.state === state)?.label ?? state
|
||||
}
|
||||
|
||||
/** 受保护系统授权不进矩阵;缺分类的非保护系统动作(如 project.create)归入「项目与协作」。 */
|
||||
function permissionCategory(item: PermissionDescriptor): string {
|
||||
if (item.protected) return ''
|
||||
return item.category || (item.scope === 'system' ? ROLE_PERMISSION_CATEGORIES[0] : item.group)
|
||||
}
|
||||
|
||||
export function permissionCategories(catalog: PermissionDescriptor[]): string[] {
|
||||
const present = [...new Set(catalog.map(permissionCategory).filter(Boolean))]
|
||||
const fixed: string[] = ROLE_PERMISSION_CATEGORIES.filter(category => present.includes(category))
|
||||
return [...fixed, ...present.filter(category => !fixed.includes(category))]
|
||||
}
|
||||
|
||||
export function categoryActions(catalog: PermissionDescriptor[], category: string): PermissionDescriptor[] {
|
||||
if (!category) return []
|
||||
return catalog.filter(item => permissionCategory(item) === category)
|
||||
}
|
||||
|
||||
export function linkedByProjectCreate(values: Record<string, RolePermissionValue>, actionId: string): boolean {
|
||||
return (PROJECT_CREATE_LINKED_ACTIONS as readonly string[]).includes(actionId) && !!values[PROJECT_CREATE_ACTION]?.allowed
|
||||
}
|
||||
|
||||
export interface RolePermissionDraft {
|
||||
baseline: Record<string, RolePermissionValue>
|
||||
@@ -68,3 +121,22 @@ export function validateRolePermissionDraft(draft: RolePermissionDraft): { actio
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function dirtyRoles(
|
||||
drafts: Record<string, RolePermissionDraft>, actionIds?: string[],
|
||||
): { roleId: string; changes: number }[] {
|
||||
return Object.entries(drafts)
|
||||
.map(([roleId, draft]) => ({ roleId, changes: visiblePatch(draft, actionIds ?? Object.keys(draft.values)).length }))
|
||||
.filter(entry => entry.changes > 0)
|
||||
}
|
||||
|
||||
// One entry per dirty role; each carries only that role's changes from its own baseline.
|
||||
export function rolePatchEntries(
|
||||
drafts: Record<string, RolePermissionDraft>, versions: Record<string, number>, actionIds?: string[],
|
||||
): RolePermissionPatchEntry[] {
|
||||
return dirtyRoles(drafts, actionIds).map(({ roleId }) => ({
|
||||
roleId,
|
||||
expectedRoleVersion: versions[roleId],
|
||||
changes: visiblePatch(drafts[roleId], actionIds ?? Object.keys(drafts[roleId].values)),
|
||||
}))
|
||||
}
|
||||
|
||||
+41
-8
@@ -519,25 +519,58 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.ad-table tr:last-child td { border-bottom: none; }
|
||||
.rp-panel { display: flex; flex-direction: column; gap: 12px; }
|
||||
.rp-filters .lg-input { min-width: 140px; }
|
||||
.rp-view-toggle { justify-content: flex-end; }
|
||||
.rp-view-toggle .ad-mini.on { background: var(--bg-active); color: var(--text); }
|
||||
.rp-view-toggle { justify-content: flex-end; flex-wrap: wrap; }
|
||||
.rp-view-toggle .ad-mini.on { background: var(--bg-active); color: var(--text); border-color: var(--accent); }
|
||||
.rp-category-nav { display: flex; gap: 6px; flex-wrap: wrap; margin-right: auto; }
|
||||
.rp-status:focus { outline: none; }
|
||||
.rp-status:empty { display: none; }
|
||||
.rp-single { display: grid; grid-template-columns: minmax(180px, 240px) minmax(0, 1fr); gap: 16px; align-items: start; }
|
||||
.rp-disable-confirm { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-top: 8px; font-size: var(--fs-ui-sm); color: var(--warn-text); background: var(--warn-soft); border: 1px solid color-mix(in srgb, var(--warn) 35%, transparent); border-radius: var(--radius); padding: 8px 11px; }
|
||||
.rp-role-list { display: flex; flex-direction: column; gap: 2px; padding: 8px; border: 1px solid var(--border-soft); border-radius: var(--radius-lg); background: var(--bg-panel); }
|
||||
.rp-role-list .az-nav-item { justify-content: space-between; width: 100%; }
|
||||
.rp-editor { min-width: 0; display: flex; flex-direction: column; gap: 12px; }
|
||||
.rp-matrix-wrap { overflow-x: auto; border: 1px solid var(--border-soft); border-radius: var(--radius-lg); }
|
||||
.rp-matrix { min-width: 760px; }
|
||||
.rp-matrix th:first-child, .rp-matrix td:first-child { position: sticky; left: 0; background: var(--bg-panel); z-index: 1; }
|
||||
.rp-matrix tr.rp-selected td { background: var(--bg-active); }
|
||||
.rp-matrix tr.rp-selected td:first-child { background: var(--bg-active); }
|
||||
.rp-matrix-wrap { overflow: auto; max-height: 70vh; border: 1px solid var(--border-soft); border-radius: var(--radius-lg); }
|
||||
.rp-matrix-wrap:focus-visible { outline: none; box-shadow: var(--ring); }
|
||||
.rp-matrix { min-width: 760px; border-collapse: separate; border-spacing: 0; }
|
||||
.rp-matrix thead th { position: sticky; top: 0; z-index: 2; background: var(--bg-panel); vertical-align: bottom; }
|
||||
.rp-matrix th:first-child { position: sticky; left: 0; z-index: 1; background: var(--bg-panel); }
|
||||
.rp-matrix thead th:first-child { z-index: 3; }
|
||||
.rp-matrix tbody th { font-weight: normal; color: var(--text); border-bottom: 1px solid var(--border-soft); vertical-align: top; }
|
||||
.rp-matrix td { vertical-align: top; }
|
||||
.rp-matrix tr.rp-selected td, .rp-matrix tr.rp-selected th { background: var(--bg-active); }
|
||||
.rp-role-cell { display: flex; flex-direction: column; gap: 2px; min-width: 180px; max-width: 240px; }
|
||||
.rp-role-name { align-self: flex-start; border: none; background: none; padding: 0; color: var(--accent-text); font-size: var(--fs-ui-sm); font-weight: 600; cursor: pointer; text-align: left; }
|
||||
.rp-role-name:hover:not(:disabled) { text-decoration: underline; }
|
||||
.rp-role-name:disabled { color: var(--text-dim); cursor: default; }
|
||||
.rp-role-desc { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); }
|
||||
.rp-disabled-note { margin: 0; font-size: var(--fs-ui-xs); color: var(--warn-text); }
|
||||
.rp-empty { padding: 12px; }
|
||||
.rp-cell { display: flex; flex-direction: column; gap: 4px; min-width: 120px; }
|
||||
.rp-state { height: 30px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-raised); color: var(--text); font-size: var(--fs-ui-sm); padding: 0 6px; }
|
||||
.rp-state:focus-visible { outline: none; border-color: var(--accent); box-shadow: var(--ring); }
|
||||
.rp-state:disabled { opacity: .55; cursor: default; }
|
||||
.rp-cell-flag { font-size: var(--fs-ui-xs); color: var(--warn-text); }
|
||||
.rp-linked { display: flex; gap: 4px; align-items: flex-start; margin: 0; font-size: var(--fs-ui-xs); color: var(--accent-text); }
|
||||
.rp-linked svg { flex: none; margin-top: 1px; }
|
||||
.rp-focus-table td { vertical-align: top; }
|
||||
.rp-diff { margin: 0; padding-left: 20px; color: var(--text-dim); font-size: var(--fs-ui-sm); }
|
||||
.rp-preview { display: flex; flex-direction: column; gap: 10px; padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--bg-raised); font-size: var(--fs-ui-sm); }
|
||||
.rp-preview-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.rp-preview-role { display: flex; flex-direction: column; gap: 4px; padding-top: 8px; border-top: 1px solid var(--border-soft); }
|
||||
.rp-preview-title { font-weight: 600; color: var(--text); }
|
||||
.rp-warn-text { font-weight: normal; color: var(--warn-text); }
|
||||
.rp-conflict { display: flex; gap: 4px; align-items: flex-start; margin: 0; color: var(--err-text); font-size: var(--fs-ui-sm); }
|
||||
.rp-conflict svg { flex: none; margin-top: 2px; }
|
||||
.rp-savebar { position: sticky; bottom: 0; z-index: 4; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--bg-panel); box-shadow: var(--shadow-md); }
|
||||
.rp-savebar-summary { flex: 1; min-width: 160px; font-size: var(--fs-ui-sm); color: var(--text); }
|
||||
.rp-grants { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 8px; margin-bottom: 12px; }
|
||||
.rp-grants .ad-check { align-items: flex-start; padding: 8px; border: 1px solid var(--border-soft); border-radius: var(--radius); background: var(--bg-raised); }
|
||||
.rp-grants .ad-mono { margin-left: auto; }
|
||||
@media (max-width: 820px) {
|
||||
@media (max-width: 900px) {
|
||||
.rp-single { grid-template-columns: 1fr; }
|
||||
.rp-role-list { max-height: 220px; overflow-y: auto; }
|
||||
.rp-matrix-wrap { max-height: 60vh; }
|
||||
.rp-savebar .ad-mini, .rp-savebar .lg-submit { flex: 1; justify-content: center; }
|
||||
}
|
||||
.ad-mono { font-family: var(--mono); font-size: var(--fs-ui-sm); }
|
||||
.ad-dim { color: var(--text-dim); }
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { PermissionDescriptor, RoleRecord } from '../src/api'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { PermissionDescriptor, RolePermissionPreview, RoleRecord } from '../src/api'
|
||||
import {
|
||||
filterRoles, groupPermissionPage, hasSystemAction, shouldAcceptRoleResponse,
|
||||
NARROW_ROLE_QUERY, cellKind, commitRoleBatch, editRoleDraft, filterRoles, hasSystemAction, initialRoleView, previewRoleBatch,
|
||||
rebaseRoleDrafts, roleCellValue, roleLeaveMessage, shouldAcceptRoleResponse,
|
||||
} from '../src/components/RolePermissionsPanel'
|
||||
import { categoryActions, dirtyRoles, rolePatchEntries, stateToValue } from '../src/rolePermissions'
|
||||
|
||||
const roles: RoleRecord[] = [
|
||||
{ roleId: 'role_engineer', name: '软件工程师', description: '研发', enabled: true, roleVersion: 1, bindingCount: 2, permissions: [] },
|
||||
@@ -10,10 +12,14 @@ const roles: RoleRecord[] = [
|
||||
]
|
||||
|
||||
const catalog: PermissionDescriptor[] = [
|
||||
{ actionId: 'project.create', label: '创建项目', description: '', group: '项目入口', scope: 'system', protected: false },
|
||||
{ actionId: 'document.read', label: '读取文档', description: '', group: '文档动作', scope: 'project', protected: false },
|
||||
{ actionId: 'document.author', label: '编制文档', description: '', group: '文档动作', scope: 'project', protected: false },
|
||||
{ actionId: 'system.roles.view', label: '查看角色', description: '', group: '角色管理', scope: 'system', protected: true },
|
||||
{ actionId: 'project.create', label: '创建项目', description: '', group: '项目入口', category: '项目与协作', scope: 'system', protected: false },
|
||||
{ actionId: 'document.read', label: '读取文档', description: '', group: '文档动作', category: '文档工作', scope: 'project', protected: false },
|
||||
{ actionId: 'document.author', label: '编制文档', description: '', group: '文档动作', category: '文档工作', scope: 'project', protected: false },
|
||||
]
|
||||
const editableIds = catalog.filter(item => !item.protected).map(item => item.actionId)
|
||||
const versions = (drafts: Record<string, { role: RoleRecord }>) =>
|
||||
Object.fromEntries(Object.entries(drafts).map(([roleId, draft]) => [roleId, draft.role.roleVersion]))
|
||||
|
||||
describe('role permission UI helpers', () => {
|
||||
it('combines exact or fuzzy search with the role status filter', () => {
|
||||
@@ -22,9 +28,10 @@ describe('role permission UI helpers', () => {
|
||||
expect(filterRoles(roles, '', 'fuzzy', 'disabled').map(role => role.roleId)).toEqual(['role_reviewer'])
|
||||
})
|
||||
|
||||
it('groups and pages matrix columns without changing their stable action IDs', () => {
|
||||
expect(groupPermissionPage(catalog, '文档动作', 0, 1).map(item => item.actionId)).toEqual(['document.read'])
|
||||
expect(groupPermissionPage(catalog, '文档动作', 1, 1).map(item => item.actionId)).toEqual(['document.author'])
|
||||
it('shows one business category of matrix columns at a time, keeping stable action IDs and skipping protected grants', () => {
|
||||
expect(categoryActions(catalog, '文档工作').map(item => item.actionId)).toEqual(['document.read', 'document.author'])
|
||||
expect(categoryActions(catalog, '项目与协作').map(item => item.actionId)).toEqual(['project.create'])
|
||||
expect(categoryActions(catalog, '').map(item => item.actionId)).toEqual([])
|
||||
})
|
||||
|
||||
it('drops late results and checks explicit system actions', () => {
|
||||
@@ -33,4 +40,77 @@ describe('role permission UI helpers', () => {
|
||||
expect(hasSystemAction(['system.roles.view'], 'system.roles.view')).toBe(true)
|
||||
expect(hasSystemAction(['system.roles.view'], 'system.permissions.manage')).toBe(false)
|
||||
})
|
||||
|
||||
it('writes a matrix cell edit into that role\'s own draft without selecting it first', () => {
|
||||
const first = editRoleDraft({}, roles[0], catalog, 'document.read', stateToValue('project'))
|
||||
const both = editRoleDraft(first, roles[1], catalog, 'document.author', stateToValue('default'))
|
||||
expect(Object.keys(both)).toEqual(['role_engineer', 'role_reviewer'])
|
||||
expect(roleCellValue(both, roles[0], 'document.read')).toEqual({ allowed: false, capAllowed: true })
|
||||
expect(roleCellValue(both, roles[0], 'document.author')).toEqual({ allowed: false, capAllowed: false })
|
||||
expect(both.role_reviewer.role.roleId).toBe('role_reviewer')
|
||||
expect(dirtyRoles(both, editableIds)).toEqual([{ roleId: 'role_engineer', changes: 1 }, { roleId: 'role_reviewer', changes: 1 }])
|
||||
})
|
||||
|
||||
it('uses a two-state control for project.create and three states for project actions', () => {
|
||||
expect(cellKind(catalog[1])).toBe('toggle')
|
||||
expect(cellKind(catalog[2])).toBe('state')
|
||||
const on = editRoleDraft({}, roles[0], catalog, 'project.create', stateToValue('default'))
|
||||
expect(rolePatchEntries(on, versions(on), editableIds)[0].changes).toEqual([{ actionId: 'project.create', allowed: true, capAllowed: true }])
|
||||
})
|
||||
|
||||
it('saves every dirty role through one authoritative preview and one batch commit', async () => {
|
||||
let drafts = editRoleDraft({}, roles[0], catalog, 'document.read', stateToValue('default'))
|
||||
drafts = editRoleDraft(drafts, roles[1], catalog, 'project.create', stateToValue('default'))
|
||||
const entries = rolePatchEntries(drafts, versions(drafts), editableIds)
|
||||
const preview = (roleId: string): RolePermissionPreview => ({ roleId, roleName: roleId, expectedRoleVersion: 1, currentRoleVersion: 1,
|
||||
versionMatches: true, changes: [], linkedExpansion: [], keptLinkedActions: [], conflicts: [] })
|
||||
const client = {
|
||||
previewRolePermissions: vi.fn(async () => entries.map(entry => preview(entry.roleId))),
|
||||
batchPatchRolePermissions: vi.fn(async () => ({ receiptId: 'r1', authorityEpoch: 2, receipts: [] })),
|
||||
}
|
||||
const reviewed = await previewRoleBatch(client, entries)
|
||||
expect(reviewed.blocked).toBe(false)
|
||||
await commitRoleBatch(client, entries, reviewed.previews, 'key-1')
|
||||
expect(client.previewRolePermissions).toHaveBeenCalledWith(entries)
|
||||
expect(client.batchPatchRolePermissions).toHaveBeenCalledTimes(1)
|
||||
expect(client.batchPatchRolePermissions).toHaveBeenCalledWith([
|
||||
{ roleId: 'role_engineer', expectedRoleVersion: 1, changes: [{ actionId: 'document.read', allowed: true, capAllowed: true }] },
|
||||
{ roleId: 'role_reviewer', expectedRoleVersion: 1, changes: [{ actionId: 'project.create', allowed: true, capAllowed: true }] },
|
||||
], 'key-1')
|
||||
|
||||
const conflicted = [{ ...preview('role_engineer'), conflicts: [{ actionId: 'document.read', reason: '平台上限不允许' }] }]
|
||||
expect((await previewRoleBatch({ previewRolePermissions: async () => conflicted }, entries)).blocked).toBe(true)
|
||||
await expect(commitRoleBatch(client, entries, conflicted, 'key-2')).rejects.toThrow()
|
||||
expect(client.batchPatchRolePermissions).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rebases every dirty role on its latest version after a batch conflict', async () => {
|
||||
let drafts = editRoleDraft({}, roles[0], catalog, 'document.read', stateToValue('default'))
|
||||
drafts = editRoleDraft(drafts, roles[1], catalog, 'document.author', stateToValue('project'))
|
||||
const fresh: Record<string, RoleRecord> = {
|
||||
role_engineer: { ...roles[0], roleVersion: 4, permissions: [{ actionId: 'document.author', allowed: true, capAllowed: true, permissionVersion: 2 }] },
|
||||
role_reviewer: { ...roles[1], roleVersion: 9, permissions: [] },
|
||||
}
|
||||
const getRole = vi.fn(async (roleId: string) => fresh[roleId])
|
||||
const rebased = await rebaseRoleDrafts({ getRole }, drafts, ['role_engineer', 'role_reviewer'], catalog)
|
||||
expect(getRole).toHaveBeenCalledTimes(2)
|
||||
expect(rebased.drafts.role_engineer.values['document.read']).toEqual({ allowed: true, capAllowed: true })
|
||||
expect(rebased.drafts.role_engineer.values['document.author']).toEqual({ allowed: true, capAllowed: true })
|
||||
expect(rolePatchEntries(rebased.drafts, versions(rebased.drafts), editableIds)).toEqual([
|
||||
{ roleId: 'role_engineer', expectedRoleVersion: 4, changes: [{ actionId: 'document.read', allowed: true, capAllowed: true }] },
|
||||
{ roleId: 'role_reviewer', expectedRoleVersion: 9, changes: [{ actionId: 'document.author', allowed: false, capAllowed: true }] },
|
||||
])
|
||||
})
|
||||
|
||||
it('names every dirty role in the leave guard', () => {
|
||||
const message = roleLeaveMessage(['软件工程师', '审核工程师'])
|
||||
expect(message).toContain('「软件工程师」')
|
||||
expect(message).toContain('「审核工程师」')
|
||||
})
|
||||
|
||||
it('defaults to single-role focus on narrow screens and to the matrix otherwise', () => {
|
||||
expect(initialRoleView(query => query === NARROW_ROLE_QUERY)).toBe('single')
|
||||
expect(initialRoleView(() => false)).toBe('matrix')
|
||||
expect(initialRoleView()).toBe('matrix')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { PermissionDescriptor } from '../src/api'
|
||||
import {
|
||||
createRolePermissionDraft, mergeRoleDraft, rebaseRoleDraft, updateRolePermission, validateRolePermissionDraft, visiblePatch,
|
||||
type RolePermissionValue,
|
||||
categoryActions, createRolePermissionDraft, dirtyRoles, linkedByProjectCreate, mergeRoleDraft, permissionCategories, rebaseRoleDraft,
|
||||
rolePatchEntries, stateToValue, updateRolePermission, validateRolePermissionDraft, valueToState, visiblePatch,
|
||||
type RolePermissionStateName, type RolePermissionValue,
|
||||
} from '../src/rolePermissions'
|
||||
|
||||
const initial: Record<string, RolePermissionValue> = {
|
||||
@@ -48,3 +50,54 @@ describe('role permission draft', () => {
|
||||
expect(rebased.values['document.read']).toEqual({ allowed: false, capAllowed: true })
|
||||
})
|
||||
})
|
||||
|
||||
const catalog: PermissionDescriptor[] = [
|
||||
{ actionId: 'system.roles.view', label: '查看角色', description: '', group: '角色管理', scope: 'system', protected: true },
|
||||
{ actionId: 'document.read', label: '读取文档', description: '', group: '文档动作', category: '文档工作', scope: 'project', protected: false },
|
||||
{ actionId: 'trace.edit', label: '编辑追溯网', description: '', group: '项目内容', category: '项目内容', scope: 'project', protected: false },
|
||||
{ actionId: 'project.create', label: '创建项目', description: '', group: '项目入口', scope: 'system', protected: false },
|
||||
{ actionId: 'project.settings', label: '项目设置', description: '', group: '项目管理', category: '项目与协作', scope: 'project', protected: false },
|
||||
{ actionId: 'document.author', label: '编制文档', description: '', group: '文档动作', category: '文档工作', scope: 'project', protected: false },
|
||||
]
|
||||
|
||||
describe('role permission tri-state and categories', () => {
|
||||
it('maps the three display states to stored values and back', () => {
|
||||
expect(stateToValue('denied')).toEqual({ allowed: false, capAllowed: false })
|
||||
expect(stateToValue('project')).toEqual({ allowed: false, capAllowed: true })
|
||||
expect(stateToValue('default')).toEqual({ allowed: true, capAllowed: true })
|
||||
for (const state of ['denied', 'project', 'default'] as RolePermissionStateName[]) expect(valueToState(stateToValue(state))).toBe(state)
|
||||
expect(valueToState(undefined)).toBe('denied')
|
||||
})
|
||||
|
||||
it('groups actions in the fixed category order and keeps catalog order inside a category', () => {
|
||||
expect(permissionCategories(catalog)).toEqual(['项目与协作', '文档工作', '项目内容'])
|
||||
expect(categoryActions(catalog, '项目与协作').map(item => item.actionId)).toEqual(['project.create', 'project.settings'])
|
||||
expect(categoryActions(catalog, '文档工作').map(item => item.actionId)).toEqual(['document.read', 'document.author'])
|
||||
expect(permissionCategories(catalog).flatMap(category => categoryActions(catalog, category)).some(item => item.protected)).toBe(false)
|
||||
})
|
||||
|
||||
it('counts only roles whose drafts differ from their baseline', () => {
|
||||
const clean = createRolePermissionDraft(initial)
|
||||
const edited = updateRolePermission(updateRolePermission(createRolePermissionDraft(initial), 'document.read', stateToValue('project')),
|
||||
'document.author', stateToValue('default'))
|
||||
const reverted = updateRolePermission(updateRolePermission(createRolePermissionDraft(initial), 'document.read', stateToValue('denied')),
|
||||
'document.read', initial['document.read'])
|
||||
expect(dirtyRoles({ clean, edited, reverted })).toEqual([{ roleId: 'edited', changes: 2 }])
|
||||
})
|
||||
|
||||
it('builds one patch entry per dirty role with only that role\'s changed actions', () => {
|
||||
const a = updateRolePermission(createRolePermissionDraft(initial), 'project.create', { allowed: true, capAllowed: true })
|
||||
const b = updateRolePermission(createRolePermissionDraft(initial), 'document.read', stateToValue('denied'))
|
||||
const entries = rolePatchEntries({ a, b, c: createRolePermissionDraft(initial) }, { a: 3, b: 7, c: 1 }, Object.keys(initial))
|
||||
expect(entries).toEqual([
|
||||
{ roleId: 'a', expectedRoleVersion: 3, changes: [{ actionId: 'project.create', allowed: true, capAllowed: true }] },
|
||||
{ roleId: 'b', expectedRoleVersion: 7, changes: [{ actionId: 'document.read', allowed: false, capAllowed: false }] },
|
||||
])
|
||||
})
|
||||
|
||||
it('marks linked actions only while project.create is allowed', () => {
|
||||
expect(linkedByProjectCreate({ 'project.create': { allowed: true, capAllowed: true } }, 'project.settings')).toBe(true)
|
||||
expect(linkedByProjectCreate({ 'project.create': { allowed: true, capAllowed: true } }, 'document.read')).toBe(false)
|
||||
expect(linkedByProjectCreate({ 'project.create': { allowed: false, capAllowed: true } }, 'project.settings')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user