Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions src/authz-module/components/TableCells.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,24 @@ describe('TableCells Components', () => {
expect(screen.getByText('Test Organization')).toBeInTheDocument();
expect(screen.queryByText('All Organizations')).not.toBeInTheDocument();
});

it('displays "All Organizations" when the org value is the "*" wildcard', () => {
const props = {
value: '*',
row: {
id: '0',
original: {
role: 'course_admin', org: '*', scope: 'course-v1:*', permissionCount: 1,
},
},
column: { id: 'org' },
};

renderWrapper(<OrgCell {...props} />);

expect(screen.getByText('All Organizations')).toBeInTheDocument();
expect(screen.queryByText('*')).not.toBeInTheDocument();
});
});

describe('ScopeCell', () => {
Expand Down Expand Up @@ -449,6 +467,96 @@ describe('TableCells Components', () => {
expect(screen.getByText('Course Scope')).toBeInTheDocument();
expect(screen.queryByText('Global')).not.toBeInTheDocument();
});

it('displays "Global" when the scope is the "*" wildcard', () => {
const props = {
value: '*',
row: {
id: '0',
original: {
role: 'course_admin', org: '*', scope: '*', permissionCount: 1,
},
},
column: { id: 'scope' },
};

renderWrapper(<ScopeCell {...props} />);

expect(screen.getByText('Global')).toBeInTheDocument();
expect(screen.queryByText('*')).not.toBeInTheDocument();
});

it('displays the platform-wide label for the course wildcard scope (course-v1:*)', () => {
const props = {
value: 'course-v1:*',
row: {
id: '0',
original: {
role: 'course_admin', org: '*', scope: 'course-v1:*', permissionCount: 1,
},
},
column: { id: 'scope' },
};

renderWrapper(<ScopeCell {...props} />);

expect(screen.getByText('All courses on the platform')).toBeInTheDocument();
expect(screen.queryByText('course-v1:*')).not.toBeInTheDocument();
});

it('displays the platform-wide label for the library wildcard scope (lib:*)', () => {
const props = {
value: 'lib:*',
row: {
id: '0',
original: {
role: 'library_admin', org: '*', scope: 'lib:*', permissionCount: 1,
},
},
column: { id: 'scope' },
};

renderWrapper(<ScopeCell {...props} />);

expect(screen.getByText('All libraries on the platform')).toBeInTheDocument();
expect(screen.queryByText('lib:*')).not.toBeInTheDocument();
});

it('displays the org-wide label for an organization-wide course scope', () => {
const props = {
value: 'course-v1:TestOrg+*',
row: {
id: '0',
original: {
role: 'course_admin', org: 'TestOrg', scope: 'course-v1:TestOrg+*', permissionCount: 1,
},
},
column: { id: 'scope' },
};

renderWrapper(<ScopeCell {...props} />);

expect(screen.getByText('All courses in this organization')).toBeInTheDocument();
expect(screen.queryByText('course-v1:TestOrg+*')).not.toBeInTheDocument();
});

it('displays the org-wide label for an organization-wide library scope', () => {
const props = {
value: 'lib:TestOrg:*',
row: {
id: '0',
original: {
role: 'library_admin', org: 'TestOrg', scope: 'lib:TestOrg:*', permissionCount: 1,
},
},
column: { id: 'scope' },
};

renderWrapper(<ScopeCell {...props} />);

expect(screen.getByText('All libraries in this organization')).toBeInTheDocument();
expect(screen.queryByText('lib:TestOrg:*')).not.toBeInTheDocument();
});
});

