feat(authz): [FC-0099] permissions tab (#12)

* feat: create permissions tab

* style: add tsdocs to getPermissionMetadata and buildPermissionMatrix

* style: fix padding on rows table

* feat: add skeleton and improve testing

* feat: create ResourceTooltip component and enhance types for permission matrix by role and resource

* style: enhance tooltip styles and permissions displayed order

* test: remove data-testid for PermissionTable

* style: use card for adding background color to the permission table component
This commit is contained in:
Diana Olarte
2025-10-24 08:01:10 +11:00
committed by GitHub
parent 27a952d976
commit 50beaef35d
13 changed files with 448 additions and 108 deletions

View File

@@ -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) => (
<Card>
<table className="permission-table w-100">
<thead>
<tr>
<th className="" aria-hidden="true" />
{roles.map(role => (
<th key={role.name} className="text-center py-3">{role.name}</th>
))}
</tr>
</thead>
<tbody>
{permissionsTable.map(resourceGroup => (
<>
<tr className="bg-info-100 text-primary">
<td colSpan={roles.length + 1} className="text-start py-3 px-4">
<strong>{resourceGroup.label}</strong>
<ResourceTooltip resourceGroup={resourceGroup} />
</td>
</tr>
{resourceGroup.permissions.map(permission => (
<tr key={permission.key} className="border-top">
<td className="text-start d-flex align-items-center small px-4 py-3">
<Icon className="d-inline-block mr-2" size="sm" src={actionsDictionary[permission.actionKey]} />
{permission.label}
</td>
{roles.map(role => (
<td key={role.name} className="text-center">
{permission.roles[role.name] ? <Icon className="d-inline-block" src={Check} /> : <Icon className="text-danger d-inline-block" src={Close} />}
</td>
))}
</tr>
))}
</>
))}
</tbody>
</table>
</Card>
);
export default PermissionTable;

View File

@@ -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) => (
<OverlayTrigger
key={`overlay-${resourceGroup.key}`}
placement="auto"
overlay={(
<Popover id={`tooltip-${resourceGroup.label}`}>
<Popover.Content className="p-3">
<h4 className="text-primary">{resourceGroup.label}</h4>
<p className="small">{resourceGroup.description}</p>
<ul className="small">
{resourceGroup.permissions.map(permission => (
<li><b>{permission.label.trim()}:</b> {permission.description}</li>
))}
</ul>
</Popover.Content>
</Popover>
)}
>
<Icon className="d-inline-block text-gray ml-2 my-auto" size="inline" src={Info} />
</OverlayTrigger>
);
export default ResourceTooltip;

View File

@@ -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) => (
<Row className="row align-items-center border px-2 py-2">
<Col md={2}>
<span className="small font-weight-bold">{resourceLabel}</span>
<span className="small font-weight-bold">{resource.label}</span>
<ResourceTooltip resourceGroup={resource} />
</Col>
<Col>
<div className="w-100 d-flex flex-wrap align-items-center">
{actions.map((action, index) => (
{resource.permissions.map((action, index) => (
<>
<Chip
key={action.key}
iconBefore={actionsDictionary[action.key as ActionKey] as ComponentType}
iconBefore={actionsDictionary[action.actionKey as ActionKey] as ComponentType}
disabled={action.disabled}
className="mx-3 my-2 px-3 bg-primary-100 border-0 permission-chip"
variant="light"
>
{action.label}
</Chip>
{(index === actions.length - 1) ? null
{(index === resource.permissions.length - 1) ? null
: (<hr className="border-right mx-2" style={{ height: '24px' }} />)}
</>
))}

View File

@@ -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(<RoleCard {...defaultProps} permissions={[]} />);
renderWrapper(<RoleCard {...defaultProps} permissionsByResource={[]} />);
expect(screen.queryByText('Library Resource')).not.toBeInTheDocument();
});
});

View File

@@ -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'])}
>
<Container>
{permissions.map(({ key, label, actions }) => (
{permissionsByResource.map((resourceGroup) => (
<PermissionRow
key={`${title}-${key}`}
resourceLabel={label}
actions={actions}
key={`${title}-${resourceGroup.key}`}
resource={resourceGroup}
/>
))}
</Container>
</Collapsible>

View File

@@ -34,6 +34,12 @@
height: var(--pgn-size-icon-xs);
}
}
.permission-table {
td {
line-height: 24px;
}
}
}

View File

@@ -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[]
}) => (
<div data-testid="role-card">
<div>{title}</div>
<div>{description}</div>
<div>{permissions.length} permissions</div>
<div>{permissionsByResource.length} permissions</div>
</div>
),
}));
@@ -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(<LibrariesTeamManager />);
// 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();
});
});

