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:
52
src/authz-module/components/PermissionTable.tsx
Normal file
52
src/authz-module/components/PermissionTable.tsx
Normal 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;
|
||||
31
src/authz-module/components/ResourceTooltip.tsx
Normal file
31
src/authz-module/components/ResourceTooltip.tsx
Normal 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;
|
||||
@@ -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' }} />)}
|
||||
</>
|
||||
))}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -34,6 +34,12 @@
|
||||
height: var(--pgn-size-icon-xs);
|
||||
}
|
||||
}
|
||||
|
||||
.permission-table {
|
||||
td {
|
||||
line-height: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
29
src/types.ts
29
src/types.ts
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user