diff --git a/src/authz-module/components/PermissionTable.tsx b/src/authz-module/components/PermissionTable.tsx new file mode 100644 index 0000000..26a90ef --- /dev/null +++ b/src/authz-module/components/PermissionTable.tsx @@ -0,0 +1,52 @@ +import { Check, Close } from '@openedx/paragon/icons'; +import { Card, Icon } from '@openedx/paragon'; +import { PermissionsResourceGrouped, Role } from '@src/types'; +import { actionsDictionary } from './RoleCard/constants'; +import ResourceTooltip from './ResourceTooltip'; + +type PermissionTableProps = { + roles: Role[]; + permissionsTable: PermissionsResourceGrouped[]; +}; + +const PermissionTable = ({ permissionsTable, roles }: PermissionTableProps) => ( + + + + + + ))} + + + + {permissionsTable.map(resourceGroup => ( + <> + + + + {resourceGroup.permissions.map(permission => ( + + + {roles.map(role => ( + + ))} + + ))} + + ))} + +
{role.name}
+ {resourceGroup.label} + +
+ + {permission.label} + + {permission.roles[role.name] ? : } +
+
+); + +export default PermissionTable; diff --git a/src/authz-module/components/ResourceTooltip.tsx b/src/authz-module/components/ResourceTooltip.tsx new file mode 100644 index 0000000..10fa4d2 --- /dev/null +++ b/src/authz-module/components/ResourceTooltip.tsx @@ -0,0 +1,31 @@ +import { Icon, OverlayTrigger, Popover } from '@openedx/paragon'; +import { Info } from '@openedx/paragon/icons'; +import { PermissionsResourceGrouped, RoleResourceGroup } from '@src/types'; + +type ResourceTooltipProps = { + resourceGroup: PermissionsResourceGrouped | RoleResourceGroup; +}; + +const ResourceTooltip = ({ resourceGroup }:ResourceTooltipProps) => ( + + +

{resourceGroup.label}

+

{resourceGroup.description}

+ +
+ + )} + > + +
+); + +export default ResourceTooltip; diff --git a/src/authz-module/components/RoleCard/PermissionsRow.tsx b/src/authz-module/components/RoleCard/PermissionsRow.tsx index aad0ebb..09f3a16 100644 --- a/src/authz-module/components/RoleCard/PermissionsRow.tsx +++ b/src/authz-module/components/RoleCard/PermissionsRow.tsx @@ -2,38 +2,34 @@ import { ComponentType } from 'react'; import { Chip, Col, Row, } from '@openedx/paragon'; +import { RoleResourceGroup } from '@src/types'; import { actionsDictionary, ActionKey } from './constants'; +import ResourceTooltip from '../ResourceTooltip'; -interface Action { - key: string; - label?: string; - disabled?: boolean; -} +type PermissionRowProps = { + resource: RoleResourceGroup; +}; -interface PermissionRowProps { - resourceLabel: string; - actions: Action[]; -} - -const PermissionRow = ({ resourceLabel, actions }: PermissionRowProps) => ( +const PermissionRow = ({ resource }: PermissionRowProps) => ( - {resourceLabel} + {resource.label} +
- {actions.map((action, index) => ( + {resource.permissions.map((action, index) => ( <> {action.label} - {(index === actions.length - 1) ? null + {(index === resource.permissions.length - 1) ? null : (
)} ))} diff --git a/src/authz-module/components/RoleCard/index.test.tsx b/src/authz-module/components/RoleCard/index.test.tsx index 2cc2290..6afc397 100644 --- a/src/authz-module/components/RoleCard/index.test.tsx +++ b/src/authz-module/components/RoleCard/index.test.tsx @@ -22,13 +22,17 @@ describe('RoleCard', () => { description: 'Can manage everything', showDelete: true, userCounter: 2, - permissions: [ + permissionsByResource: [ { key: 'library', label: 'Library Resource', - actions: [ - { key: 'view', label: 'View' }, - { key: 'manage', label: 'Manage', disabled: true }, + permissions: [ + { + key: 'view', label: 'View', actionKey: 'view', disabled: false, + }, + { + key: 'manage', label: 'Manage', actionKey: 'manage', disabled: true, + }, ], }, ], @@ -83,7 +87,7 @@ describe('RoleCard', () => { }); it('handles empty permissions gracefully', () => { - renderWrapper(); + renderWrapper(); expect(screen.queryByText('Library Resource')).not.toBeInTheDocument(); }); }); diff --git a/src/authz-module/components/RoleCard/index.tsx b/src/authz-module/components/RoleCard/index.tsx index 1c15466..350aed6 100644 --- a/src/authz-module/components/RoleCard/index.tsx +++ b/src/authz-module/components/RoleCard/index.tsx @@ -15,7 +15,7 @@ interface RoleCardProps extends CardTitleProps { objectName?: string | null; description: string; showDelete?: boolean; - permissions: any[]; + permissionsByResource: any[]; } const CardTitle = ({ title, userCounter = null }: CardTitleProps) => ( @@ -31,7 +31,7 @@ const CardTitle = ({ title, userCounter = null }: CardTitleProps) => ( ); const RoleCard = ({ - title, objectName, description, showDelete, permissions, userCounter, + title, objectName, description, showDelete, permissionsByResource, userCounter, }: RoleCardProps) => { const intl = useIntl(); @@ -51,13 +51,11 @@ const RoleCard = ({ title={intl.formatMessage(messages['authz.permissions.title'])} > - {permissions.map(({ key, label, actions }) => ( + {permissionsByResource.map((resourceGroup) => ( - ))} diff --git a/src/authz-module/index.scss b/src/authz-module/index.scss index 790adc8..907cd6c 100644 --- a/src/authz-module/index.scss +++ b/src/authz-module/index.scss @@ -34,6 +34,12 @@ height: var(--pgn-size-icon-xs); } } + + .permission-table { + td { + line-height: 24px; + } + } } diff --git a/src/authz-module/libraries-manager/LibrariesTeamManager.test.tsx b/src/authz-module/libraries-manager/LibrariesTeamManager.test.tsx index be558e3..9e5ffa4 100644 --- a/src/authz-module/libraries-manager/LibrariesTeamManager.test.tsx +++ b/src/authz-module/libraries-manager/LibrariesTeamManager.test.tsx @@ -1,4 +1,4 @@ -import { screen } from '@testing-library/react'; +import { screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { renderWrapper } from '@src/setupTest'; import { initializeMockApp } from '@edx/frontend-platform/testing'; @@ -32,11 +32,15 @@ jest.mock('./components/AddNewTeamMemberModal', () => ({ jest.mock('../components/RoleCard', () => ({ __esModule: true, - default: ({ title, description, permissions }: { title: string, description: string, permissions: any[] }) => ( + default: ({ title, description, permissionsByResource }: { + title: string, + description: string, + permissionsByResource: any[] + }) => (
{title}
{description}
-
{permissions.length} permissions
+
{permissionsByResource.length} permissions
), })); @@ -63,9 +67,9 @@ describe('LibrariesTeamManager', () => { ], permissions: [ { key: 'view_library', label: 'view', resource: 'library' }, - { key: 'edit_library', name: 'edit', resource: 'library' }, + { key: 'edit_library', label: 'edit', resource: 'library' }, ], - resources: [{ key: 'library', displayName: 'Library' }], + resources: [{ key: 'library', label: 'Library' }], canManageTeam: true, }); @@ -106,10 +110,28 @@ describe('LibrariesTeamManager', () => { await user.click(rolesTab); const roleCards = await screen.findAllByTestId('role-card'); - - expect(roleCards.length).toBeGreaterThan(0); - expect(screen.getByText('Instructor')).toBeInTheDocument(); + const rolesScope = within(roleCards[0]); + expect(roleCards.length).toBe(1); + expect(rolesScope.getByText('Instructor')).toBeInTheDocument(); expect(screen.getByText(/Can manage content/i)).toBeInTheDocument(); expect(screen.getByText(/1 permissions/i)).toBeInTheDocument(); }); + + it('renders role matrix when "Permissions" tab is selected', async () => { + const user = userEvent.setup(); + + renderWrapper(); + + // Click on "Permissions" tab + const permissionsTab = await screen.findByRole('tab', { name: /permissions/i }); + await user.click(permissionsTab); + + const tablePermissionMatrix = await screen.getByRole('table'); + const matrixScope = within(tablePermissionMatrix); + + expect(matrixScope.getByText('Library')).toBeInTheDocument(); + expect(matrixScope.getByText('Instructor')).toBeInTheDocument(); + expect(matrixScope.getByText('edit')).toBeInTheDocument(); + expect(matrixScope.getByText('view')).toBeInTheDocument(); + }); }); diff --git a/src/authz-module/libraries-manager/LibrariesTeamManager.tsx b/src/authz-module/libraries-manager/LibrariesTeamManager.tsx index dd9fc2c..11dcd2d 100644 --- a/src/authz-module/libraries-manager/LibrariesTeamManager.tsx +++ b/src/authz-module/libraries-manager/LibrariesTeamManager.tsx @@ -8,9 +8,10 @@ import { useLocation } from 'react-router-dom'; import TeamTable from './components/TeamTable'; import AuthZLayout from '../components/AuthZLayout'; import RoleCard from '../components/RoleCard'; +import PermissionTable from '../components/PermissionTable'; import { useLibraryAuthZ } from './context'; import { AddNewTeamMemberTrigger } from './components/AddNewTeamMemberModal'; -import { buildPermissionsByRoleMatrix } from './utils'; +import { buildPermissionMatrixByResource, buildPermissionMatrixByRole } from './utils'; import messages from './messages'; @@ -23,12 +24,18 @@ const LibrariesTeamManager = () => { const { data: library } = useLibrary(libraryId); const rootBradecrumb = intl.formatMessage(messages['library.authz.breadcrumb.root']) || ''; const pageTitle = intl.formatMessage(messages['library.authz.manage.page.title']); - const libraryRoles = useMemo(() => roles.map(role => ({ - ...role, - permissions: buildPermissionsByRoleMatrix({ - rolePermissions: role.permissions, permissions, resources, intl, - }), - })), [roles, permissions, resources, intl]); + + const [libraryPermissionsByRole, libraryPermissionsByResource] = useMemo(() => { + if (!roles && !permissions && !resources) { return [null, null]; } + const permissionsByRole = buildPermissionMatrixByRole({ + roles, permissions, resources, intl, + }); + const permissionsByResource = buildPermissionMatrixByResource({ + roles, permissions, resources, intl, + }); + + return [permissionsByRole, permissionsByResource]; + }, [roles, permissions, resources, intl]); return (
@@ -54,20 +61,23 @@ const LibrariesTeamManager = () => { - {!libraryRoles ? : null} - {libraryRoles && libraryRoles.map(role => ( - - ))} + {!libraryPermissionsByRole ? + : libraryPermissionsByRole.map(role => ( + + ))} - Permissions tab. + + {!libraryPermissionsByResource ? + : } + diff --git a/src/authz-module/libraries-manager/LibrariesUserManager.tsx b/src/authz-module/libraries-manager/LibrariesUserManager.tsx index 6883e81..94d9697 100644 --- a/src/authz-module/libraries-manager/LibrariesUserManager.tsx +++ b/src/authz-module/libraries-manager/LibrariesUserManager.tsx @@ -8,7 +8,7 @@ import { useLibraryAuthZ } from './context'; import RoleCard from '../components/RoleCard'; import { AssignNewRoleTrigger } from './components/AssignNewRoleModal'; import { useLibrary, useTeamMembers } from '../data/hooks'; -import { buildPermissionsByRoleMatrix } from './utils'; +import { buildPermissionMatrixByRole } from './utils'; import messages from './messages'; @@ -25,14 +25,10 @@ const LibrariesUserManager = () => { const { data: teamMembers, isLoading } = useTeamMembers(libraryId); const user = teamMembers?.find(member => member.username === username); const userRoles = useMemo(() => { - const assignedRoles = roles.filter(role => user?.roles.includes(role.role)) - .map(role => ({ - ...role, - permissions: buildPermissionsByRoleMatrix({ - rolePermissions: role.permissions, permissions, resources, intl, - }), - })); - return assignedRoles; + const assignedRoles = roles.filter(role => user?.roles.includes(role.role)); + return buildPermissionMatrixByRole({ + roles: assignedRoles, permissions, resources, intl, + }); }, [roles, user?.roles, permissions, resources, intl]); return ( @@ -60,7 +56,7 @@ const LibrariesUserManager = () => { objectName={library.title} description={role.description} showDelete - permissions={role.permissions as any[]} + permissionsByResource={role.resources as any[]} /> ))} diff --git a/src/authz-module/libraries-manager/constants.ts b/src/authz-module/libraries-manager/constants.ts index 60fdf45..858c32f 100644 --- a/src/authz-module/libraries-manager/constants.ts +++ b/src/authz-module/libraries-manager/constants.ts @@ -17,9 +17,9 @@ export const libraryResourceTypes: ResourceMetadata[] = [ ]; export const libraryPermissions: PermissionMetadata[] = [ - { key: 'view_library', resource: 'library', description: 'View content, search, filter, and sort within the library.' }, { key: 'delete_library', resource: 'library', description: 'Allows the user to delete the library and all its contents.' }, { key: 'manage_library_tags', resource: 'library', description: 'Add or remove tags from content.' }, + { key: 'view_library', resource: 'library', description: 'View content, search, filter, and sort within the library.' }, { key: 'edit_library_content', resource: 'library_content', description: 'Edit content in draft mode' }, { key: 'publish_library_content', resource: 'library_content', description: 'Publish content, making it available for reuse' }, diff --git a/src/authz-module/libraries-manager/utils.test.ts b/src/authz-module/libraries-manager/utils.test.ts index c3c4a67..07d6600 100644 --- a/src/authz-module/libraries-manager/utils.test.ts +++ b/src/authz-module/libraries-manager/utils.test.ts @@ -1,23 +1,107 @@ -import { buildPermissionsByRoleMatrix } from './utils'; +import { buildPermissionMatrixByResource, buildPermissionMatrixByRole } from './utils'; -describe('buildPermissionsByRoleMatrix', () => { - it('returns permissions matrix for given role', () => { - const rolePermissions = ['create_library']; - const permissions = [ - { key: 'create_library', resource: 'library', label: 'Create Library' }, - { key: 'edit_library', resource: 'library', label: 'Edit Library' }, - ]; - const resources = [ - { key: 'library', label: 'Library', description: '' }, - ]; +const intl = { formatMessage: jest.fn((msg: any) => msg) }; - const intl = { formatMessage: jest.fn((msg: any) => msg.defaultMessage) }; - const matrix = buildPermissionsByRoleMatrix({ - rolePermissions, permissions, resources, intl, - }) as Array<{ key: string; actions: Array<{ disabled: boolean }> }>; - expect(matrix[0].key).toBe('library'); - expect(matrix[0].actions.length).toBe(2); - expect(matrix[0].actions[0].disabled).toBe(false); - expect(matrix[0].actions[1].disabled).toBe(true); +const permissions = [ + { + key: 'create_library', resource: 'library', label: 'Create Library', description: '', + }, + { + key: 'edit_library', resource: 'library', label: 'Edit Library', description: '', + }, +]; +const resources = [ + { key: 'library', label: 'Library', description: '' }, +]; +const roles = [ + { + name: 'admin', permissions: ['create_library', 'edit_library'], userCount: 2, role: 'admin', description: '', + }, + { + name: 'editor', permissions: ['edit_library'], userCount: 2, role: 'editor', description: '', + }, + { + name: 'guest', permissions: [], userCount: 2, role: 'guest', description: '', + }, +]; + +describe('buildPermissionsMatrix', () => { + it('returns permissions a matrix of given roles', () => { + const matrix = buildPermissionMatrixByRole({ + roles, permissions, resources, intl, + }); + expect(matrix.length).toBe(3); + expect(matrix[1]).toEqual({ + name: 'editor', + userCount: 2, + role: 'editor', + description: '', + permissions: ['edit_library'], + resources: [ + { + key: 'library', + label: 'Library', + description: '', + permissions: [ + { + actionKey: 'create', + description: '', + disabled: true, + key: 'create_library', + label: 'Create Library', + resource: 'library', + }, + { + key: 'edit_library', + resource: 'library', + label: 'Edit Library', + description: '', + actionKey: 'edit', + disabled: false, + }, + ], + }, + ], + }); + }); + + it('should build permission matrix grouped by resources with role access mapped', () => { + const matrix = buildPermissionMatrixByResource({ + roles, permissions, resources, intl, + }); + + expect(matrix).toEqual([ + { + key: 'library', + label: 'Library', + description: '', + permissions: [ + { + key: 'create_library', + actionKey: 'create', + label: 'Create Library', + resource: 'library', + description: '', + roles: { + admin: true, + editor: false, + guest: false, + }, + }, + { + key: 'edit_library', + actionKey: 'edit', + resource: 'library', + label: 'Edit Library', + description: '', + roles: { + admin: true, + editor: true, + guest: false, + }, + }, + ], + }, + ]); }); }); diff --git a/src/authz-module/libraries-manager/utils.ts b/src/authz-module/libraries-manager/utils.ts index bf27f60..17ff64c 100644 --- a/src/authz-module/libraries-manager/utils.ts +++ b/src/authz-module/libraries-manager/utils.ts @@ -1,45 +1,157 @@ +import { IntlShape } from '@edx/frontend-platform/i18n'; import { actionKeys } from '@src/authz-module/components/RoleCard/constants'; +import { + EnrichedPermission, PermissionMetadata, PermissionsResourceGrouped, + PermissionsRoleGrouped, ResourceMetadata, Role, RoleResourceGroup, +} from '@src/types'; import actionMessages from '../components/RoleCard/messages'; /** - * Builds a permission matrix for a role. + * Derives the localized label and action key for a given permission. + * + * This function enhance the permissions metadata mapping the key to a list of prefefined actions + * to add visual elemments (icons) and a localized label. + * If a label is already defined in the permission metadata, that is returned as-is. + * + * Special handling is applied for action keys like `'tag'` and `'team'`, which are + * normalized to `'manage'` and given a custom resource string for translation. + * + * @param permission - The permission metadata, typically containing a key and optional label. + * @param intl - The `IntlShape` object used to generate localized labels. + * + * @returns An object containing: + * - `label`: The human-readable, localized label for the permission. + * - `actionKey`: A string representing icon to be displayed (e.g., `'Read'`, `'Edit'`), or '' if not matched. + */ +const getPermissionMetadata = (permission: PermissionMetadata, intl: IntlShape): EnrichedPermission => { + const actionKey = actionKeys.find(action => permission.key.includes(action)) || ''; + let messageKey = `authz.permissions.actions.${actionKey}`; + let messageResource = ''; + + if (actionKey === 'tag' || actionKey === 'team') { + messageKey = 'authz.permissions.actions.manage'; + messageResource = actionKey === 'tag' ? 'Tags' : ''; + } + + const messageDescriptor = actionMessages[messageKey]; + const label = permission.label || (messageDescriptor + ? intl.formatMessage(messageDescriptor, { resource: messageResource }) + : permission.key); + + return { ...permission, label, actionKey }; +}; + +type BuildPermissionsMatrixProps = { + roles: Role[]; + permissions: PermissionMetadata[]; + resources: ResourceMetadata[]; + intl: IntlShape; +}; + +/** + * Builds a permission matrix from the given roles, permissions, and resources. + * + * The matrix groups permissions under their respective resources and maps + * each permission to which roles have access to it. + * + * @param roles - List of roles, each containing a list of granted permission keys. + * @param permissions - Metadata describing each permission, including its associated resource. + * @param resources - List of resource metadata used to group permissions. + * @param intl - The internationalization object used to localize permission labels. + * + * @returns A permission matrix grouped by resource, with role mappings per permission. + */ +const buildPermissionMatrixByResource = ({ + roles, permissions, resources, intl, +}: BuildPermissionsMatrixProps): PermissionsResourceGrouped[] => { + const enrichedPermissions = permissions.reduce((acc, perm) => { + acc[perm.key] = getPermissionMetadata(perm, intl); + return acc; + }, {} as Record); + + const permissionsByResource = permissions.reduce>((acc, perm) => { + if (!acc[perm.resource]) { acc[perm.resource] = []; } + acc[perm.resource].push(perm); + return acc; + }, {}); + + return resources.map(resource => { + const perms = permissionsByResource[resource.key] || []; + + const permissionRows = perms.map(permission => { + const enriched = enrichedPermissions[permission.key]; + const rolesMap = roles.reduce((acc, role) => { + acc[role.name] = role.permissions.includes(permission.key); + return acc; + }, {} as Record); + + return { + ...enriched, + roles: rolesMap, + }; + }); + + return { + ...resource, + permissions: permissionRows, + }; + }); +}; + +/** + * Builds a permission matrix for grouped by roles. * * Builds a permission matrix grouped by resource, mapping each action to its display label * and enabled/disabled state based on the role's allowed permissions. * - * @param rolePermissions - Array of permission keys allowed for the current role. + * @param roles - Array of roles metadata. * @param permissions - Permissions metadata. * @param resources - Resources metadata. * @param intl - the i18n function to enable label translations. - * @returns An array of permission groupings by resource with action-level details. + * @returns An array of permission groupings by role and resource with action-level details. */ -const buildPermissionsByRoleMatrix = ({ - rolePermissions, permissions, resources, intl, -}) => { - const permissionsMatrix = {}; - const allowedPermissions = new Set(rolePermissions); - - permissions.forEach((permission) => { - const resourceLabel = resources.find(r => r.key === permission.resource)?.label || permission.resource; - const actionKey = actionKeys.find(action => permission.key.includes(action)); - let messageKey = `authz.permissions.actions.${actionKey}`; - let messageResource = ''; - - permissionsMatrix[permission.resource] = permissionsMatrix[permission.resource] - || { key: permission.resource, label: resourceLabel, actions: [] }; - - if (actionKey === 'tag' || actionKey === 'team') { - messageKey = 'authz.permissions.actions.manage'; - messageResource = actionKey === 'tag' ? 'Tags' : messageResource; - } - - permissionsMatrix[permission.resource].actions.push({ - key: actionKey, - label: permission.label || intl.formatMessage(actionMessages[messageKey], { resource: messageResource }), - disabled: !allowedPermissions.has(permission.key), +const buildPermissionMatrixByRole = ({ + roles, permissions, resources, intl, +}: BuildPermissionsMatrixProps): PermissionsRoleGrouped[] => { + const enrichedPermissions = permissions.reduce((acc, perm) => { + acc[perm.key] = getPermissionMetadata(perm, intl); + return acc; + }, {} as Record); + + return roles.map(role => { + const allowed = new Set(role.permissions); + const permissionsGroupedByResource: Record = {}; + + permissions.forEach(permission => { + const enriched = enrichedPermissions[permission.key]; + const { resource } = permission; + + if (!enriched.actionKey) { return; } + + if (!permissionsGroupedByResource[resource]) { + const resourceInfo = resources.find(r => r.key === resource); + if (!resourceInfo) { return; } + + permissionsGroupedByResource[resource] = { + key: resourceInfo.key, + label: resourceInfo.label, + description: resourceInfo.description, + permissions: [], + }; + } + + permissionsGroupedByResource[resource].permissions.push({ + ...enriched, + description: permission.description, + disabled: !allowed.has(permission.key), + }); }); + + return { + ...role, + resources: Object.values(permissionsGroupedByResource), + }; }); - return Object.values(permissionsMatrix); }; -export { buildPermissionsByRoleMatrix }; +export { buildPermissionMatrixByResource, buildPermissionMatrixByRole }; diff --git a/src/types.ts b/src/types.ts index 3a7ebdd..e1e39eb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -45,6 +45,35 @@ export type PermissionMetadata = { description?: string; }; +// Permissions Matrix + +export type EnrichedPermission = PermissionMetadata & { + actionKey: string; +}; + +export type PermissionWithRoles = EnrichedPermission & { + roles: Record; +}; + +export type PermissionsResourceGrouped = ResourceMetadata & { + permissions: PermissionWithRoles[]; +}; + +export type RolePermission = EnrichedPermission & { + disabled: boolean; +}; + +export type RoleResourceGroup = { + key: string; + label: string; + description: string; + permissions: RolePermission[]; +}; + +export type PermissionsRoleGrouped = Role & { + resources: RoleResourceGroup[]; +}; + // Paragon table type export interface TableCellValue { row: {