- Added utility functions for normalizing sidebar paths and collecting metadata from sidebar items. - Implemented logic to match models with sidebar sections, improving the organization of permissions. - Enhanced the data source construction for the PermissionsMatrix, allowing for better grouping and display of models. - Introduced state management for expanded rows and hovered columns to improve user interaction. - Refactored existing functionality to accommodate new features while maintaining performance and clarity.
646 lines
18 KiB
JavaScript
646 lines
18 KiB
JavaScript
import {
|
|
useMemo,
|
|
useCallback,
|
|
useState,
|
|
createElement,
|
|
useContext
|
|
} from 'react'
|
|
import PropTypes from 'prop-types'
|
|
import { Table, Flex, Tooltip, Button } from 'antd'
|
|
import { useMediaQuery } from 'react-responsive'
|
|
import { ElectronContext } from '../context/ElectronContext'
|
|
import {
|
|
getPermissionMatrixModels,
|
|
getPermissionMatrixActions,
|
|
modelHasPermissionAction
|
|
} from '../../../database/ObjectModels'
|
|
import { getSidebar, getSidebarMenuSections } from '../../../database/Sidebars'
|
|
import { getSidebarIconNode } from '../../Icons/sidebarIconMap'
|
|
import PermissionCheckbox from './PermissionCheckbox'
|
|
import { CaretRightOutlined } from '@ant-design/icons'
|
|
|
|
const DEFAULT_SCROLL_HEIGHT = 'calc(var(--unit-100vh) - 258px)'
|
|
|
|
const getPermissionState = (permissions, modelName, actionName) => {
|
|
const value = permissions?.[modelName]?.[actionName]
|
|
if (value === true) return true
|
|
if (value === false) return false
|
|
return null
|
|
}
|
|
|
|
const setPermissionState = (permissions, modelName, actionName, next) => {
|
|
const current =
|
|
permissions && typeof permissions === 'object' ? permissions : {}
|
|
const modelPermissions = {
|
|
...(current[modelName] && typeof current[modelName] === 'object'
|
|
? current[modelName]
|
|
: {})
|
|
}
|
|
|
|
if (next === null) {
|
|
delete modelPermissions[actionName]
|
|
} else {
|
|
modelPermissions[actionName] = next
|
|
}
|
|
|
|
const nextPermissions = { ...current }
|
|
if (Object.keys(modelPermissions).length === 0) {
|
|
delete nextPermissions[modelName]
|
|
} else {
|
|
nextPermissions[modelName] = modelPermissions
|
|
}
|
|
return nextPermissions
|
|
}
|
|
|
|
const cyclePermissionState = (current, inherit) => {
|
|
if (!inherit) {
|
|
return current === true ? false : true
|
|
}
|
|
if (current === true) return false
|
|
if (current === false) return null
|
|
return true
|
|
}
|
|
|
|
const normalizePermissionState = (state, inherit) => {
|
|
if (!inherit && state === null) return false
|
|
return state
|
|
}
|
|
|
|
const getAggregatePermissionState = (states) => {
|
|
if (states.length === 0) return null
|
|
const first = states[0]
|
|
for (let i = 1; i < states.length; i++) {
|
|
if (states[i] !== first) return 'mixed'
|
|
}
|
|
return first
|
|
}
|
|
|
|
const getRowPermissionStates = (permissions, model, actions, inherit) =>
|
|
actions
|
|
.filter((action) => modelHasPermissionAction(model, action.name))
|
|
.map((action) =>
|
|
normalizePermissionState(
|
|
getPermissionState(permissions, model.name, action.name),
|
|
inherit
|
|
)
|
|
)
|
|
|
|
const getColumnPermissionStates = (permissions, models, action, inherit) =>
|
|
models
|
|
.filter((model) => modelHasPermissionAction(model, action.name))
|
|
.map((model) =>
|
|
normalizePermissionState(
|
|
getPermissionState(permissions, model.name, action.name),
|
|
inherit
|
|
)
|
|
)
|
|
|
|
const setRowPermissionState = (permissions, model, actions, next) => {
|
|
let nextPermissions = permissions
|
|
actions.forEach((action) => {
|
|
if (modelHasPermissionAction(model, action.name)) {
|
|
nextPermissions = setPermissionState(
|
|
nextPermissions,
|
|
model.name,
|
|
action.name,
|
|
next
|
|
)
|
|
}
|
|
})
|
|
return nextPermissions
|
|
}
|
|
|
|
const setColumnPermissionState = (permissions, models, action, next) => {
|
|
let nextPermissions = permissions
|
|
models.forEach((model) => {
|
|
if (modelHasPermissionAction(model, action.name)) {
|
|
nextPermissions = setPermissionState(
|
|
nextPermissions,
|
|
model.name,
|
|
action.name,
|
|
next
|
|
)
|
|
}
|
|
})
|
|
return nextPermissions
|
|
}
|
|
|
|
const getCheckboxTitle = (state, inherit) => {
|
|
if (state === 'mixed') return 'Mixed'
|
|
if (inherit && state === null) return 'Inherit'
|
|
if (state === true) return 'Allow'
|
|
return 'Deny'
|
|
}
|
|
|
|
const normalizePath = (path) => {
|
|
if (!path || typeof path !== 'string') return ''
|
|
return path.split('?')[0].replace(/\/+$/, '')
|
|
}
|
|
|
|
const collectSidebarMeta = (items, acc = { paths: [], iconKeys: [] }) => {
|
|
items.forEach((item) => {
|
|
if (item?.type === 'divider') return
|
|
if (item.path) acc.paths.push(normalizePath(item.path))
|
|
if (item.iconKey) acc.iconKeys.push(item.iconKey)
|
|
if (item.children?.length) collectSidebarMeta(item.children, acc)
|
|
})
|
|
return acc
|
|
}
|
|
|
|
const getModelPaths = (model) => {
|
|
const paths = []
|
|
const addPath = (url) => {
|
|
if (typeof url === 'string') {
|
|
paths.push(normalizePath(url))
|
|
return
|
|
}
|
|
if (typeof url === 'function') {
|
|
try {
|
|
const sample = url('')
|
|
if (typeof sample === 'string') {
|
|
paths.push(normalizePath(sample.split('?')[0]))
|
|
}
|
|
} catch {
|
|
// ignore uncallable url builders
|
|
}
|
|
}
|
|
}
|
|
|
|
addPath(model?.url)
|
|
;(model?.actions || []).forEach((action) => addPath(action?.url))
|
|
return [...new Set(paths.filter(Boolean))]
|
|
}
|
|
|
|
const pathMatches = (modelPath, sidebarPath) =>
|
|
modelPath === sidebarPath || modelPath.startsWith(`${sidebarPath}/`)
|
|
|
|
const getModelSectionOrder = (model, section) => {
|
|
const modelPaths = getModelPaths(model)
|
|
const pathIndex = section.paths.findIndex((sidebarPath) =>
|
|
modelPaths.some((modelPath) => pathMatches(modelPath, sidebarPath))
|
|
)
|
|
if (pathIndex !== -1) return pathIndex
|
|
const iconIndex = section.iconKeys.findIndex(
|
|
(iconKey) => iconKey === model.name
|
|
)
|
|
if (iconIndex !== -1) return iconIndex
|
|
return Number.MAX_SAFE_INTEGER
|
|
}
|
|
|
|
const modelMatchesSection = (model, section) => {
|
|
const modelPaths = getModelPaths(model)
|
|
if (
|
|
modelPaths.some((modelPath) =>
|
|
section.paths.some((sidebarPath) => pathMatches(modelPath, sidebarPath))
|
|
)
|
|
) {
|
|
return true
|
|
}
|
|
if (section.iconKeys.includes(model.name)) return true
|
|
const prefix = `/dashboard/${section.key}`
|
|
return modelPaths.some((modelPath) => pathMatches(modelPath, prefix))
|
|
}
|
|
|
|
const getRecordModels = (record) => {
|
|
if (record?.isGroup) {
|
|
return (record.children || []).map((child) => child.model).filter(Boolean)
|
|
}
|
|
return record?.model ? [record.model] : []
|
|
}
|
|
|
|
const getModelsRowPermissionStates = (permissions, models, actions, inherit) =>
|
|
models.flatMap((model) =>
|
|
getRowPermissionStates(permissions, model, actions, inherit)
|
|
)
|
|
|
|
const setModelsRowPermissionState = (permissions, models, actions, next) => {
|
|
let nextPermissions = permissions
|
|
models.forEach((model) => {
|
|
nextPermissions = setRowPermissionState(
|
|
nextPermissions,
|
|
model,
|
|
actions,
|
|
next
|
|
)
|
|
})
|
|
return nextPermissions
|
|
}
|
|
|
|
const buildPermissionMatrixDataSource = (models) => {
|
|
const assigned = new Set()
|
|
const sections = getSidebarMenuSections({ includeDev: false }).map(
|
|
(section) => {
|
|
const sidebar = getSidebar(section.key)
|
|
const meta = collectSidebarMeta(section.items || [])
|
|
Object.keys(sidebar?.routeAliases || {}).forEach((path) => {
|
|
meta.paths.push(normalizePath(path))
|
|
})
|
|
return {
|
|
key: section.key,
|
|
label: section.label,
|
|
iconKey: section.iconKey,
|
|
paths: meta.paths,
|
|
iconKeys: meta.iconKeys
|
|
}
|
|
}
|
|
)
|
|
|
|
const dataSource = []
|
|
sections.forEach((section) => {
|
|
const sectionModels = models
|
|
.filter(
|
|
(model) =>
|
|
!assigned.has(model.name) && modelMatchesSection(model, section)
|
|
)
|
|
.sort(
|
|
(a, b) =>
|
|
getModelSectionOrder(a, section) - getModelSectionOrder(b, section)
|
|
)
|
|
|
|
sectionModels.forEach((model) => assigned.add(model.name))
|
|
if (sectionModels.length === 0) return
|
|
|
|
dataSource.push({
|
|
key: `section-${section.key}`,
|
|
isGroup: true,
|
|
label: section.label,
|
|
iconNode: getSidebarIconNode(section.iconKey),
|
|
children: sectionModels.map((model) => ({
|
|
key: model.name,
|
|
label: model.label,
|
|
icon: model.icon,
|
|
model
|
|
}))
|
|
})
|
|
})
|
|
|
|
const leftover = models.filter((model) => !assigned.has(model.name))
|
|
if (leftover.length > 0) {
|
|
dataSource.push({
|
|
key: 'section-other',
|
|
isGroup: true,
|
|
label: 'Other',
|
|
iconNode: null,
|
|
children: leftover.map((model) => ({
|
|
key: model.name,
|
|
label: model.label,
|
|
icon: model.icon,
|
|
model
|
|
}))
|
|
})
|
|
}
|
|
|
|
return dataSource
|
|
}
|
|
|
|
const PermissionsMatrix = ({
|
|
value,
|
|
onChange,
|
|
disabled = false,
|
|
inherit = true,
|
|
scrollHeight = DEFAULT_SCROLL_HEIGHT,
|
|
size = 'middle'
|
|
}) => {
|
|
const { isElectron } = useContext(ElectronContext)
|
|
const isMobile = useMediaQuery({ maxWidth: 768 })
|
|
const models = useMemo(() => getPermissionMatrixModels(), [])
|
|
const actions = useMemo(() => getPermissionMatrixActions(), [])
|
|
const permissions = useMemo(
|
|
() => (value && typeof value === 'object' ? value : {}),
|
|
[value]
|
|
)
|
|
const dataSource = useMemo(
|
|
() => buildPermissionMatrixDataSource(models),
|
|
[models]
|
|
)
|
|
const [expandedRowKeys, setExpandedRowKeys] = useState(null)
|
|
const [hoveredColumnKey, setHoveredColumnKey] = useState(null)
|
|
|
|
const resolvedExpandedRowKeys = useMemo(
|
|
() => expandedRowKeys ?? dataSource.map((row) => row.key),
|
|
[dataSource, expandedRowKeys]
|
|
)
|
|
|
|
const isSectionExpanded = useCallback(
|
|
(record) => resolvedExpandedRowKeys.includes(record.key),
|
|
[resolvedExpandedRowKeys]
|
|
)
|
|
|
|
const handleToggleSection = useCallback(
|
|
(record) => {
|
|
if (!record.isGroup) return
|
|
setExpandedRowKeys((current) => {
|
|
const keys = current ?? dataSource.map((row) => row.key)
|
|
return keys.includes(record.key)
|
|
? keys.filter((key) => key !== record.key)
|
|
: [...keys, record.key]
|
|
})
|
|
},
|
|
[dataSource]
|
|
)
|
|
|
|
const getColumnHoverProps = useCallback(
|
|
(columnKey, isHeader = false) => ({
|
|
onMouseEnter: () => setHoveredColumnKey(columnKey),
|
|
className:
|
|
!isHeader && hoveredColumnKey === columnKey
|
|
? 'ant-table-cell-row-hover'
|
|
: undefined
|
|
}),
|
|
[hoveredColumnKey]
|
|
)
|
|
|
|
var adjustedScrollHeight = scrollHeight
|
|
if (isMobile) {
|
|
adjustedScrollHeight = 'calc(var(--unit-100vh) - 298px)'
|
|
}
|
|
if (isElectron) {
|
|
adjustedScrollHeight = 'calc(var(--unit-100vh) - 238px)'
|
|
}
|
|
if (isMobile && isElectron) {
|
|
adjustedScrollHeight = 'calc(var(--unit-100vh) - 282px)'
|
|
}
|
|
|
|
const handleCycle = useCallback(
|
|
(modelName, actionName) => {
|
|
if (disabled || typeof onChange !== 'function') return
|
|
const current = getPermissionState(permissions, modelName, actionName)
|
|
const next = cyclePermissionState(current, inherit)
|
|
onChange(setPermissionState(permissions, modelName, actionName, next))
|
|
},
|
|
[disabled, inherit, onChange, permissions]
|
|
)
|
|
|
|
const handleCycleModels = useCallback(
|
|
(recordModels) => {
|
|
if (
|
|
disabled ||
|
|
typeof onChange !== 'function' ||
|
|
recordModels.length === 0
|
|
) {
|
|
return
|
|
}
|
|
const aggregate = getAggregatePermissionState(
|
|
getModelsRowPermissionStates(
|
|
permissions,
|
|
recordModels,
|
|
actions,
|
|
inherit
|
|
)
|
|
)
|
|
const next = cyclePermissionState(
|
|
aggregate === 'mixed' ? null : aggregate,
|
|
inherit
|
|
)
|
|
onChange(
|
|
setModelsRowPermissionState(permissions, recordModels, actions, next)
|
|
)
|
|
},
|
|
[actions, disabled, inherit, onChange, permissions]
|
|
)
|
|
|
|
const handleCycleModelsAction = useCallback(
|
|
(recordModels, action) => {
|
|
if (disabled || typeof onChange !== 'function') return
|
|
const applicable = recordModels.filter((model) =>
|
|
modelHasPermissionAction(model, action.name)
|
|
)
|
|
if (applicable.length === 0) return
|
|
const aggregate = getAggregatePermissionState(
|
|
getColumnPermissionStates(permissions, applicable, action, inherit)
|
|
)
|
|
const next = cyclePermissionState(
|
|
aggregate === 'mixed' ? null : aggregate,
|
|
inherit
|
|
)
|
|
onChange(setColumnPermissionState(permissions, applicable, action, next))
|
|
},
|
|
[disabled, inherit, onChange, permissions]
|
|
)
|
|
|
|
const handleCycleColumn = useCallback(
|
|
(action) => {
|
|
if (disabled || typeof onChange !== 'function') return
|
|
const aggregate = getAggregatePermissionState(
|
|
getColumnPermissionStates(permissions, models, action, inherit)
|
|
)
|
|
const next = cyclePermissionState(
|
|
aggregate === 'mixed' ? null : aggregate,
|
|
inherit
|
|
)
|
|
onChange(setColumnPermissionState(permissions, models, action, next))
|
|
},
|
|
[disabled, inherit, models, onChange, permissions]
|
|
)
|
|
|
|
const renderCheckbox = useCallback(
|
|
(state, onCycle) => {
|
|
const mixed = state === 'mixed'
|
|
const checked = state === true
|
|
const indeterminate = inherit && state === null
|
|
|
|
return (
|
|
<Tooltip title={getCheckboxTitle(state, inherit)} arrow={false}>
|
|
<span>
|
|
<PermissionCheckbox
|
|
checked={checked}
|
|
indeterminate={indeterminate}
|
|
mixed={mixed}
|
|
disabled={disabled}
|
|
onChange={onCycle}
|
|
/>
|
|
</span>
|
|
</Tooltip>
|
|
)
|
|
},
|
|
[disabled, inherit]
|
|
)
|
|
|
|
const columns = useMemo(() => {
|
|
const modelColumn = {
|
|
title: '',
|
|
key: 'model',
|
|
dataIndex: 'label',
|
|
fixed: isMobile ? undefined : 'left',
|
|
width: 280,
|
|
onHeaderCell: () => getColumnHoverProps('model', true),
|
|
onCell: () => getColumnHoverProps('model'),
|
|
render: (label, record) => {
|
|
const recordModels = getRecordModels(record)
|
|
const aggregate = getAggregatePermissionState(
|
|
getModelsRowPermissionStates(
|
|
permissions,
|
|
recordModels,
|
|
actions,
|
|
inherit
|
|
)
|
|
)
|
|
|
|
return (
|
|
<Flex
|
|
align='center'
|
|
gap='small'
|
|
justify='space-between'
|
|
style={{ width: '100%', paddingRight: '10px' }}
|
|
>
|
|
<Flex
|
|
align='center'
|
|
gap='small'
|
|
style={{
|
|
marginLeft: record.isGroup ? '0px' : '6px',
|
|
fontWeight: record.isGroup ? 600 : undefined
|
|
}}
|
|
>
|
|
{record.isGroup ? (
|
|
<Button
|
|
type='text'
|
|
size='small'
|
|
className='permissions-matrix-expand-button'
|
|
aria-expanded={isSectionExpanded(record)}
|
|
aria-label={
|
|
isSectionExpanded(record)
|
|
? 'Collapse section'
|
|
: 'Expand section'
|
|
}
|
|
icon={
|
|
<CaretRightOutlined
|
|
className={
|
|
isSectionExpanded(record)
|
|
? 'permissions-matrix-expand-icon permissions-matrix-expand-icon-open'
|
|
: 'permissions-matrix-expand-icon'
|
|
}
|
|
/>
|
|
}
|
|
onClick={(event) => {
|
|
event.stopPropagation()
|
|
handleToggleSection(record)
|
|
}}
|
|
/>
|
|
) : (
|
|
<span className='permissions-matrix-expand-spacer' />
|
|
)}
|
|
{record.iconNode
|
|
? record.iconNode
|
|
: record.icon
|
|
? createElement(record.icon)
|
|
: null}
|
|
{label}
|
|
</Flex>
|
|
<div style={{ paddingBottom: '1px' }}>
|
|
{renderCheckbox(aggregate, () => {
|
|
handleCycleModels(recordModels)
|
|
})}
|
|
</div>
|
|
</Flex>
|
|
)
|
|
}
|
|
}
|
|
|
|
const actionColumns = actions.map((action) => {
|
|
const columnAggregate = getAggregatePermissionState(
|
|
getColumnPermissionStates(permissions, models, action, inherit)
|
|
)
|
|
|
|
return {
|
|
title: (
|
|
<Flex
|
|
vertical
|
|
align='center'
|
|
gap={12}
|
|
justify='flex-end'
|
|
style={{ paddingTop: '8px' }}
|
|
>
|
|
<span className='permissions-matrix-header'>{action.label}</span>
|
|
<div style={{ paddingBottom: '2px' }}>
|
|
{renderCheckbox(columnAggregate, () => {
|
|
handleCycleColumn(action)
|
|
})}
|
|
</div>
|
|
</Flex>
|
|
),
|
|
key: action.name,
|
|
width: 48,
|
|
align: 'center',
|
|
onHeaderCell: () => getColumnHoverProps(action.name, true),
|
|
onCell: () => getColumnHoverProps(action.name),
|
|
render: (_, record) => {
|
|
const recordModels = getRecordModels(record)
|
|
const applicable = recordModels.filter((model) =>
|
|
modelHasPermissionAction(model, action.name)
|
|
)
|
|
if (applicable.length === 0) return null
|
|
|
|
if (!record.isGroup && applicable.length === 1) {
|
|
const state = getPermissionState(
|
|
permissions,
|
|
applicable[0].name,
|
|
action.name
|
|
)
|
|
return renderCheckbox(state, () => {
|
|
handleCycle(applicable[0].name, action.name)
|
|
})
|
|
}
|
|
|
|
const state = getAggregatePermissionState(
|
|
getColumnPermissionStates(permissions, applicable, action, inherit)
|
|
)
|
|
return renderCheckbox(state, () => {
|
|
handleCycleModelsAction(applicable, action)
|
|
})
|
|
}
|
|
}
|
|
})
|
|
|
|
return [modelColumn, ...actionColumns]
|
|
}, [
|
|
actions,
|
|
getColumnHoverProps,
|
|
handleCycle,
|
|
handleCycleColumn,
|
|
handleCycleModels,
|
|
handleCycleModelsAction,
|
|
handleToggleSection,
|
|
inherit,
|
|
isMobile,
|
|
isSectionExpanded,
|
|
models,
|
|
permissions,
|
|
renderCheckbox
|
|
])
|
|
|
|
return (
|
|
<div onMouseLeave={() => setHoveredColumnKey(null)}>
|
|
<Table
|
|
className='dashboard-table permissions-matrix-table'
|
|
dataSource={dataSource}
|
|
columns={columns}
|
|
pagination={false}
|
|
rowKey='key'
|
|
bordered={true}
|
|
size={size}
|
|
scroll={{ x: 'max-content', y: adjustedScrollHeight }}
|
|
rowClassName={(record) =>
|
|
record.isGroup ? 'permissions-matrix-group-row' : ''
|
|
}
|
|
expandable={{
|
|
expandedRowKeys: resolvedExpandedRowKeys,
|
|
onExpandedRowsChange: setExpandedRowKeys,
|
|
indentSize: 0,
|
|
expandIcon: () => null
|
|
}}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
PermissionsMatrix.propTypes = {
|
|
value: PropTypes.object,
|
|
onChange: PropTypes.func,
|
|
disabled: PropTypes.bool,
|
|
inherit: PropTypes.bool,
|
|
scrollHeight: PropTypes.string,
|
|
size: PropTypes.string
|
|
}
|
|
|
|
export default PermissionsMatrix
|