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 ( ) }, [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 ( {record.isGroup ? (