From 00c1b25b27bfb653768ff6ba73df6f47c8586087 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Fri, 3 Oct 2025 02:02:08 +1000 Subject: [PATCH 1/8] feat: create permissions tab --- .../components/PermissionTable.tsx | 44 +++++++ .../LibrariesTeamManager.tsx | 14 ++- src/authz-module/libraries-manager/utils.ts | 107 ++++++++++++++++-- 3 files changed, 151 insertions(+), 14 deletions(-) create mode 100644 src/authz-module/components/PermissionTable.tsx diff --git a/src/authz-module/components/PermissionTable.tsx b/src/authz-module/components/PermissionTable.tsx new file mode 100644 index 00000000..80b1f299 --- /dev/null +++ b/src/authz-module/components/PermissionTable.tsx @@ -0,0 +1,44 @@ +import { Check, Close } from "@openedx/paragon/icons"; +import { actionsDictionary } from "./RoleCard/constants"; +import { Icon } from "@openedx/paragon"; + +const PermissionTable = ({ permissionsTable, roles }) => ( + + + + + {roles.map(role => ( + + ))} + + + + {permissionsTable.map(resourceGroup => ( + <> + + + + { + resourceGroup.permissions.map(permission => ( + + + {roles.map(role => ( + + ))} + + )) + } + + ))} + +
{role.name}
+ {resourceGroup.resourceLabel} +
{ + } + {permission.label} + + {permission.roles[role.name] ? : } +
+); + +export default PermissionTable; \ No newline at end of file diff --git a/src/authz-module/libraries-manager/LibrariesTeamManager.tsx b/src/authz-module/libraries-manager/LibrariesTeamManager.tsx index dd9fc2c5..2ad0dea9 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 { buildPermissionMatrix, buildPermissionsByRoleMatrix } from './utils'; import messages from './messages'; @@ -30,6 +31,11 @@ const LibrariesTeamManager = () => { }), })), [roles, permissions, resources, intl]); + const permissionsTable = useMemo(() => { + if (!roles || !permissions || !resources) return []; + return buildPermissionMatrix(roles, permissions, resources, intl); + }, [roles, permissions, resources]); + return (
{ - Permissions tab. + + + -
+ ); }; diff --git a/src/authz-module/libraries-manager/utils.ts b/src/authz-module/libraries-manager/utils.ts index bf27f606..2793cc0c 100644 --- a/src/authz-module/libraries-manager/utils.ts +++ b/src/authz-module/libraries-manager/utils.ts @@ -1,5 +1,26 @@ +import { IntlShape } from '@edx/frontend-platform/i18n'; import { actionKeys } from '@src/authz-module/components/RoleCard/constants'; import actionMessages from '../components/RoleCard/messages'; +import { PermissionMetadata, ResourceMetadata, Role } from 'types'; + +function getPermissionMetadata( + permission: PermissionMetadata, + intl: IntlShape, +): { label: string; actionKey: string | undefined } { + 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 label = permission.label || intl.formatMessage(actionMessages[messageKey], { resource: messageResource }); + + return { label, actionKey }; +} + /** * Builds a permission matrix for a role. @@ -14,32 +35,96 @@ import actionMessages from '../components/RoleCard/messages'; * @returns An array of permission groupings by resource with action-level details. */ const buildPermissionsByRoleMatrix = ({ - rolePermissions, permissions, resources, intl, + 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 = ''; + const resourceLabel = + resources.find((r) => r.key === permission.resource)?.label || + permission.resource; + + const { label, actionKey } = getPermissionMetadata(permission, intl); - permissionsMatrix[permission.resource] = permissionsMatrix[permission.resource] - || { key: permission.resource, label: resourceLabel, actions: [] }; + if (!actionKey) return; // Skip unknown actions - if (actionKey === 'tag' || actionKey === 'team') { - messageKey = 'authz.permissions.actions.manage'; - messageResource = actionKey === 'tag' ? 'Tags' : messageResource; + // Initialize resource group if not already present + if (!permissionsMatrix[permission.resource]) { + permissionsMatrix[permission.resource] = { + key: permission.resource, + label: resourceLabel, + actions: [], + }; } permissionsMatrix[permission.resource].actions.push({ key: actionKey, - label: permission.label || intl.formatMessage(actionMessages[messageKey], { resource: messageResource }), + label, disabled: !allowedPermissions.has(permission.key), }); }); + return Object.values(permissionsMatrix); }; + + + +type PermissionMatrix = { + resource: string; + resourceLabel: string; + permissions: { + key: string; + label: string; + roles: Record; + }[]; +}[]; + +export function buildPermissionMatrix( + roles: Role[], + permissions: PermissionMetadata[], + resources: ResourceMetadata[], + intl: IntlShape, +): PermissionMatrix { + const permissionsByResource = permissions.reduce>((acc, perm) => { + if (!acc[perm.resource]) acc[perm.resource] = []; + acc[perm.resource].push(perm); + return acc; + }, {}); + + const matrix: PermissionMatrix = resources.map(resource => { + const resourcePermissions = permissionsByResource[resource.key] || []; + + const permissionRows = resourcePermissions.map(permission => { + const rolesMap: Record = {}; + + roles.forEach(role => { + rolesMap[role.name] = role.permissions.includes(permission.key); + }); + + const { label, actionKey } = getPermissionMetadata(permission, intl); + + return { + key: permission.key, + actionKey, // Important for icon mapping + label, + roles: rolesMap, + }; + }); + + return { + resource: resource.key, + resourceLabel: resource.label, + permissions: permissionRows, + }; + }); + + return matrix; +} + + export { buildPermissionsByRoleMatrix }; From 431244060adb30bdfc2da9ea11a49654f551a357 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Tue, 14 Oct 2025 13:56:06 +1100 Subject: [PATCH 2/8] style: add tsdocs to getPermissionMetadata and buildPermissionMatrix --- src/authz-module/libraries-manager/utils.ts | 31 +++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/authz-module/libraries-manager/utils.ts b/src/authz-module/libraries-manager/utils.ts index 2793cc0c..ae998c5b 100644 --- a/src/authz-module/libraries-manager/utils.ts +++ b/src/authz-module/libraries-manager/utils.ts @@ -3,6 +3,23 @@ import { actionKeys } from '@src/authz-module/components/RoleCard/constants'; import actionMessages from '../components/RoleCard/messages'; import { PermissionMetadata, ResourceMetadata, Role } from 'types'; +/** + * Derives the localized label and action key for a given permission. + * + * This function attempts to extract a known `actionKey` from the permission's key, + * and uses that to construct an internationalized label using `intl.formatMessage`. + * 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 `undefined` if not matched. + */ function getPermissionMetadata( permission: PermissionMetadata, intl: IntlShape, @@ -84,6 +101,20 @@ type PermissionMatrix = { }[]; }[]; +/** + * 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. + */ + export function buildPermissionMatrix( roles: Role[], permissions: PermissionMetadata[], From 351e1722efa555f9567d34e445eccffc9769686d Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Tue, 14 Oct 2025 20:13:07 +1100 Subject: [PATCH 3/8] style: fix padding on rows table --- src/authz-module/components/PermissionTable.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/authz-module/components/PermissionTable.tsx b/src/authz-module/components/PermissionTable.tsx index 80b1f299..5a38502d 100644 --- a/src/authz-module/components/PermissionTable.tsx +++ b/src/authz-module/components/PermissionTable.tsx @@ -8,22 +8,22 @@ const PermissionTable = ({ permissionsTable, roles }) => ( {roles.map(role => ( - {role.name} + {role.name} ))} {permissionsTable.map(resourceGroup => ( <> - - + + {resourceGroup.resourceLabel} { resourceGroup.permissions.map(permission => ( - { + { } {permission.label} From a144f44757436f8b1796692f03d2ad273d2222db Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Tue, 14 Oct 2025 22:26:22 +1100 Subject: [PATCH 4/8] feat: add skeleton and improve testing --- .../components/PermissionTable.tsx | 27 +++++--- .../LibrariesTeamManager.test.tsx | 30 +++++++-- .../LibrariesTeamManager.tsx | 9 +-- .../libraries-manager/utils.test.ts | 65 ++++++++++++++++--- src/authz-module/libraries-manager/utils.ts | 26 ++++---- 5 files changed, 113 insertions(+), 44 deletions(-) diff --git a/src/authz-module/components/PermissionTable.tsx b/src/authz-module/components/PermissionTable.tsx index 5a38502d..a83bda09 100644 --- a/src/authz-module/components/PermissionTable.tsx +++ b/src/authz-module/components/PermissionTable.tsx @@ -1,12 +1,19 @@ -import { Check, Close } from "@openedx/paragon/icons"; -import { actionsDictionary } from "./RoleCard/constants"; -import { Icon } from "@openedx/paragon"; +import { Check, Close } from '@openedx/paragon/icons'; +import { Icon } from '@openedx/paragon'; +import { Role } from '@src/types'; +import { PermissionMatrix } from '@src/authz-module/libraries-manager/utils'; +import { actionsDictionary } from './RoleCard/constants'; -const PermissionTable = ({ permissionsTable, roles }) => ( - +type PermissionTableProps = { + roles: Role[]; + permissionsTable: PermissionMatrix; +}; + +const PermissionTable = ({ permissionsTable, roles }: PermissionTableProps) => ( +
- + ))} @@ -22,9 +29,9 @@ const PermissionTable = ({ permissionsTable, roles }) => ( { resourceGroup.permissions.map(permission => ( - - + {roles.map(role => ( @@ -41,4 +48,4 @@ const PermissionTable = ({ permissionsTable, roles }) => (
{role.name}
{ - } +
+ {permission.label}
); -export default PermissionTable; \ No newline at end of file +export default PermissionTable; diff --git a/src/authz-module/libraries-manager/LibrariesTeamManager.test.tsx b/src/authz-module/libraries-manager/LibrariesTeamManager.test.tsx index be558e39..5d41ffa0 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'; @@ -63,9 +63,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 +106,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 permissionsMatrix = await screen.findByTestId('permissions-matrix'); + const metrixScope = within(permissionsMatrix); + + expect(metrixScope.getByText('Instructor')).toBeInTheDocument(); + expect(metrixScope.getByText('Library')).toBeInTheDocument(); + expect(metrixScope.getByText('edit')).toBeInTheDocument(); + expect(metrixScope.getByText('view')).toBeInTheDocument(); + }); }); diff --git a/src/authz-module/libraries-manager/LibrariesTeamManager.tsx b/src/authz-module/libraries-manager/LibrariesTeamManager.tsx index 2ad0dea9..ea9bd7ad 100644 --- a/src/authz-module/libraries-manager/LibrariesTeamManager.tsx +++ b/src/authz-module/libraries-manager/LibrariesTeamManager.tsx @@ -32,9 +32,9 @@ const LibrariesTeamManager = () => { })), [roles, permissions, resources, intl]); const permissionsTable = useMemo(() => { - if (!roles || !permissions || !resources) return []; + if (!roles || !permissions || !resources) { return []; } return buildPermissionMatrix(roles, permissions, resources, intl); - }, [roles, permissions, resources]); + }, [roles, permissions, resources, intl]); return (
@@ -74,12 +74,13 @@ const LibrariesTeamManager = () => { - + {!permissionsTable ? + : } -
+ ); }; diff --git a/src/authz-module/libraries-manager/utils.test.ts b/src/authz-module/libraries-manager/utils.test.ts index c3c4a670..364350fd 100644 --- a/src/authz-module/libraries-manager/utils.test.ts +++ b/src/authz-module/libraries-manager/utils.test.ts @@ -1,17 +1,18 @@ -import { buildPermissionsByRoleMatrix } from './utils'; +import { buildPermissionMatrix, buildPermissionsByRoleMatrix } from './utils'; + +const intl = { formatMessage: jest.fn((msg: any) => msg.defaultMessage) }; +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: '' }, +]; 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.defaultMessage) }; const matrix = buildPermissionsByRoleMatrix({ rolePermissions, permissions, resources, intl, }) as Array<{ key: string; actions: Array<{ disabled: boolean }> }>; @@ -21,3 +22,49 @@ describe('buildPermissionsByRoleMatrix', () => { expect(matrix[0].actions[1].disabled).toBe(true); }); }); + +describe('buildPermissionsByRoleMatrix', () => { + it('should build permission matrix grouped by resources with role access mapped', () => { + 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: '', + }, + ]; + const matrix = buildPermissionMatrix(roles, permissions, resources, intl); + + expect(matrix).toEqual([ + { + resource: 'library', + resourceLabel: 'Library', + permissions: [ + { + key: 'create_library', + actionKey: 'create', + label: 'Create Library', + roles: { + admin: true, + editor: false, + guest: false, + }, + }, + { + key: 'edit_library', + actionKey: 'edit', + label: 'Edit Library', + 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 ae998c5b..e9aef837 100644 --- a/src/authz-module/libraries-manager/utils.ts +++ b/src/authz-module/libraries-manager/utils.ts @@ -1,7 +1,7 @@ import { IntlShape } from '@edx/frontend-platform/i18n'; import { actionKeys } from '@src/authz-module/components/RoleCard/constants'; +import { PermissionMetadata, ResourceMetadata, Role } from '@src/types'; import actionMessages from '../components/RoleCard/messages'; -import { PermissionMetadata, ResourceMetadata, Role } from 'types'; /** * Derives the localized label and action key for a given permission. @@ -18,13 +18,13 @@ import { PermissionMetadata, ResourceMetadata, Role } from 'types'; * * @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 `undefined` if not matched. + * - `actionKey`: A string representing icon to be displayed (e.g., `'Read'`, `'Edit'`), or '' if not matched. */ function getPermissionMetadata( permission: PermissionMetadata, intl: IntlShape, -): { label: string; actionKey: string | undefined } { - const actionKey = actionKeys.find(action => permission.key.includes(action)); +): { label: string; actionKey: string } { + const actionKey = actionKeys.find(action => permission.key.includes(action)) || ''; let messageKey = `authz.permissions.actions.${actionKey}`; let messageResource = ''; @@ -61,13 +61,12 @@ const buildPermissionsByRoleMatrix = ({ const allowedPermissions = new Set(rolePermissions); permissions.forEach((permission) => { - const resourceLabel = - resources.find((r) => r.key === permission.resource)?.label || - permission.resource; + const resourceLabel = resources.find((r) => r.key === permission.resource)?.label + || permission.resource; const { label, actionKey } = getPermissionMetadata(permission, intl); - if (!actionKey) return; // Skip unknown actions + if (!actionKey) { return; } // Skip unknown actions // Initialize resource group if not already present if (!permissionsMatrix[permission.resource]) { @@ -88,15 +87,13 @@ const buildPermissionsByRoleMatrix = ({ return Object.values(permissionsMatrix); }; - - - -type PermissionMatrix = { +export type PermissionMatrix = { resource: string; resourceLabel: string; permissions: { key: string; label: string; + actionKey: string; roles: Record; }[]; }[]; @@ -122,12 +119,12 @@ export function buildPermissionMatrix( intl: IntlShape, ): PermissionMatrix { const permissionsByResource = permissions.reduce>((acc, perm) => { - if (!acc[perm.resource]) acc[perm.resource] = []; + if (!acc[perm.resource]) { acc[perm.resource] = []; } acc[perm.resource].push(perm); return acc; }, {}); - const matrix: PermissionMatrix = resources.map(resource => { + const matrix = resources.map(resource => { const resourcePermissions = permissionsByResource[resource.key] || []; const permissionRows = resourcePermissions.map(permission => { @@ -157,5 +154,4 @@ export function buildPermissionMatrix( return matrix; } - export { buildPermissionsByRoleMatrix }; From f4c0e3346011779a148763f06134c09782dbfa37 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Wed, 15 Oct 2025 11:05:00 +1100 Subject: [PATCH 5/8] feat: create ResourceTooltip component and enhance types for permission matrix by role and resource --- .../components/PermissionTable.tsx | 9 +- .../components/ResourceTooltip.tsx | 31 +++ .../components/RoleCard/PermissionsRow.tsx | 26 ++- .../components/RoleCard/index.test.tsx | 14 +- .../components/RoleCard/index.tsx | 12 +- .../LibrariesTeamManager.test.tsx | 8 +- .../LibrariesTeamManager.tsx | 45 ++--- .../LibrariesUserManager.tsx | 16 +- .../libraries-manager/utils.test.ts | 99 +++++++--- src/authz-module/libraries-manager/utils.ts | 184 +++++++++--------- src/types.ts | 29 +++ 11 files changed, 285 insertions(+), 188 deletions(-) create mode 100644 src/authz-module/components/ResourceTooltip.tsx diff --git a/src/authz-module/components/PermissionTable.tsx b/src/authz-module/components/PermissionTable.tsx index a83bda09..6f3eaf91 100644 --- a/src/authz-module/components/PermissionTable.tsx +++ b/src/authz-module/components/PermissionTable.tsx @@ -1,12 +1,12 @@ import { Check, Close } from '@openedx/paragon/icons'; import { Icon } from '@openedx/paragon'; -import { Role } from '@src/types'; -import { PermissionMatrix } from '@src/authz-module/libraries-manager/utils'; +import { PermissionsResourceGrouped, Role } from '@src/types'; import { actionsDictionary } from './RoleCard/constants'; +import ResourceTooltip from './ResourceTooltip'; type PermissionTableProps = { roles: Role[]; - permissionsTable: PermissionMatrix; + permissionsTable: PermissionsResourceGrouped[]; }; const PermissionTable = ({ permissionsTable, roles }: PermissionTableProps) => ( @@ -24,7 +24,8 @@ const PermissionTable = ({ permissionsTable, roles }: PermissionTableProps) => ( <> - {resourceGroup.resourceLabel} + {resourceGroup.label} + { diff --git a/src/authz-module/components/ResourceTooltip.tsx b/src/authz-module/components/ResourceTooltip.tsx new file mode 100644 index 00000000..cee8621a --- /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}

+
    + {resourceGroup.permissions.map(permission => ( +
  • {permission.label.trim()}: {permission.description}
  • + ))} +
+
+ + )} + > + +
+); + +export default ResourceTooltip; diff --git a/src/authz-module/components/RoleCard/PermissionsRow.tsx b/src/authz-module/components/RoleCard/PermissionsRow.tsx index aad0ebbb..09f3a166 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 2cc22900..6afc3977 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 1c154668..350aed6b 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/libraries-manager/LibrariesTeamManager.test.tsx b/src/authz-module/libraries-manager/LibrariesTeamManager.test.tsx index 5d41ffa0..96282185 100644 --- a/src/authz-module/libraries-manager/LibrariesTeamManager.test.tsx +++ b/src/authz-module/libraries-manager/LibrariesTeamManager.test.tsx @@ -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
), })); diff --git a/src/authz-module/libraries-manager/LibrariesTeamManager.tsx b/src/authz-module/libraries-manager/LibrariesTeamManager.tsx index ea9bd7ad..11dcd2da 100644 --- a/src/authz-module/libraries-manager/LibrariesTeamManager.tsx +++ b/src/authz-module/libraries-manager/LibrariesTeamManager.tsx @@ -11,7 +11,7 @@ import RoleCard from '../components/RoleCard'; import PermissionTable from '../components/PermissionTable'; import { useLibraryAuthZ } from './context'; import { AddNewTeamMemberTrigger } from './components/AddNewTeamMemberModal'; -import { buildPermissionMatrix, buildPermissionsByRoleMatrix } from './utils'; +import { buildPermissionMatrixByResource, buildPermissionMatrixByRole } from './utils'; import messages from './messages'; @@ -24,16 +24,17 @@ 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 permissionsTable = useMemo(() => { - if (!roles || !permissions || !resources) { return []; } - return buildPermissionMatrix(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 ( @@ -60,22 +61,22 @@ const LibrariesTeamManager = () => { - {!libraryRoles ? : null} - {libraryRoles && libraryRoles.map(role => ( - - ))} + {!libraryPermissionsByRole ? + : libraryPermissionsByRole.map(role => ( + + ))} - {!permissionsTable ? - : } + {!libraryPermissionsByResource ? + : } diff --git a/src/authz-module/libraries-manager/LibrariesUserManager.tsx b/src/authz-module/libraries-manager/LibrariesUserManager.tsx index 6883e818..94d96977 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/utils.test.ts b/src/authz-module/libraries-manager/utils.test.ts index 364350fd..07d6600a 100644 --- a/src/authz-module/libraries-manager/utils.test.ts +++ b/src/authz-module/libraries-manager/utils.test.ts @@ -1,52 +1,87 @@ -import { buildPermissionMatrix, buildPermissionsByRoleMatrix } from './utils'; +import { buildPermissionMatrixByResource, buildPermissionMatrixByRole } from './utils'; + +const intl = { formatMessage: jest.fn((msg: any) => msg) }; -const intl = { formatMessage: jest.fn((msg: any) => msg.defaultMessage) }; const permissions = [ - { key: 'create_library', resource: 'library', label: 'Create Library' }, - { key: 'edit_library', resource: 'library', label: 'Edit Library' }, + { + 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('buildPermissionsByRoleMatrix', () => { - it('returns permissions matrix for given role', () => { - const rolePermissions = ['create_library']; - - 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); +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, + }, + ], + }, + ], + }); }); -}); -describe('buildPermissionsByRoleMatrix', () => { it('should build permission matrix grouped by resources with role access mapped', () => { - 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: '', - }, - ]; - const matrix = buildPermissionMatrix(roles, permissions, resources, intl); + const matrix = buildPermissionMatrixByResource({ + roles, permissions, resources, intl, + }); expect(matrix).toEqual([ { - resource: 'library', - resourceLabel: 'Library', + key: 'library', + label: 'Library', + description: '', permissions: [ { key: 'create_library', actionKey: 'create', label: 'Create Library', + resource: 'library', + description: '', roles: { admin: true, editor: false, @@ -56,7 +91,9 @@ describe('buildPermissionsByRoleMatrix', () => { { key: 'edit_library', actionKey: 'edit', + resource: 'library', label: 'Edit Library', + description: '', roles: { admin: true, editor: true, diff --git a/src/authz-module/libraries-manager/utils.ts b/src/authz-module/libraries-manager/utils.ts index e9aef837..17ff64cc 100644 --- a/src/authz-module/libraries-manager/utils.ts +++ b/src/authz-module/libraries-manager/utils.ts @@ -1,13 +1,16 @@ import { IntlShape } from '@edx/frontend-platform/i18n'; import { actionKeys } from '@src/authz-module/components/RoleCard/constants'; -import { PermissionMetadata, ResourceMetadata, Role } from '@src/types'; +import { + EnrichedPermission, PermissionMetadata, PermissionsResourceGrouped, + PermissionsRoleGrouped, ResourceMetadata, Role, RoleResourceGroup, +} from '@src/types'; import actionMessages from '../components/RoleCard/messages'; /** * Derives the localized label and action key for a given permission. * - * This function attempts to extract a known `actionKey` from the permission's key, - * and uses that to construct an internationalized label using `intl.formatMessage`. + * 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 @@ -20,10 +23,7 @@ import actionMessages from '../components/RoleCard/messages'; * - `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. */ -function getPermissionMetadata( - permission: PermissionMetadata, - intl: IntlShape, -): { label: string; actionKey: string } { +const getPermissionMetadata = (permission: PermissionMetadata, intl: IntlShape): EnrichedPermission => { const actionKey = actionKeys.find(action => permission.key.includes(action)) || ''; let messageKey = `authz.permissions.actions.${actionKey}`; let messageResource = ''; @@ -33,70 +33,20 @@ function getPermissionMetadata( messageResource = actionKey === 'tag' ? 'Tags' : ''; } - const label = permission.label || intl.formatMessage(actionMessages[messageKey], { resource: messageResource }); + const messageDescriptor = actionMessages[messageKey]; + const label = permission.label || (messageDescriptor + ? intl.formatMessage(messageDescriptor, { resource: messageResource }) + : permission.key); - return { label, actionKey }; -} - - -/** - * Builds a permission matrix for a role. - * - * 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 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. - */ -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 { label, actionKey } = getPermissionMetadata(permission, intl); - - if (!actionKey) { return; } // Skip unknown actions - - // Initialize resource group if not already present - if (!permissionsMatrix[permission.resource]) { - permissionsMatrix[permission.resource] = { - key: permission.resource, - label: resourceLabel, - actions: [], - }; - } - - permissionsMatrix[permission.resource].actions.push({ - key: actionKey, - label, - disabled: !allowedPermissions.has(permission.key), - }); - }); - - return Object.values(permissionsMatrix); + return { ...permission, label, actionKey }; }; -export type PermissionMatrix = { - resource: string; - resourceLabel: string; - permissions: { - key: string; - label: string; - actionKey: string; - roles: Record; - }[]; -}[]; +type BuildPermissionsMatrixProps = { + roles: Role[]; + permissions: PermissionMetadata[]; + resources: ResourceMetadata[]; + intl: IntlShape; +}; /** * Builds a permission matrix from the given roles, permissions, and resources. @@ -111,47 +61,97 @@ export type PermissionMatrix = { * * @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); -export function buildPermissionMatrix( - roles: Role[], - permissions: PermissionMetadata[], - resources: ResourceMetadata[], - intl: IntlShape, -): PermissionMatrix { const permissionsByResource = permissions.reduce>((acc, perm) => { if (!acc[perm.resource]) { acc[perm.resource] = []; } acc[perm.resource].push(perm); return acc; }, {}); - const matrix = resources.map(resource => { - const resourcePermissions = permissionsByResource[resource.key] || []; + return resources.map(resource => { + const perms = permissionsByResource[resource.key] || []; - const permissionRows = resourcePermissions.map(permission => { - const rolesMap: Record = {}; - - roles.forEach(role => { - rolesMap[role.name] = role.permissions.includes(permission.key); - }); - - const { label, actionKey } = getPermissionMetadata(permission, intl); + 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 { - key: permission.key, - actionKey, // Important for icon mapping - label, + ...enriched, roles: rolesMap, }; }); return { - resource: resource.key, - resourceLabel: resource.label, + ...resource, permissions: permissionRows, }; }); +}; - return matrix; -} +/** + * 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 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 role and resource with action-level details. + */ +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), + }; + }); +}; -export { buildPermissionsByRoleMatrix }; +export { buildPermissionMatrixByResource, buildPermissionMatrixByRole }; diff --git a/src/types.ts b/src/types.ts index 3a7ebdd9..e1e39eb5 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: { From 48efee0318eae5b9db38961689eae99d5bdcad10 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Wed, 22 Oct 2025 20:27:25 +1100 Subject: [PATCH 6/8] style: enhance tooltip styles and permissions displayed order --- src/authz-module/components/ResourceTooltip.tsx | 10 +++++----- src/authz-module/libraries-manager/constants.ts | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/authz-module/components/ResourceTooltip.tsx b/src/authz-module/components/ResourceTooltip.tsx index cee8621a..5a13528c 100644 --- a/src/authz-module/components/ResourceTooltip.tsx +++ b/src/authz-module/components/ResourceTooltip.tsx @@ -9,13 +9,13 @@ type ResourceTooltipProps = { const ResourceTooltip = ({ resourceGroup }:ResourceTooltipProps) => ( -

{resourceGroup.label}

-

{resourceGroup.description}

-
    +

    {resourceGroup.label}

    +

    {resourceGroup.description}

    +
      {resourceGroup.permissions.map(permission => (
    • {permission.label.trim()}: {permission.description}
    • ))} @@ -24,7 +24,7 @@ const ResourceTooltip = ({ resourceGroup }:ResourceTooltipProps) => ( )} > - + ); diff --git a/src/authz-module/libraries-manager/constants.ts b/src/authz-module/libraries-manager/constants.ts index 60fdf45d..858c32f4 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' }, From 14a631ba2ba673a6bd2f6bd82e1361f86cdb2d83 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Fri, 24 Oct 2025 01:59:35 +1100 Subject: [PATCH 7/8] test: remove data-testid for PermissionTable --- src/authz-module/components/PermissionTable.tsx | 2 +- .../libraries-manager/LibrariesTeamManager.test.tsx | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/authz-module/components/PermissionTable.tsx b/src/authz-module/components/PermissionTable.tsx index 6f3eaf91..4456971d 100644 --- a/src/authz-module/components/PermissionTable.tsx +++ b/src/authz-module/components/PermissionTable.tsx @@ -10,7 +10,7 @@ type PermissionTableProps = { }; const PermissionTable = ({ permissionsTable, roles }: PermissionTableProps) => ( - +
      + ); export default PermissionTable; diff --git a/src/authz-module/components/ResourceTooltip.tsx b/src/authz-module/components/ResourceTooltip.tsx index 5a13528c..10fa4d2c 100644 --- a/src/authz-module/components/ResourceTooltip.tsx +++ b/src/authz-module/components/ResourceTooltip.tsx @@ -11,7 +11,7 @@ const ResourceTooltip = ({ resourceGroup }:ResourceTooltipProps) => ( key={`overlay-${resourceGroup.key}`} placement="auto" overlay={( - +

      {resourceGroup.label}

      {resourceGroup.description}

      diff --git a/src/authz-module/index.scss b/src/authz-module/index.scss index 790adc83..907cd6c4 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; + } + } }