View File

@@ -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 (
<div className="authz-libraries">
@@ -54,20 +61,23 @@ const LibrariesTeamManager = () => {
</Tab>
<Tab eventKey="roles" title={intl.formatMessage(messages['library.authz.tabs.roles'])}>
<Container className="p-5">
{!libraryRoles ? <Skeleton count={2} height={200} /> : null}
{libraryRoles && libraryRoles.map(role => (
<RoleCard
key={`${role.role}-description`}
title={role.name}
userCounter={role.userCount}
description={role.description}
permissions={role.permissions as any[]}
/>
))}
{!libraryPermissionsByRole ? <Skeleton count={2} height={200} />
: libraryPermissionsByRole.map(role => (
<RoleCard
key={`${role.role}-description`}
title={role.name}
userCounter={role.userCount}
description={role.description}
permissionsByResource={role.resources as any[]}
/>
))}
</Container>
</Tab>
<Tab id="libraries-permissions-tab" eventKey="permissions" title={intl.formatMessage(messages['library.authz.tabs.permissions'])}>
Permissions tab.
<Container className="p-5 container-mw-lg">
{!libraryPermissionsByResource ? <Skeleton count={2} height={200} />
: <PermissionTable permissionsTable={libraryPermissionsByResource} roles={roles} />}
</Container>
</Tab>
</Tabs>
</AuthZLayout>

View File

@@ -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';
@@ -34,14 +34,10 @@ const LibrariesUserManager = () => {
const user = teamMember?.results?.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 (
@@ -69,7 +65,7 @@ const LibrariesUserManager = () => {
objectName={library.title}
description={role.description}
showDelete
permissions={role.permissions as any[]}
permissionsByResource={role.resources as any[]}
/>
))}
</Container>

View File

@@ -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' },

View File

@@ -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,
},
},
],
},
]);
});
});

View File

@@ -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<string, EnrichedPermission>);
const permissionsByResource = permissions.reduce<Record<string, PermissionMetadata[]>>((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<string, boolean>);
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);
const buildPermissionMatrixByRole = ({
roles, permissions, resources, intl,
}: BuildPermissionsMatrixProps): PermissionsRoleGrouped[] => {
const enrichedPermissions = permissions.reduce((acc, perm) => {
acc[perm.key] = getPermissionMetadata(perm, intl);
return acc;
}, {} as Record<string, EnrichedPermission>);
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 = '';
return roles.map(role => {
const allowed = new Set(role.permissions);
const permissionsGroupedByResource: Record<string, RoleResourceGroup> = {};
permissionsMatrix[permission.resource] = permissionsMatrix[permission.resource]
|| { key: permission.resource, label: resourceLabel, actions: [] };
permissions.forEach(permission => {
const enriched = enrichedPermissions[permission.key];
const { resource } = permission;
if (actionKey === 'tag' || actionKey === 'team') {
messageKey = 'authz.permissions.actions.manage';
messageResource = actionKey === 'tag' ? 'Tags' : messageResource;
}
if (!enriched.actionKey) { return; }
permissionsMatrix[permission.resource].actions.push({
key: actionKey,
label: permission.label || intl.formatMessage(actionMessages[messageKey], { resource: messageResource }),
disabled: !allowedPermissions.has(permission.key),
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 };

View File

@@ -46,6 +46,35 @@ export type PermissionMetadata = {
description?: string;
};
// Permissions Matrix
export type EnrichedPermission = PermissionMetadata & {
actionKey: string;
};
export type PermissionWithRoles = EnrichedPermission & {
roles: Record<string, boolean>;
};
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<T> {
row: {