diff --git a/src/components/RolePermissionsPanel.tsx b/src/components/RolePermissionsPanel.tsx index a70acac..9af7bd4 100644 --- a/src/components/RolePermissionsPanel.tsx +++ b/src/components/RolePermissionsPanel.tsx @@ -1,15 +1,14 @@ -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, -} from '../rolePermissions' +import { useEffect, useRef, useState } from 'react' +import { api, type RoleRecord } from '../api' +import { projectAccessOf, projectAccessPatch } from '../rolePermissions' import { Collapsible, Icon } from '@ui' -import { useStore } from '../store' +import { useStore, type Me } from '../store' -/** DR9 离开管理区守卫:面板有未保存权限草稿时注册;AdminZone「返回工作台」消费。 */ +/** DR9 离开管理区守卫:面板有未保存项目访问草稿时注册;AdminZone「返回工作台」消费。 */ export const roleDraftLeaveGuard: { current: (() => boolean) | null } = { current: null } +export const PROJECT_ACCESS_MANAGE_REASON = '需要角色管理权限才能修改项目访问' + export function filterRoles( roles: RoleRecord[], query: string, mode: 'exact' | 'fuzzy', status: 'all' | 'enabled' | 'disabled', ): RoleRecord[] { @@ -23,11 +22,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,41 +30,59 @@ export function hasSystemAction(actions: string[] | undefined, action: string): return actions?.includes(action) ?? false } -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 => { - const value = existing.get(item.actionId) - return [item.actionId, { allowed: value?.allowed ?? false, capAllowed: value?.capAllowed ?? false }] - })) +/** R13:角色面板只认 system.roles.view / system.roles.manage;isAdmin 不旁路,与服务端同语义。 */ +export function rolePanelAccess(me: Pick | null | undefined): { canView: boolean; canManage: boolean } { + return { + canView: hasSystemAction(me?.systemActions, 'system.roles.view'), + canManage: hasSystemAction(me?.systemActions, 'system.roles.manage'), + } } -function roleValue(role: RoleRecord, actionId: string): RolePermissionValue { - const value = role.permissions.find(item => item.actionId === actionId) - return { allowed: value?.allowed ?? false, capAllowed: value?.capAllowed ?? false } +type ProjectAccessSaveResult = { kind: 'unchanged' } | { kind: 'saved' } | { kind: 'conflict'; fresh: RoleRecord } + +/** 409 时读取最新角色交由调用方按新基线重放本地布尔;其他失败原样抛出。 */ +export async function saveProjectAccess(role: RoleRecord, next: boolean, idempotencyKey: string): Promise { + const changes = projectAccessPatch(role, next) + if (changes.length === 0) return { kind: 'unchanged' } + try { + await api.replaceRolePermissions(role.roleId, role.roleVersion, changes, idempotencyKey) + return { kind: 'saved' } + } catch (error) { + if (!String(error).includes('409')) throw error + return { kind: 'conflict', fresh: await api.getRole(role.roleId) } + } +} + +export function ProjectAccessCheckbox({ roleName, checked, canManage, disabled, showReason = true, onChange }: { + roleName: string; checked: boolean; canManage: boolean; disabled: boolean; showReason?: boolean + onChange: (next: boolean) => void +}) { + return + + {!canManage && showReason && {PROJECT_ACCESS_MANAGE_REASON}} + } export function RolePermissionsPanel() { const me = useStore(state => state.me) - const allowed = (action: string) => !!me?.isAdmin || hasSystemAction(me?.systemActions, action) - const canManageRoles = allowed('system.roles.manage') - const canViewPermissions = allowed('system.permissions.view') - const canManagePermissions = canViewPermissions && allowed('system.permissions.manage') + const { canView, canManage } = rolePanelAccess(me) const [roles, setRoles] = useState(null) - const [catalog, setCatalog] = useState([]) const [selectedId, setSelectedId] = useState('') - const [drafts, setDrafts] = useState>({}) + // DR9:草稿按 roleId 隔离,只存本地意图;显示值 = 草稿 ?? 服务端值,刷新不覆盖本地编辑。 + 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 [actionFilter, setActionFilter] = useState('') const [roleCursor, setRoleCursor] = useState('') const [roleTrail, setRoleTrail] = useState([]) const [nextRoleCursor, setNextRoleCursor] = useState('') - const [page, setPage] = useState(0) const [view, setView] = useState<'single' | 'matrix'>('single') const [newName, setNewName] = useState('') const [newDescription, setNewDescription] = useState('') @@ -83,62 +95,54 @@ export function RolePermissionsPanel() { const savePermsRef = 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 changes = draft ? visiblePatch(draft, allEditableIds) : [] + const selected = filtered.find(role => role.roleId === selectedId) ?? null + const accessOf = (role: RoleRecord) => drafts[role.roleId] ?? projectAccessOf(role) + const changes = selected ? projectAccessPatch(selected, accessOf(selected)) : [] + const dirty = changes.length > 0 - const load = async (pageCursor = roleCursor, refreshCatalog = false, preferredId = selectedId, - filters = { query, mode, status, action: actionFilter }) => { + const load = async (pageCursor = roleCursor, preferredId = selectedId, filters = { query, mode, status }) => { const sequence = ++requestSequence.current setRoleLoading(true) setErr('') - const [roleResult, catalogResult] = await Promise.allSettled([ - api.listRoles({ query: filters.query.trim(), mode: filters.mode, status: filters.status, - action: filters.action || undefined, cursor: pageCursor, limit: 50 }), - canViewPermissions && (refreshCatalog || catalog.length === 0) ? api.permissionCatalog() : Promise.resolve(catalog), - ]) - if (!shouldAcceptRoleResponse(requestSequence.current, sequence)) return - setRoleLoading(false) - const descriptors = catalogResult.status === 'fulfilled' ? catalogResult.value : catalog - if (catalogResult.status === 'fulfilled') setCatalog(descriptors) - if (roleResult.status === 'rejected') { - setErr(String(roleResult.reason)) - return + try { + const rolePage = await api.listRoles({ query: filters.query.trim(), mode: filters.mode, status: filters.status, cursor: pageCursor, limit: 50 }) + if (!shouldAcceptRoleResponse(requestSequence.current, sequence)) return + setRoleLoading(false) + setRoles(rolePage.roles ?? []) + setNextRoleCursor(rolePage.nextCursor ?? '') + const nextSelected = rolePage.roles.find(role => role.roleId === preferredId) ?? rolePage.roles[0] ?? null + if (nextSelected) selectRole(nextSelected, sequence) + else setSelectedId('') + } catch (error) { + if (!shouldAcceptRoleResponse(requestSequence.current, sequence)) return + setRoleLoading(false) + setErr(String(error)) } - const rolePage = roleResult.value - setRoles(rolePage.roles ?? []) - setNextRoleCursor(rolePage.nextCursor ?? '') - const nextSelected = rolePage.roles.find(role => role.roleId === preferredId) ?? rolePage.roles[0] ?? null - if (nextSelected) selectRole(nextSelected, descriptors, sequence) - else setSelectedId('') - // 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('') } + const replaceRole = (fresh: RoleRecord) => setRoles(current => current?.some(role => role.roleId === fresh.roleId) + ? current.map(role => role.roleId === fresh.roleId ? fresh : role) : [fresh, ...(current ?? [])]) + + const clearDraft = (roleId: string) => setDrafts(current => { + if (!(roleId in current)) return current + const next = { ...current }; delete next[roleId]; return next + }) + // DR9:离开管理区须显式决定;带未保存草稿时刷新走浏览器原生确认。 useEffect(() => { if (!dirty || !selected) { roleDraftLeaveGuard.current = null; return } - const message = `角色「${selected.name}」有未保存的权限修改,离开将放弃这些修改。确定离开吗?` + const message = `角色「${selected.name}」有未保存的项目访问修改,离开将放弃这些修改。确定离开吗?` roleDraftLeaveGuard.current = () => window.confirm(message) return () => { roleDraftLeaveGuard.current = null } }, [dirty, selected?.roleId, selected?.name]) @@ -150,10 +154,11 @@ export function RolePermissionsPanel() { }, [dirty]) useEffect(() => { + if (!canView) return setNextRoleCursor(''); setRoleLoading(true) const timer = window.setTimeout(() => { void load(roleCursor) }, 180) return () => { window.clearTimeout(timer); requestSequence.current++ } - }, [query, mode, status, actionFilter, roleCursor, canViewPermissions]) + }, [query, mode, status, roleCursor, canView]) const create = async () => { if (!newName.trim()) return @@ -162,11 +167,11 @@ export function RolePermissionsPanel() { try { const role = await api.createRole(newName.trim(), newDescription.trim()) setNewName(''); setNewDescription('') - setQuery(''); setStatus('all'); setActionFilter(''); setRoleCursor(''); setRoleTrail([]) + setQuery(''); setStatus('all'); 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: '' }) + await load('', role.roleId, { query: '', mode, status: 'all' }) setNotice(`已创建角色 ${role.name}`) } catch (error) { setErr(String(error)) } finally { setBusy(false) } } @@ -185,8 +190,7 @@ export function RolePermissionsPanel() { 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 ?? [])]) + replaceRole(fresh) setErr('角色资料版本冲突;已读取最新版本并保留本地名称与描述,请核对后重试') } catch (refreshError) { setErr(String(refreshError)) } } else setErr(String(error)) @@ -208,45 +212,34 @@ export function RolePermissionsPanel() { 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 ?? [])]) + replaceRole(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 savePermissions = async () => { - if (!selected || !draft) return + const saveAccess = async () => { + if (!selected || !canManage) return const roleId = selected.roleId + const next = accessOf(selected) 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('角色权限已保存') - } catch (error) { - if (String(error).includes('409')) { - try { await rebasePermissionConflict(roleId, draft, operationSequence) } catch (refreshError) { setErr(String(refreshError)) } - } else setErr(String(error)) - } + const result = await saveProjectAccess(selected, next, crypto.randomUUID()) + if (result.kind === 'conflict') { + if (requestSequence.current !== operationSequence || selectedIdRef.current !== roleId) return + // DR9:409 只重放同一角色的草稿——本地布尔保留,基线换成最新服务端值。 + replaceRole(result.fresh) + setErr(projectAccessOf(result.fresh) === next + ? '项目访问版本冲突;最新版本已与本地修改一致,无需再次保存' + : '项目访问版本冲突;已基于最新版本重放本地修改,请核对后再次保存') + return + } + clearDraft(roleId) + await load(roleCursor, roleId); setNotice('项目访问已保存') + } catch (error) { setErr(String(error)) } finally { setBusy(false) // DR10:保存/冲突/失败后焦点稳定——若焦点因按钮短暂 disabled 掉回 body,则归还。 @@ -256,52 +249,40 @@ export function RolePermissionsPanel() { } } - 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 editAccess = (role: RoleRecord, next: boolean) => { + if (role.roleId !== selectedId) return + setDrafts(prev => { + const updated = { ...prev } + if (next === projectAccessOf(role)) delete updated[role.roleId] + else updated[role.roleId] = next + return updated + }) } - 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) - } + if (!canView) return

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

- const matrix = () =>
- - {pageItems.map(item => )} - {filtered.map(role => - - {pageItems.map(item => { - const value = role.roleId === selectedId && draft ? draft.values[item.actionId] : roleValue(role, item.actionId) - return - })} - )} -
角色{item.label}
· {role.bindingCount} 人 - {item.protected ? 系统授权 : <> - - - } -
+ const saveBar = selected && canManage &&
+ {dirty ? `未保存:「${selected.name}」项目访问将${accessOf(selected) ? '开启' : '关闭'}` : '没有未保存的修改'} +
+ const matrix = () => <> + {!canManage &&

{PROJECT_ACCESS_MANAGE_REASON}

} +
+ + + {filtered.map(role => + + + )} +
角色项目访问
· {role.bindingCount} 人 editAccess(role, next)} />
+
+ {saveBar} + + return
- {canManageRoles &&
+ {canManage &&
setNewName(event.target.value)} /> setNewDescription(event.target.value)} /> @@ -317,44 +298,29 @@ export function RolePermissionsPanel() { - - {canViewPermissions && }
- {canViewPermissions && } - - + - {view === 'matrix' && canManagePermissions && <> - - - - - }
{notice &&
{notice}
} {err &&
{err}
} {view === 'matrix' ? matrix() :
- {selected && draft ? <> + {selected ? <>
- setName(event.target.value)} /> - setDescription(event.target.value)} /> - {canManageRoles && } - {canManageRoles && (selected.enabled + setName(event.target.value)} /> + setDescription(event.target.value)} /> + {canManage && } + {canManage && (selected.enabled ? : )}
@@ -363,22 +329,9 @@ export function RolePermissionsPanel() {
} - {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 && } - :

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

} + editAccess(selected, next)} /> + {saveBar} :

请选择角色

}
} diff --git a/src/rolePermissions.ts b/src/rolePermissions.ts index 999106e..4fb6b26 100644 --- a/src/rolePermissions.ts +++ b/src/rolePermissions.ts @@ -1,70 +1,16 @@ -export type RolePermissionValue = { allowed: boolean; capAllowed: boolean } +import type { RoleRecord } from './api' + type RolePermissionChange = { actionId: string; allowed: boolean; capAllowed: boolean } -export interface RolePermissionDraft { - baseline: Record - values: Record - generation: number +const PROJECT_ACCESS_ACTION = 'project.create' + +/** 角色唯一可编辑能力「项目访问」:project.create 行需默认与上限同时允许,缺行即无访问。 */ +export function projectAccessOf(role: RoleRecord): boolean { + const row = role.permissions.find(item => item.actionId === PROJECT_ACCESS_ACTION) + return !!row && row.allowed && row.capAllowed } -function copy(values: Record): Record { - return Object.fromEntries(Object.entries(values).map(([actionId, value]) => [actionId, { ...value }])) -} - -export function createRolePermissionDraft(values: Record, generation = 0): RolePermissionDraft { - return { baseline: copy(values), values: copy(values), generation } -} - -export function updateRolePermission( - draft: RolePermissionDraft, - actionId: string, - value: RolePermissionValue, -): RolePermissionDraft { - return { ...draft, values: { ...draft.values, [actionId]: { ...value } }, generation: draft.generation + 1 } -} - -export function mergeRoleDraft( - current: RolePermissionDraft, - serverValues: Record, - responseGeneration: number, -): RolePermissionDraft { - if (responseGeneration < current.generation) return current - return createRolePermissionDraft(serverValues, responseGeneration) -} - -// Rebase local edits on a newer server baseline after an optimistic conflict. -// Only fields changed from the old baseline are replayed. -export function rebaseRoleDraft( - current: RolePermissionDraft, - serverValues: Record, -): RolePermissionDraft { - const values = copy(serverValues) - for (const [actionId, local] of Object.entries(current.values)) { - const old = current.baseline[actionId] - if (!old || old.allowed !== local.allowed || old.capAllowed !== local.capAllowed) values[actionId] = { ...local } - } - return { baseline: copy(serverValues), values, generation: current.generation + 1 } -} - -export function visiblePatch(draft: RolePermissionDraft, visibleActionIds: string[]): RolePermissionChange[] { - const seen = new Set() - const changes: RolePermissionChange[] = [] - for (const actionId of visibleActionIds) { - if (seen.has(actionId)) continue - seen.add(actionId) - const value = draft.values[actionId] - if (!value) continue - const baseline = draft.baseline[actionId] - if (baseline && baseline.allowed === value.allowed && baseline.capAllowed === value.capAllowed) continue - changes.push({ actionId, ...value }) - } - return changes -} - -export function validateRolePermissionDraft(draft: RolePermissionDraft): { actionId: string; reason: string } | null { - for (const actionId of Object.keys(draft.values).sort()) { - const value = draft.values[actionId] - if (value.allowed && !value.capAllowed) return { actionId, reason: 'default-exceeds-cap' } - } - return null +export function projectAccessPatch(role: RoleRecord, next: boolean): RolePermissionChange[] { + if (projectAccessOf(role) === next) return [] + return [{ actionId: PROJECT_ACCESS_ACTION, allowed: next, capAllowed: next }] } diff --git a/src/styles.css b/src/styles.css index 54f065a..1ff8fc6 100644 --- a/src/styles.css +++ b/src/styles.css @@ -527,11 +527,12 @@ button { font-family: inherit; font-size: inherit; color: inherit; } .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 { min-width: 420px; } .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-diff { margin: 0; padding-left: 20px; color: var(--text-dim); font-size: var(--fs-ui-sm); } +.rp-access { display: flex; flex-direction: column; gap: 4px; } +.rp-save { justify-content: space-between; flex-wrap: wrap; } .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; } diff --git a/test/role-permissions-ui.test.ts b/test/role-permissions-ui.test.ts index 560b4b1..8262c8e 100644 --- a/test/role-permissions-ui.test.ts +++ b/test/role-permissions-ui.test.ts @@ -1,36 +1,125 @@ -import { describe, expect, it } from 'vitest' -import type { PermissionDescriptor, RoleRecord } from '../src/api' +// 角色面板(计划 U1 / R13):角色只编辑单一「项目访问」布尔;可编辑性只看 system.roles.manage, +// 可读性只看 system.roles.view,isAdmin 不旁路。服务端授权仍为权威,这里锁定门禁、呈现与请求体。 +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { api, type RoleRecord } from '../src/api' import { - filterRoles, groupPermissionPage, hasSystemAction, shouldAcceptRoleResponse, + PROJECT_ACCESS_MANAGE_REASON, ProjectAccessCheckbox, filterRoles, hasSystemAction, rolePanelAccess, saveProjectAccess, + shouldAcceptRoleResponse, } from '../src/components/RolePermissionsPanel' +import { projectAccessPatch } from '../src/rolePermissions' + +afterEach(() => vi.restoreAllMocks()) const roles: RoleRecord[] = [ { roleId: 'role_engineer', name: '软件工程师', description: '研发', enabled: true, roleVersion: 1, bindingCount: 2, permissions: [] }, { roleId: 'role_reviewer', name: '审核工程师', description: '质量', enabled: false, roleVersion: 1, bindingCount: 1, permissions: [] }, ] +const withAccess = (role: RoleRecord, access: boolean, roleVersion = role.roleVersion): RoleRecord => ({ + ...role, roleVersion, permissions: [{ actionId: 'project.create', allowed: access, capAllowed: access, permissionVersion: roleVersion }], +}) +const noop = () => undefined -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 }, -] - -describe('role permission UI helpers', () => { +describe('role panel helpers', () => { it('combines exact or fuzzy search with the role status filter', () => { expect(filterRoles(roles, '软件工程师', 'exact', 'enabled').map(role => role.roleId)).toEqual(['role_engineer']) expect(filterRoles(roles, '工程师', 'fuzzy', 'all').map(role => role.roleId)).toEqual(['role_engineer', 'role_reviewer']) 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('drops late results and checks explicit system actions', () => { expect(shouldAcceptRoleResponse(4, 3)).toBe(false) expect(shouldAcceptRoleResponse(4, 4)).toBe(true) expect(hasSystemAction(['system.roles.view'], 'system.roles.view')).toBe(true) - expect(hasSystemAction(['system.roles.view'], 'system.permissions.manage')).toBe(false) + expect(hasSystemAction(['system.roles.view'], 'system.roles.manage')).toBe(false) + }) +}) + +describe('R13 门禁', () => { + it('可编辑性只看 system.roles.manage,与权限目录系统授权无关', () => { + expect(rolePanelAccess({ systemActions: ['system.roles.view', 'system.roles.manage'] })).toEqual({ canView: true, canManage: true }) + expect(rolePanelAccess({ systemActions: ['system.roles.view', 'system.permissions.view', 'system.permissions.manage'] })) + .toEqual({ canView: true, canManage: false }) + expect(rolePanelAccess({ systemActions: ['system.roles.view'] })).toEqual({ canView: true, canManage: false }) + }) + + it('isAdmin 不再默认可读可写;缺 me 时全部关闭', () => { + const admin = { isAdmin: true, systemActions: [] as string[] } + expect(rolePanelAccess(admin)).toEqual({ canView: false, canManage: false }) + expect(rolePanelAccess(null)).toEqual({ canView: false, canManage: false }) + }) +}) + +describe('项目访问复选框', () => { + it('可管理时呈现单一可勾选控件,aria-label 带角色名', () => { + const html = renderToStaticMarkup(createElement(ProjectAccessCheckbox, { roleName: '软件工程师', checked: true, canManage: true, disabled: false, onChange: noop })) + expect(html.match(/type="checkbox"/g)).toHaveLength(1) + expect(html).toContain('aria-label="软件工程师 项目访问"') + expect(html).toContain('checked=""') + expect(html).not.toContain('disabled=""') + expect(html).not.toContain(PROJECT_ACCESS_MANAGE_REASON) + }) + + it('无角色管理权限时禁用并说明原因', () => { + const html = renderToStaticMarkup(createElement(ProjectAccessCheckbox, { roleName: '审核工程师', checked: false, canManage: false, disabled: false, onChange: noop })) + expect(html).toContain('disabled=""') + expect(html).toContain(PROJECT_ACCESS_MANAGE_REASON) + const matrixCell = renderToStaticMarkup(createElement(ProjectAccessCheckbox, { roleName: '审核工程师', checked: false, canManage: false, disabled: false, showReason: false, onChange: noop })) + expect(matrixCell).toContain('disabled=""') + expect(matrixCell).not.toContain(PROJECT_ACCESS_MANAGE_REASON) + }) + + it('零裸字段:不暴露动作 ID、默认/上限双控件或原始字段名', () => { + const html = renderToStaticMarkup(createElement(ProjectAccessCheckbox, { roleName: '软件工程师', checked: false, canManage: false, disabled: true, onChange: noop })) + for (const raw of ['project.create', 'actionId', 'allowed', 'capAllowed', '默认', '上限']) expect(html).not.toContain(raw) + expect(html).toContain('项目访问(可创建项目)') + }) +}) + +describe('保存项目访问', () => { + it('只提交单布尔补丁,并携带角色版本与幂等键', async () => { + const replace = vi.spyOn(api, 'replaceRolePermissions').mockResolvedValue({ receiptId: 'r1', roleVersion: 2, authorityEpoch: 1 }) + const result = await saveProjectAccess(roles[0], true, 'key-1') + expect(result).toEqual({ kind: 'saved' }) + expect(replace).toHaveBeenCalledTimes(1) + expect(replace).toHaveBeenCalledWith('role_engineer', 1, [{ actionId: 'project.create', allowed: true, capAllowed: true }], 'key-1') + }) + + it('值不变时不发请求', async () => { + const replace = vi.spyOn(api, 'replaceRolePermissions') + expect(await saveProjectAccess(withAccess(roles[0], true), true, 'key-2')).toEqual({ kind: 'unchanged' }) + expect(replace).not.toHaveBeenCalled() + }) + + it('409 时读取最新版本,并在新基线上重放本地布尔', async () => { + const fresh = withAccess(roles[0], false, 5) + const replace = vi.spyOn(api, 'replaceRolePermissions') + .mockRejectedValueOnce(new Error('409: role version conflict')) + .mockResolvedValueOnce({ receiptId: 'r2', roleVersion: 6, authorityEpoch: 2 }) + const getRole = vi.spyOn(api, 'getRole').mockResolvedValue(fresh) + + const conflict = await saveProjectAccess(roles[0], true, 'key-3') + expect(conflict).toEqual({ kind: 'conflict', fresh }) + expect(getRole).toHaveBeenCalledWith('role_engineer') + expect(projectAccessPatch(fresh, true)).toEqual([{ actionId: 'project.create', allowed: true, capAllowed: true }]) + + expect(await saveProjectAccess(fresh, true, 'key-4')).toEqual({ kind: 'saved' }) + expect(replace).toHaveBeenLastCalledWith('role_engineer', 5, [{ actionId: 'project.create', allowed: true, capAllowed: true }], 'key-4') + }) + + it('409 后最新版本已等于本地值时重放补丁为空', async () => { + vi.spyOn(api, 'replaceRolePermissions').mockRejectedValue(new Error('409: role version conflict')) + const fresh = withAccess(roles[0], true, 4) + vi.spyOn(api, 'getRole').mockResolvedValue(fresh) + expect(await saveProjectAccess(roles[0], true, 'key-5')).toEqual({ kind: 'conflict', fresh }) + expect(projectAccessPatch(fresh, true)).toEqual([]) + }) + + it('非冲突失败原样抛出,不读取最新版本', async () => { + vi.spyOn(api, 'replaceRolePermissions').mockRejectedValue(new Error('403: forbidden')) + const getRole = vi.spyOn(api, 'getRole') + await expect(saveProjectAccess(roles[0], true, 'key-6')).rejects.toThrow('403') + expect(getRole).not.toHaveBeenCalled() }) }) diff --git a/test/role-permissions.test.ts b/test/role-permissions.test.ts index fabfb76..becb140 100644 --- a/test/role-permissions.test.ts +++ b/test/role-permissions.test.ts @@ -1,50 +1,35 @@ import { describe, expect, it } from 'vitest' -import { - createRolePermissionDraft, mergeRoleDraft, rebaseRoleDraft, updateRolePermission, validateRolePermissionDraft, visiblePatch, - type RolePermissionValue, -} from '../src/rolePermissions' +import type { RoleRecord, RolePermissionRecord } from '../src/api' +import { projectAccessOf, projectAccessPatch } from '../src/rolePermissions' -const initial: Record = { - 'project.create': { allowed: false, capAllowed: true }, - 'document.read': { allowed: true, capAllowed: true }, - 'document.author': { allowed: false, capAllowed: true }, -} +const role = (permissions: RolePermissionRecord[]): RoleRecord => ({ + roleId: 'role_engineer', name: '软件工程师', description: '', enabled: true, roleVersion: 3, bindingCount: 0, permissions, +}) +const row = (allowed: boolean, capAllowed: boolean, actionId = 'project.create'): RolePermissionRecord => + ({ actionId, allowed, capAllowed, permissionVersion: 1 }) -describe('role permission draft', () => { - it('retains hidden edits and patches only explicit visible actions', () => { - let draft = createRolePermissionDraft(initial) - draft = updateRolePermission(draft, 'project.create', { allowed: true, capAllowed: true }) - draft = updateRolePermission(draft, 'document.author', { allowed: true, capAllowed: true }) - - expect(visiblePatch(draft, ['project.create'])).toEqual([ - { actionId: 'project.create', allowed: true, capAllowed: true }, - ]) - expect(draft.values['document.author']).toEqual({ allowed: true, capAllowed: true }) +describe('project access normalization', () => { + it('requires both default and cap on the project.create row', () => { + expect(projectAccessOf(role([row(true, true)]))).toBe(true) + expect(projectAccessOf(role([row(false, true)]))).toBe(false) + expect(projectAccessOf(role([row(true, false)]))).toBe(false) }) - it('produces the same patch for single-role and matrix edits', () => { - const single = updateRolePermission(createRolePermissionDraft(initial), 'document.read', { allowed: false, capAllowed: true }) - const matrix = updateRolePermission(createRolePermissionDraft(initial), 'document.read', { allowed: false, capAllowed: true }) - expect(visiblePatch(single, Object.keys(initial))).toEqual(visiblePatch(matrix, Object.keys(initial))) + it('treats a missing project.create row as no access', () => { + expect(projectAccessOf(role([]))).toBe(false) + expect(projectAccessOf(role([row(true, true, 'document.read')]))).toBe(false) }) +}) - it('discards a stale server refresh without replacing newer edits', () => { - const edited = updateRolePermission(createRolePermissionDraft(initial), 'project.create', { allowed: true, capAllowed: true }) - const merged = mergeRoleDraft(edited, initial, edited.generation - 1) - expect(merged).toBe(edited) +describe('project access patch', () => { + it('emits a single project.create change with default and cap moving together', () => { + expect(projectAccessPatch(role([]), true)).toEqual([{ actionId: 'project.create', allowed: true, capAllowed: true }]) + expect(projectAccessPatch(role([row(true, true)]), false)).toEqual([{ actionId: 'project.create', allowed: false, capAllowed: false }]) }) - it('rejects defaults that exceed the role cap', () => { - const invalid = updateRolePermission(createRolePermissionDraft(initial), 'document.read', { allowed: true, capAllowed: false }) - expect(validateRolePermissionDraft(invalid)).toEqual({ actionId: 'document.read', reason: 'default-exceeds-cap' }) - }) - - it('rebases local changes on a newer role version without losing the draft', () => { - const edited = updateRolePermission(createRolePermissionDraft(initial), 'project.create', { allowed: true, capAllowed: true }) - const server = { ...initial, 'document.read': { allowed: false, capAllowed: true } } - const rebased = rebaseRoleDraft(edited, server) - expect(rebased.baseline['document.read']).toEqual({ allowed: false, capAllowed: true }) - expect(rebased.values['project.create']).toEqual({ allowed: true, capAllowed: true }) - expect(rebased.values['document.read']).toEqual({ allowed: false, capAllowed: true }) + it('returns an empty patch when the effective value is unchanged', () => { + expect(projectAccessPatch(role([row(true, true)]), true)).toEqual([]) + expect(projectAccessPatch(role([row(false, true)]), false)).toEqual([]) + expect(projectAccessPatch(role([]), false)).toEqual([]) }) })