diff --git a/src/authz-module/components/TableCells.test.tsx b/src/authz-module/components/TableCells.test.tsx
index 397e39a8..c5ad11c5 100644
--- a/src/authz-module/components/TableCells.test.tsx
+++ b/src/authz-module/components/TableCells.test.tsx
@@ -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();
+
+ expect(screen.getByText('All Organizations')).toBeInTheDocument();
+ expect(screen.queryByText('*')).not.toBeInTheDocument();
+ });
});
describe('ScopeCell', () => {
@@ -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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ expect(screen.getByText('All libraries in this organization')).toBeInTheDocument();
+ expect(screen.queryByText('lib:TestOrg:*')).not.toBeInTheDocument();
+ });
});
describe('PermissionsCell', () => {
diff --git a/src/authz-module/components/TableCells.tsx b/src/authz-module/components/TableCells.tsx
index 0b7febba..e2948d03 100644
--- a/src/authz-module/components/TableCells.tsx
+++ b/src/authz-module/components/TableCells.tsx
@@ -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,
@@ -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 (
- {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}
);
};
@@ -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 (
@@ -174,7 +187,7 @@ const PermissionsCell = ({ row }: CellProps) => {
const isDjangoRole = DJANGO_MANAGED_ROLES.includes(role);
return (
- { isDjangoRole
+ {isDjangoRole
? formatMessage(
messages['authz.user.table.permissions.access.label'],
{ accessType: role === 'django.superuser' ? 'total' : 'partial' },
@@ -239,7 +252,7 @@ const ActionsCell = ({
{formatMessage(messages['authz.user.table.delete.action.djangorole.tooltip'])}
- )}
+ )}
>
{formatMessage(messages['authz.user.table.delete.action.adminrole.tooltip'])}
- )}
+ )}
>
{
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();
+ });
+});
diff --git a/src/authz-module/constants.ts b/src/authz-module/constants.ts
index c344f9aa..26cbe833 100644
--- a/src/authz-module/constants.ts
+++ b/src/authz-module/constants.ts
@@ -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];
@@ -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 => {
@@ -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(() => ({
diff --git a/src/authz-module/messages.ts b/src/authz-module/messages.ts
index 3b3916f6..455ae2db 100644
--- a/src/authz-module/messages.ts
+++ b/src/authz-module/messages.ts
@@ -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:+* 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::* 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;
diff --git a/src/authz-module/role-assignation-wizard/hooks/useScopeListData.ts b/src/authz-module/role-assignation-wizard/hooks/useScopeListData.ts
index 3740ed0d..ab595e89 100644
--- a/src/authz-module/role-assignation-wizard/hooks/useScopeListData.ts
+++ b/src/authz-module/role-assignation-wizard/hooks/useScopeListData.ts
@@ -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';
@@ -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)
? {
diff --git a/src/authz-module/role-assignation-wizard/messages.ts b/src/authz-module/role-assignation-wizard/messages.ts
index 47694635..d20ce302 100644
--- a/src/authz-module/role-assignation-wizard/messages.ts
+++ b/src/authz-module/role-assignation-wizard/messages.ts
@@ -203,16 +203,6 @@ const messages = defineMessages({
defaultMessage: 'Includes current and future libraries',
description: 'Description for the platform-wide aggregate scope item when context type is library',
},
- 'wizard.step2.scope.aggregate.platform.label.course': {
- id: 'wizard.step2.scope.aggregate.platform.label.course',
- defaultMessage: 'All courses on the platform',
- description: 'Display name for the platform-wide aggregate scope item when context type is course',
- },
- 'wizard.step2.scope.aggregate.platform.label.library': {
- id: 'wizard.step2.scope.aggregate.platform.label.library',
- defaultMessage: 'All libraries on the platform',
- description: 'Display name for the platform-wide aggregate scope item when context type is library',
- },
// ScopeList — org section header
'wizard.step2.scopeList.orgLabel': {
@@ -221,18 +211,6 @@ const messages = defineMessages({
description: 'Label for the collapsible org section header in the scope list',
},
- // ScopeList — org-level aggregate scope items
- 'wizard.step2.scopeList.aggregate.label.course': {
- id: 'wizard.step2.scopeList.aggregate.label.course',
- defaultMessage: 'All courses in this organization',
- description: 'Display name for the org-wide aggregate scope item when context type is course',
- },
- 'wizard.step2.scopeList.aggregate.label.library': {
- id: 'wizard.step2.scopeList.aggregate.label.library',
- defaultMessage: 'All libraries in this organization',
- description: 'Display name for the org-wide aggregate scope item when context type is library',
- },
-
// ScopeList — loading / empty states
'wizard.step2.scopeList.loading': {
id: 'wizard.step2.scopeList.loading',