describe('PermissionsCell', () => {
Expand Down
31 changes: 22 additions & 9 deletions src/authz-module/components/TableCells.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ import { UserRoleWithPermissions, RoleToDelete } from '@src/types';
import { useNavigate } from 'react-router-dom';
import { useContext, useMemo, type ComponentProps } from 'react';
import {
ADMIN_ROLES, DJANGO_MANAGED_ROLES, MAP_ROLE_KEY_TO_LABEL,
ADMIN_ROLES, CONTEXT_TYPES, DJANGO_MANAGED_ROLES, getAggregateScopeType, getPlatformAggregateScopeKey,
getScopeContextType, MAP_ROLE_KEY_TO_LABEL,
} from '@src/authz-module/constants';
import { AGGREGATE_SCOPE_LABELS } from '@src/authz-module/messages';
import {
Icon, IconButton, OverlayTrigger, Tooltip, DataTableContext,
type DataTableCellProps,
Expand Down Expand Up @@ -127,9 +129,11 @@ const createViewActionCell = (extraProps: ViewActionCellExtraProps) => function

const OrgCell = ({ value, row }: CellPropsWithValue) => {
const { formatMessage } = useIntl();
// The backend returns '*' as the org wildcard, meaning the role spans every organization.
const isAllOrgs = DJANGO_MANAGED_ROLES.includes(row.original.role) || value === '*';
return (
<span>
{DJANGO_MANAGED_ROLES.includes(row.original.role) ? formatMessage(messages['authz.user.table.org.all.organizations.label']) : value}
{isAllOrgs ? formatMessage(messages['authz.user.table.org.all.organizations.label']) : value}
</span>
);
};
Expand All @@ -138,18 +142,27 @@ const ScopeCell = ({ row }: CellProps) => {
const { formatMessage } = useIntl();

const { scopeText, iconSrc } = useMemo(() => {
if (DJANGO_MANAGED_ROLES.includes(row.original.role)) {
const { role, scope, org } = row.original;
if (DJANGO_MANAGED_ROLES.includes(role) || scope === getPlatformAggregateScopeKey('global')) {
return {
scopeText: formatMessage(messages['authz.user.table.scope.global.label']),
iconSrc: RESOURCE_ICONS.GLOBAL,
};
}
const scopeIcon = row.original.role?.startsWith('lib') ? RESOURCE_ICONS.LIBRARY : RESOURCE_ICONS.COURSE;
const contextType = getScopeContextType(scope);
const scopeIcon = contextType === CONTEXT_TYPES.LIBRARY ? RESOURCE_ICONS.LIBRARY : RESOURCE_ICONS.COURSE;
const aggregateType = getAggregateScopeType(scope, org);
if (aggregateType) {
return {
scopeText: formatMessage(AGGREGATE_SCOPE_LABELS[aggregateType][contextType]),
iconSrc: scopeIcon,
};
}
return {
scopeText: row.original.scope,
scopeText: scope,
iconSrc: scopeIcon,
};
}, [row.original.role, row.original.scope, formatMessage]);
}, [row.original, formatMessage]);

return (
<span className="d-flex align-items-center">
Expand All @@ -174,7 +187,7 @@ const PermissionsCell = ({ row }: CellProps) => {
const isDjangoRole = DJANGO_MANAGED_ROLES.includes(role);
return (
<span>
{ isDjangoRole
{isDjangoRole
? formatMessage(
messages['authz.user.table.permissions.access.label'],
{ accessType: role === 'django.superuser' ? 'total' : 'partial' },
Expand Down Expand Up @@ -239,7 +252,7 @@ const ActionsCell = ({
<Tooltip variant="light" id="tooltip-left">
{formatMessage(messages['authz.user.table.delete.action.djangorole.tooltip'])}
</Tooltip>
)}
)}
>
<Icon
className="mx-2 pl-1"
Expand All @@ -257,7 +270,7 @@ const ActionsCell = ({
<Tooltip variant="light" id="tooltip-left">
{formatMessage(messages['authz.user.table.delete.action.adminrole.tooltip'])}
</Tooltip>
)}
)}
>
<Icon
className="mx-2 pl-1 text-light-500"
Expand Down
36 changes: 35 additions & 1 deletion src/authz-module/constants.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {
buildWizardPath, getOrgAggregateScopeKey, getPlatformAggregateScopeKey, ROUTES,
buildWizardPath, getAggregateScopeType, getOrgAggregateScopeKey, getPlatformAggregateScopeKey, ROUTES,
} from './constants';
import type { ContextType } from './constants';

Expand Down Expand Up @@ -63,3 +63,37 @@ describe('getPlatformAggregateScopeKey', () => {
expect(() => getPlatformAggregateScopeKey('unknown' as ContextType)).toThrow('Unknown contextType: "unknown"');
});
});

describe('getAggregateScopeType', () => {
it('recognizes the platform-wide course scope', () => {
expect(getAggregateScopeType('course-v1:*', '*')).toBe('platform');
});

it('recognizes the platform-wide library scope', () => {
expect(getAggregateScopeType('lib:*', '*')).toBe('platform');
});

it('recognizes an org-wide course scope', () => {
expect(getAggregateScopeType('course-v1:MIT+*', 'MIT')).toBe('org');
});

it('recognizes an org-wide library scope', () => {
expect(getAggregateScopeType('lib:MIT:*', 'MIT')).toBe('org');
});

it('returns null for a single course scope', () => {
expect(getAggregateScopeType('course-v1:MIT+DemoX+2024', 'MIT')).toBeNull();
});

it('returns null for a single library scope', () => {
expect(getAggregateScopeType('lib:MIT:demo', 'MIT')).toBeNull();
});

it('returns null for an org-wide scope when the org is unknown', () => {
expect(getAggregateScopeType('course-v1:MIT+*')).toBeNull();
});

it('returns null when the scope belongs to a different org', () => {
expect(getAggregateScopeType('course-v1:MIT+*', 'HarvardX')).toBeNull();
});
});
19 changes: 19 additions & 0 deletions src/authz-module/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
export const CONTEXT_TYPES = {
LIBRARY: 'library',
COURSE: 'course',
GLOBAL: 'global',
} as const;

export type ContextType = typeof CONTEXT_TYPES[keyof typeof CONTEXT_TYPES];
Expand All @@ -20,6 +21,7 @@ export const getOrgAggregateScopeKey = (contextType: ContextType, orgSlug: strin
const PLATFORM_AGGREGATE_SCOPE_KEYS = {
[CONTEXT_TYPES.COURSE]: 'course-v1:*',
[CONTEXT_TYPES.LIBRARY]: 'lib:*',
[CONTEXT_TYPES.GLOBAL]: '*',
};

export const getPlatformAggregateScopeKey = (contextType: ContextType): string => {
Expand All @@ -28,6 +30,23 @@ export const getPlatformAggregateScopeKey = (contextType: ContextType): string =
return scope;
};

export const getScopeContextType = (scope: string): ContextType => (
scope.startsWith('lib') ? CONTEXT_TYPES.LIBRARY : CONTEXT_TYPES.COURSE
);

/**
* Tells whether a scope is one of the wildcard scopes, and which level it aggregates.
*
* Returns `null` for a scope pointing at a single course or library. The org slug is
* needed to recognize an org-level aggregate, since its key embeds the slug.
*/
export const getAggregateScopeType = (scope: string, org?: string | null): 'platform' | 'org' | null => {
const contextType = getScopeContextType(scope);
if (scope === getPlatformAggregateScopeKey(contextType)) { return 'platform'; }
if (org && scope === getOrgAggregateScopeKey(contextType, org)) { return 'org'; }
return null;
};

export const DEFAULT_TOAST_DELAY = 5000;
export const RETRY_TOAST_DELAY = 120_000; // 2 minutes
export const SKELETON_ROWS = Array.from({ length: 10 }).map(() => ({
Expand Down
37 changes: 37 additions & 0 deletions src/authz-module/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,44 @@ const messages = defineMessages(
defaultMessage: 'Retry',
description: 'Label for retry button.',
},
'authz.scope.aggregate.platform.course': {
id: 'authz.scope.aggregate.platform.course',
defaultMessage: 'All courses on the platform',
description: 'Label for the aggregate scope covering every course in the platform (course-v1:* scope).',
},
'authz.scope.aggregate.platform.library': {
id: 'authz.scope.aggregate.platform.library',
defaultMessage: 'All libraries on the platform',
description: 'Label for the aggregate scope covering every library in the platform (lib:* scope).',
},
'authz.scope.aggregate.org.course': {
id: 'authz.scope.aggregate.org.course',
defaultMessage: 'All courses in this organization',
description: 'Label for the aggregate scope covering every course of a single organization (course-v1:<org>+* scope).',
},
'authz.scope.aggregate.org.library': {
id: 'authz.scope.aggregate.org.library',
defaultMessage: 'All libraries in this organization',
description: 'Label for the aggregate scope covering every library of a single organization (lib:<org>:* scope).',
},
},
);

/**
* Labels for the aggregate (wildcard) scopes, keyed by aggregate type and context type.
*
* Shared so the scope list in the assignment wizard and the scope column in the
* assignments tables name the same scope the same way.
*/
export const AGGREGATE_SCOPE_LABELS = {
platform: {
course: messages['authz.scope.aggregate.platform.course'],
library: messages['authz.scope.aggregate.platform.library'],
},
org: {
course: messages['authz.scope.aggregate.org.course'],
library: messages['authz.scope.aggregate.org.library'],
},
};

export default messages;
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import { Scope } from '@src/types';
import { useOrgs, useScopes } from '@src/authz-module/data/hooks';
import { useCourseAuthoringFlag } from '@src/authz-module/hooks/useCourseAuthoringFlag';
import { getOrgAggregateScopeKey, getPlatformAggregateScopeKey } from '@src/authz-module/constants';
import { CONTEXT_TYPES, getOrgAggregateScopeKey, getPlatformAggregateScopeKey } from '@src/authz-module/constants';
import type { ContextType } from '@src/authz-module/constants';
import { AGGREGATE_SCOPE_LABELS } from '@src/authz-module/messages';
import messages from '../messages';
import useScopePermissions from './useScopePermissions';

Expand Down Expand Up @@ -74,13 +75,11 @@ const useScopeListData = ({ contextType, search, orgs }: UseScopeListDataParams)
? intl.formatMessage(messages['wizard.step2.scope.aggregate.description.course'])
: intl.formatMessage(messages['wizard.step2.scope.aggregate.description.library']);

const platformAggregateLabel = contextType === 'course'
? intl.formatMessage(messages['wizard.step2.scope.aggregate.platform.label.course'])
: intl.formatMessage(messages['wizard.step2.scope.aggregate.platform.label.library']);
const labelContextType = contextType === 'course' ? CONTEXT_TYPES.COURSE : CONTEXT_TYPES.LIBRARY;

const orgAggregateLabel = contextType === 'course'
? intl.formatMessage(messages['wizard.step2.scopeList.aggregate.label.course'])
: intl.formatMessage(messages['wizard.step2.scopeList.aggregate.label.library']);
const platformAggregateLabel = intl.formatMessage(AGGREGATE_SCOPE_LABELS.platform[labelContextType]);

const orgAggregateLabel = intl.formatMessage(AGGREGATE_SCOPE_LABELS.org[labelContextType]);

const platformAggregateScopeItem: Scope | null = (contextType && hasPlatformPermission)
? {
Expand Down
Loading