From 4be73628423fe79c22ad62ac02604eb358b94433 Mon Sep 17 00:00:00 2001 From: agent-exec-transport Date: Mon, 19 Oct 2026 04:04:38 +0000 Subject: [PATCH] agent-exec transport 719afff63d5bb7be93041751b9e839ce/da1c115ab399 --- src/api.ts | 27 +- src/components/RolePermissionsPanel.tsx | 455 ++++++++++++++++-------- src/rolePermissions.ts | 72 ++++ src/styles.css | 49 ++- test/role-permissions-ui.test.ts | 98 ++++- test/role-permissions.test.ts | 57 ++- 6 files changed, 584 insertions(+), 174 deletions(-) diff --git a/src/api.ts b/src/api.ts index 0c5d646..f48e967 100644 --- a/src/api.ts +++ b/src/api.ts @@ -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(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(r)), getUserRole: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/role`).then(r => json(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' }, diff --git a/src/components/RolePermissionsPanel.tsx b/src/components/RolePermissionsPanel.tsx index a70acac..72f87a5 100644 --- a/src/components/RolePermissionsPanel.tsx +++ b/src/components/RolePermissionsPanel.tsx @@ -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 +type RolePermissionClient = Pick + 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 { 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, entries: RolePermissionPatchEntry[]) { + const previews = await client.previewRolePermissions(entries) + return { previews, blocked: previewBlocksSave(previews) } +} + +// 原子保存:所有脏角色一次提交;预览存在冲突或错误时拒绝提交。 +export async function commitRoleBatch( + client: Pick, entries: RolePermissionPatchEntry[], + previews: RolePermissionPreview[], idempotencyKey: string, +): Promise { + 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, 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(null) const [catalog, setCatalog] = useState([]) + const [catalogFailed, setCatalogFailed] = useState(false) const [selectedId, setSelectedId] = useState('') - const [drafts, setDrafts] = useState>({}) + const [drafts, setDrafts] = useState({}) 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([]) 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(null) + const statusRef = useRef(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
+ {cellKind(item) === 'toggle' + ? + : } + {modified && 已修改} + {linked && {LINKED_NOTE}} +
} - const matrix = () =>
+ const matrix = () =>
- {pageItems.map(item => )} + {columns.map(item => )}{filtered.map(role => - - {pageItems.map(item => { - const value = role.roleId === selectedId && draft ? draft.values[item.actionId] : roleValue(role, item.actionId) - return - })} + + {columns.map(item => )} )}
角色{item.label}
角色{item.label}
· {role.bindingCount} 人 - {item.protected ? 系统授权 : <> - - - } -
+ + {role.description && {role.description}} + {role.bindingCount} 人 · {role.enabled ? '启用' : '已停用'}{dirtyList.some(entry => entry.roleId === role.roleId) ? ' · 未保存' : ''} + {!role.enabled && 停用角色不可编辑权限,请先启用} +
{cell(role, item)}
+ {filtered.length === 0 && !roleLoading &&

没有符合条件的角色

}
+ const focus = () =>
+ +
+ {selected ? <> +
+ setName(event.target.value)} /> + setDescription(event.target.value)} /> + {canManageRoles && } + {canManageRoles && (selected.enabled + ? + : )} +
+ {confirmDisable && selected.enabled &&
+ 停用角色「{selected.name}」?该角色现有 {selected.bindingCount} 个用户绑定,停用后这些绑定将不再授予权限。 + + +
} + {canViewPermissions ? <> + {!selected.enabled &&

角色已停用,启用后才能编辑权限

} + + {columns.map(item => + + )}
权限 · {activeCategory}状态说明
{item.label}{cell(selected, item)}{item.description}
+ +
    +
  • 角色 ID:{selected.roleId} · 版本 {selected.roleVersion}
  • + {columns.map(item =>
  • {item.label}:{item.actionId}
  • )} +
+
+ + {changes.length === 0 || !draft ?

没有权限变化

:
    {changes.map(change => +
  • {labelOf(change.actionId)}:{valueLabel(change.actionId, draft.baseline[change.actionId])} → {valueLabel(change.actionId, change)}
  • )}
} +
+ :

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

} + :

{roleLoading ? '加载中…' : '请选择角色'}

} +
+
+ + const previewPanel = (current: NonNullable) =>
+
+ {current.confirm ? '请确认以下变化后保存' : '待保存的变化'} + +
+ {current.previews.map(item =>
+
{item.roleName} + {!item.versionMatches && · 角色已被他人修改,保存时将提示冲突并基于最新版本重放}
+ {item.error &&

错误{item.errorActionId ? ` · ${labelOf(item.errorActionId)}` : ''}:{item.error}

} + {(item.changes ?? []).length > 0 + ?
    {item.changes.map(change =>
  • {change.label}:{change.before} → {change.after}
  • )}
+ :

没有状态变化

} + {(item.linkedExpansion ?? []).length > 0 &&

+ 系统将自动补齐:{item.linkedExpansion.map(labelOf).join('、')}{item.linkedExpansionState ? `(${item.linkedExpansionState})` : ''}

} + {(item.keptLinkedActions ?? []).length > 0 &&

关闭创建项目后保留:{item.keptLinkedActions.map(labelOf).join('、')}

} + {item.migrationNote &&

{item.migrationNote}

} + {(item.conflicts ?? []).map(conflict =>

+ 冲突 · {labelOf(conflict.actionId)}:{conflict.reason}

)} +
)} + {blocked &&

存在冲突或错误,调整后才能保存

} +
+ return
{canManageRoles &&
@@ -317,9 +501,6 @@ export function RolePermissionsPanel() { - {canViewPermissions && setName(event.target.value)} /> - setDescription(event.target.value)} /> - {canManageRoles && } - {canManageRoles && (selected.enabled - ? - : )} -
- {confirmDisable && selected.enabled &&
- 停用角色「{selected.name}」?该角色现有 {selected.bindingCount} 个用户绑定,停用后这些绑定将不再授予权限。 - - -
} - {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}
- - {changes.length === 0 ?

没有权限变化

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

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

} - :

请选择角色

} - + {canViewPermissions && +

开启「{labelOf(PROJECT_CREATE_ACTION)}」的角色,在本人创建的项目中自动具备{PROJECT_CREATE_LINKED_ACTIONS.map(id => `「${labelOf(id)}」`).join('、')}。 + 这些资格仅对本人创建的项目生效,其他项目仍按项目授权判断。

+
} +
+ {notice &&
{notice}
} + {err &&
{err}
} +
+ {roles === null && roleLoading ?
加载中…
: effectiveView === 'matrix' ? matrix() : focus()} + {preview && previewPanel(preview)} + {canManagePermissions && dirty &&
+ 已修改 {dirtyList.length} 个角色、{changeCount} 项设置 + + + {preview?.confirm + ? + : }
}
} diff --git a/src/rolePermissions.ts b/src/rolePermissions.ts index 999106e..ed30ab4 100644 --- a/src/rolePermissions.ts +++ b/src/rolePermissions.ts @@ -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, actionId: string): boolean { + return (PROJECT_CREATE_LINKED_ACTIONS as readonly string[]).includes(actionId) && !!values[PROJECT_CREATE_ACTION]?.allowed +} export interface RolePermissionDraft { baseline: Record @@ -68,3 +121,22 @@ export function validateRolePermissionDraft(draft: RolePermissionDraft): { actio } return null } + +export function dirtyRoles( + drafts: Record, 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, versions: Record, actionIds?: string[], +): RolePermissionPatchEntry[] { + return dirtyRoles(drafts, actionIds).map(({ roleId }) => ({ + roleId, + expectedRoleVersion: versions[roleId], + changes: visiblePatch(drafts[roleId], actionIds ?? Object.keys(drafts[roleId].values)), + })) +} diff --git a/src/styles.css b/src/styles.css index 7229246..5b668bc 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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); } diff --git a/test/role-permissions-ui.test.ts b/test/role-permissions-ui.test.ts index 560b4b1..f65504e 100644 --- a/test/role-permissions-ui.test.ts +++ b/test/role-permissions-ui.test.ts @@ -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) => + 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 = { + 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') + }) }) diff --git a/test/role-permissions.test.ts b/test/role-permissions.test.ts index fabfb76..2ff678f 100644 --- a/test/role-permissions.test.ts +++ b/test/role-permissions.test.ts @@ -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 = { @@ -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) + }) +})