diff --git a/src/authz-module/components/AddRoleButton.test.tsx b/src/authz-module/components/AddRoleButton.test.tsx
index facfc718..7307eb42 100644
--- a/src/authz-module/components/AddRoleButton.test.tsx
+++ b/src/authz-module/components/AddRoleButton.test.tsx
@@ -2,7 +2,9 @@ import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useNavigate } from 'react-router-dom';
import { initializeMockApp } from '@edx/frontend-platform/testing';
-import { renderWrapper } from '@src/setupTest';
+import { renderWithAllProviders } from '@src/setupTest';
+import { useValidateUserPermissionsNonSuspense } from '@src/data/hooks';
+import { CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS } from '../roles-permissions';
import AddRoleButton from './AddRoleButton';
// Mock react-router-dom navigation
@@ -11,6 +13,20 @@ jest.mock('react-router-dom', () => ({
useNavigate: jest.fn(),
}));
+jest.mock('@src/data/hooks', () => ({
+ ...jest.requireActual('@src/data/hooks'),
+ useValidateUserPermissionsNonSuspense: jest.fn(),
+}));
+
+const mockUseValidatePermissions = useValidateUserPermissionsNonSuspense as jest.Mock;
+const allowAllPermissions = {
+ data: [
+ { action: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM, allowed: true },
+ { action: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM, allowed: true },
+ ],
+ isLoading: false,
+};
+
describe('AddRoleButton', () => {
const mockNavigate = jest.fn();
@@ -26,6 +42,7 @@ describe('AddRoleButton', () => {
beforeEach(() => {
(useNavigate as jest.Mock).mockReturnValue(mockNavigate);
+ mockUseValidatePermissions.mockReturnValue(allowAllPermissions);
});
afterEach(() => {
@@ -34,21 +51,21 @@ describe('AddRoleButton', () => {
describe('rendering', () => {
it('renders the assign role button with correct text', () => {
- renderWrapper();
+ renderWithAllProviders();
const button = screen.getByRole('button', { name: /assign role/i });
expect(button).toBeInTheDocument();
});
it('displays the plus icon', () => {
- renderWrapper();
+ renderWithAllProviders();
const button = screen.getByRole('button', { name: /assign role/i });
expect(button.querySelector('svg')).toBeInTheDocument();
});
it('renders correctly when presetUsername is provided', () => {
- renderWrapper();
+ renderWithAllProviders();
const button = screen.getByRole('button', { name: /assign role/i });
expect(button).toBeInTheDocument();
@@ -58,7 +75,7 @@ describe('AddRoleButton', () => {
describe('navigation behavior', () => {
it('navigates to assign role page without username when clicked', async () => {
const user = userEvent.setup();
- renderWrapper();
+ renderWithAllProviders();
const button = screen.getByRole('button', { name: /assign role/i });
await user.click(button);
@@ -70,7 +87,7 @@ describe('AddRoleButton', () => {
it('navigates to assign role page with username query parameter when presetUsername is provided', async () => {
const user = userEvent.setup();
const presetUsername = 'john.doe';
- renderWrapper();
+ renderWithAllProviders();
const button = screen.getByRole('button', { name: /assign role/i });
await user.click(button);
@@ -82,7 +99,7 @@ describe('AddRoleButton', () => {
it('handles special characters in presetUsername correctly', async () => {
const user = userEvent.setup();
const presetUsername = 'user@example.com';
- renderWrapper();
+ renderWithAllProviders();
const button = screen.getByRole('button', { name: /assign role/i });
await user.click(button);
@@ -95,7 +112,7 @@ describe('AddRoleButton', () => {
describe('user interactions', () => {
it('responds to keyboard navigation', async () => {
const user = userEvent.setup();
- renderWrapper();
+ renderWithAllProviders();
const button = screen.getByRole('button', { name: /assign role/i });
@@ -108,7 +125,7 @@ describe('AddRoleButton', () => {
it('responds to spacebar activation', async () => {
const user = userEvent.setup();
- renderWrapper();
+ renderWithAllProviders();
const button = screen.getByRole('button', { name: /assign role/i });
button.focus();
@@ -119,7 +136,7 @@ describe('AddRoleButton', () => {
it('handles multiple clicks gracefully', async () => {
const user = userEvent.setup();
- renderWrapper();
+ renderWithAllProviders();
const button = screen.getByRole('button', { name: /assign role/i });
@@ -131,4 +148,39 @@ describe('AddRoleButton', () => {
expect(mockNavigate).toHaveBeenCalledWith('/authz/assign-role?users=testuser');
});
});
+
+ describe('permission gating', () => {
+ it('does not render the button when the user cannot manage any team', () => {
+ mockUseValidatePermissions.mockReturnValue({
+ data: [
+ { action: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM, allowed: false },
+ { action: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM, allowed: false },
+ ],
+ isLoading: false,
+ });
+ renderWithAllProviders();
+
+ expect(screen.queryByRole('button', { name: /assign role/i })).not.toBeInTheDocument();
+ });
+
+ it('renders the button when at least one team-management permission is allowed', () => {
+ mockUseValidatePermissions.mockReturnValue({
+ data: [
+ { action: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM, allowed: false },
+ { action: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM, allowed: true },
+ ],
+ isLoading: false,
+ });
+ renderWithAllProviders();
+
+ expect(screen.getByRole('button', { name: /assign role/i })).toBeInTheDocument();
+ });
+
+ it('does not render the button while permissions are loading', () => {
+ mockUseValidatePermissions.mockReturnValue({ data: undefined, isLoading: true });
+ renderWithAllProviders();
+
+ expect(screen.queryByRole('button', { name: /assign role/i })).not.toBeInTheDocument();
+ });
+ });
});
diff --git a/src/authz-module/components/AddRoleButton.tsx b/src/authz-module/components/AddRoleButton.tsx
index fb952aa7..7eedbe50 100644
--- a/src/authz-module/components/AddRoleButton.tsx
+++ b/src/authz-module/components/AddRoleButton.tsx
@@ -5,6 +5,8 @@ import { useNavigate } from 'react-router-dom';
import baseMessages from '@src/authz-module/messages';
import { buildWizardPath } from '@src/authz-module/constants';
+import { useValidateUserPermissionsNonSuspense } from '@src/data/hooks';
+import { MANAGE_TEAM_PERMISSIONS } from '../roles-permissions';
interface AddRoleButtonProps {
presetUsername?: string;
@@ -15,19 +17,23 @@ const AddRoleButton = ({ presetUsername, from }: AddRoleButtonProps) => {
const intl = useIntl();
const navigate = useNavigate();
+ const { data: permissionValidationResponse } = useValidateUserPermissionsNonSuspense(MANAGE_TEAM_PERMISSIONS);
+ const canAssignRole = permissionValidationResponse?.some((permission) => permission.allowed);
+
const handleClick = () => {
const path = buildWizardPath({ from, users: presetUsername });
navigate(path);
};
return (
-
- );
+ canAssignRole ? (
+
+ ) : null);
};
export default AddRoleButton;
diff --git a/src/authz-module/components/TableControlBar/RolesFilter.test.tsx b/src/authz-module/components/TableControlBar/RolesFilter.test.tsx
index a00f2f56..a4be6eb1 100644
--- a/src/authz-module/components/TableControlBar/RolesFilter.test.tsx
+++ b/src/authz-module/components/TableControlBar/RolesFilter.test.tsx
@@ -1,7 +1,21 @@
-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 { useValidateUserPermissionsNonSuspense } from '@src/data/hooks';
+import { CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS } from '@src/authz-module/roles-permissions';
import RolesFilter from './RolesFilter';
+jest.mock('@src/data/hooks', () => ({
+ useValidateUserPermissionsNonSuspense: jest.fn(),
+}));
+
+const mockUsePermissions = useValidateUserPermissionsNonSuspense as jest.Mock;
+
+const permissionsData = ({ library, course }: { library?: boolean; course?: boolean }) => [
+ { action: CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM, allowed: !!library },
+ { action: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM, allowed: !!course },
+];
+
describe('RolesFilter', () => {
const defaultProps = {
filterButtonText: 'Roles',
@@ -10,11 +24,17 @@ describe('RolesFilter', () => {
disabled: false,
};
+ const openDropdown = async (user: ReturnType) => {
+ await user.click(screen.getByRole('button', { name: /Roles/i }));
+ return within(await screen.findByRole('group', { name: 'Roles' }));
+ };
+
beforeEach(() => {
jest.clearAllMocks();
+ mockUsePermissions.mockReturnValue({ data: permissionsData({ library: true, course: true }) });
});
- it('renders without crashing', () => {
+ it('renders the filter toggle', () => {
renderWrapper();
expect(screen.getByText('Roles')).toBeInTheDocument();
});
@@ -29,9 +49,52 @@ describe('RolesFilter', () => {
expect(screen.getByText('Select Roles')).toBeInTheDocument();
});
- it('calls setFilter when filter changes', () => {
- const mockSetFilter = jest.fn();
- renderWrapper();
- expect(screen.getByText('Roles')).toBeInTheDocument();
+ it('calls setFilter with the selected role when a role is checked', async () => {
+ const user = userEvent.setup();
+ const setFilter = jest.fn();
+ renderWrapper();
+ const menu = await openDropdown(user);
+ await user.click(menu.getByLabelText('Course Admin'));
+ expect(setFilter).toHaveBeenCalledWith(
+ ['course_admin'],
+ expect.objectContaining({ value: 'course_admin', displayName: 'Course Admin' }),
+ );
+ });
+
+ it('shows only library roles when the user can view the library scope only', async () => {
+ const user = userEvent.setup();
+ mockUsePermissions.mockReturnValue({ data: permissionsData({ library: true }) });
+ renderWrapper();
+ const menu = await openDropdown(user);
+ expect(menu.getByText('Libraries')).toBeInTheDocument();
+ expect(menu.getByLabelText('Library Admin')).toBeInTheDocument();
+ expect(menu.queryByText('Courses')).not.toBeInTheDocument();
+ expect(menu.queryByLabelText('Course Admin')).not.toBeInTheDocument();
+ });
+
+ it('shows both course and library roles when the user can view both scopes', async () => {
+ const user = userEvent.setup();
+ renderWrapper();
+ const menu = await openDropdown(user);
+ expect(menu.getByText('Courses')).toBeInTheDocument();
+ expect(menu.getByText('Libraries')).toBeInTheDocument();
+ });
+
+ it('shows no role options when the user cannot view any scope', async () => {
+ const user = userEvent.setup();
+ mockUsePermissions.mockReturnValue({ data: permissionsData({}) });
+ renderWrapper();
+ const menu = await openDropdown(user);
+ expect(menu.queryByText('Courses')).not.toBeInTheDocument();
+ expect(menu.queryByText('Libraries')).not.toBeInTheDocument();
+ });
+
+ it('shows no role options while permissions are still loading', async () => {
+ const user = userEvent.setup();
+ mockUsePermissions.mockReturnValue({ data: undefined });
+ renderWrapper();
+ const menu = await openDropdown(user);
+ expect(menu.queryByText('Courses')).not.toBeInTheDocument();
+ expect(menu.queryByText('Libraries')).not.toBeInTheDocument();
});
});
diff --git a/src/authz-module/components/TableControlBar/RolesFilter.tsx b/src/authz-module/components/TableControlBar/RolesFilter.tsx
index d0e061e9..db66a8c2 100644
--- a/src/authz-module/components/TableControlBar/RolesFilter.tsx
+++ b/src/authz-module/components/TableControlBar/RolesFilter.tsx
@@ -1,6 +1,9 @@
import { useMemo } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Person } from '@openedx/paragon/icons';
+import { useValidateUserPermissionsNonSuspense } from '@src/data/hooks';
+import { CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS } from '@src/authz-module/roles-permissions';
+import { CONTEXT_TYPES } from '@src/authz-module/constants';
import MultipleChoiceFilter from './MultipleChoiceFilter';
import { MultipleChoiceFilterProps } from './types';
import { getRolesFiltersOptions } from '../constants';
@@ -11,7 +14,27 @@ const RolesFilter = ({
filterButtonText, filterValue, setFilter, disabled,
}: RolesFilterProps) => {
const intl = useIntl();
- const rolesOptions = useMemo(() => getRolesFiltersOptions(intl), [intl]);
+ const { data: permissions } = useValidateUserPermissionsNonSuspense([
+ { action: CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM },
+ { action: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM },
+ ]);
+
+ // Only show role groups for the domains the user can view. Global roles stay
+ // hidden until a platform-wide permission is available to gate them on.
+ const allowedContexts = useMemo(() => {
+ const contexts = new Set();
+ permissions?.forEach((p) => {
+ if (!p.allowed) { return; }
+ if (p.action === CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM) { contexts.add(CONTEXT_TYPES.LIBRARY); }
+ if (p.action === CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM) { contexts.add(CONTEXT_TYPES.COURSE); }
+ });
+ return contexts;
+ }, [permissions]);
+
+ const rolesOptions = useMemo(
+ () => getRolesFiltersOptions(intl).filter((option) => allowedContexts.has(option.contextType)),
+ [intl, allowedContexts],
+ );
return (
({
useScopes: () => ({ data: { results: [] } }),
}));
+// RolesFilter validates view-team permissions to decide which role groups to show.
+// Grant both so the full course/library role set renders for these wiring tests.
+jest.mock('@src/data/hooks', () => {
+ const { CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS } = jest.requireActual('@src/authz-module/roles-permissions');
+ return {
+ useValidateUserPermissionsNonSuspense: () => ({
+ data: [
+ { action: CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM, allowed: true },
+ { action: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM, allowed: true },
+ ],
+ }),
+ };
+});
+
describe('TableControlBar', () => {
const mockDataTableContext = {
columns: mockColumns,
@@ -90,9 +104,9 @@ describe('TableControlBar', () => {
const rolesButton = screen.getByText('Select Roles');
expect(rolesButton).toBeInTheDocument();
await user.click(rolesButton);
- const superAdminOption = screen.getByRole('checkbox', { name: /Super Admin/i });
- expect(superAdminOption).toBeInTheDocument();
- await user.click(superAdminOption);
+ const courseAdminOption = screen.getByRole('checkbox', { name: /Course Admin/i });
+ expect(courseAdminOption).toBeInTheDocument();
+ await user.click(courseAdminOption);
expect(contextWithRolesFilter.columns[0].setFilter).toHaveBeenCalled();
});
diff --git a/src/authz-module/components/constants.ts b/src/authz-module/components/constants.ts
index 21f43bf0..d2afdcae 100644
--- a/src/authz-module/components/constants.ts
+++ b/src/authz-module/components/constants.ts
@@ -8,12 +8,14 @@ export const getRolesFiltersOptions = (intl: IntlShape) => [
groupIcon: Language,
displayName: 'Super Admin',
value: 'super_admin',
+ contextType: 'global',
},
{
groupName: intl.formatMessage(messages['authz.team.members.table.group.global']),
groupIcon: Language,
displayName: 'Global Staff',
value: 'global_staff',
+ contextType: 'global',
},
{
@@ -21,24 +23,28 @@ export const getRolesFiltersOptions = (intl: IntlShape) => [
groupIcon: School,
displayName: 'Course Admin',
value: 'course_admin',
+ contextType: 'course',
},
{
groupName: intl.formatMessage(messages['authz.team.members.table.group.courses']),
groupIcon: School,
displayName: 'Course Staff',
value: 'course_staff',
+ contextType: 'course',
},
{
groupName: intl.formatMessage(messages['authz.team.members.table.group.courses']),
groupIcon: School,
displayName: 'Course Editor',
value: 'course_editor',
+ contextType: 'course',
},
{
groupName: intl.formatMessage(messages['authz.team.members.table.group.courses']),
groupIcon: School,
displayName: 'Course Auditor',
value: 'course_auditor',
+ contextType: 'course',
},
{
@@ -46,24 +52,28 @@ export const getRolesFiltersOptions = (intl: IntlShape) => [
groupIcon: LibraryBooks,
displayName: 'Library Admin',
value: 'library_admin',
+ contextType: 'library',
},
{
groupName: intl.formatMessage(messages['authz.team.members.table.group.libraries']),
groupIcon: LibraryBooks,
displayName: 'Library Author',
value: 'library_author',
+ contextType: 'library',
},
{
groupName: intl.formatMessage(messages['authz.team.members.table.group.libraries']),
groupIcon: LibraryBooks,
displayName: 'Library Contributor',
value: 'library_contributor',
+ contextType: 'library',
},
{
groupName: intl.formatMessage(messages['authz.team.members.table.group.libraries']),
groupIcon: LibraryBooks,
displayName: 'Library User',
value: 'library_user',
+ contextType: 'library',
},
];
diff --git a/src/authz-module/constants.ts b/src/authz-module/constants.ts
index a3dc353a..f26940cb 100644
--- a/src/authz-module/constants.ts
+++ b/src/authz-module/constants.ts
@@ -67,9 +67,9 @@ export const DEFAULT_FILTER_PAGE_SIZE = 5;
export const ADMIN_ROLES = ['course_admin', 'library_admin'];
// Resource Type Definitions
-export const RESOURCE_TYPES = {
+export const CONTEXT_TYPES = {
LIBRARY: 'library',
COURSE: 'course',
} as const;
-export type ResourceType = typeof RESOURCE_TYPES[keyof typeof RESOURCE_TYPES];
+export type ResourceType = typeof CONTEXT_TYPES[keyof typeof CONTEXT_TYPES];
diff --git a/src/authz-module/role-assignation-wizard/AssignRoleWizard.test.tsx b/src/authz-module/role-assignation-wizard/AssignRoleWizard.test.tsx
index 70b65f1d..f1c3becc 100644
--- a/src/authz-module/role-assignation-wizard/AssignRoleWizard.test.tsx
+++ b/src/authz-module/role-assignation-wizard/AssignRoleWizard.test.tsx
@@ -7,8 +7,11 @@ import {
useValidateUsers, useAssignTeamMembersRole, useScopes, useOrgs,
} from '../data/hooks';
import useScopePermissions from './hooks/useScopePermissions';
+import { courseRolesMetadata, libraryRolesMetadata } from '../roles-permissions';
import AssignRoleWizard from './AssignRoleWizard';
+const allRolesMetadata = [...courseRolesMetadata, ...libraryRolesMetadata];
+
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedUser: jest.fn(),
}));
@@ -76,7 +79,7 @@ const oneOrg = [
const renderWizard = (props = {}) => renderWrapper(
-
+
,
);
@@ -117,6 +120,12 @@ describe('AssignRoleWizard — Step 1', () => {
expect(getNextButton()).toBeDisabled();
});
+ it('renders no role options and keeps Next disabled when roles is empty', () => {
+ renderWizard({ roles: [] });
+ expect(screen.queryByRole('radio')).not.toBeInTheDocument();
+ expect(getNextButton()).toBeDisabled();
+ });
+
it('selecting a different role replaces the previous selection', async () => {
const user = userEvent.setup();
renderWizard();
diff --git a/src/authz-module/role-assignation-wizard/AssignRoleWizard.tsx b/src/authz-module/role-assignation-wizard/AssignRoleWizard.tsx
index d15adab4..9a327260 100644
--- a/src/authz-module/role-assignation-wizard/AssignRoleWizard.tsx
+++ b/src/authz-module/role-assignation-wizard/AssignRoleWizard.tsx
@@ -10,14 +10,10 @@ import { RoleMetadata } from '@src/types';
import { useToastManager } from '@src/components/ToastManager/ToastManagerContext';
import SelectUsersAndRoleStep from './components/SelectUsersAndRoleStep';
import DefineApplicationScopeStep from './components/DefineApplicationScopeStep';
-import { libraryRolesMetadata } from '../roles-permissions/library/constants';
-import { courseRolesMetadata } from '../roles-permissions/course/constants';
import { useValidateUsers, useAssignTeamMembersRole } from '../data/hooks';
import messages from './messages';
import { formatRoleAssignmentError } from './utils';
-const allRolesMetadata = [...courseRolesMetadata, ...libraryRolesMetadata];
-
const STEPS = {
SELECT_USERS_AND_ROLE: 'select-users-and-role',
DEFINE_APPLICATION_SCOPE: 'define-application-scope',
@@ -28,8 +24,6 @@ type StepKey = typeof STEPS[keyof typeof STEPS];
interface AssignRoleWizardProps {
onClose: () => void;
initialUsers?: string;
- /** Filtered role list. Defaults to all roles (course + library). Pass a subset
- * once a permission-lookup API is available to hide groups the caller cannot assign. */
roles?: RoleMetadata[];
}
@@ -48,7 +42,7 @@ const getInitialState = (initialUsers: string) => ({
});
const AssignRoleWizard = ({
- onClose, initialUsers = '', roles = allRolesMetadata,
+ onClose, initialUsers = '', roles = [],
}: AssignRoleWizardProps) => {
const intl = useIntl();
const { showToast, showErrorToast } = useToastManager();
diff --git a/src/authz-module/role-assignation-wizard/AssignRoleWizardPage.test.tsx b/src/authz-module/role-assignation-wizard/AssignRoleWizardPage.test.tsx
index a544972b..4197518d 100644
--- a/src/authz-module/role-assignation-wizard/AssignRoleWizardPage.test.tsx
+++ b/src/authz-module/role-assignation-wizard/AssignRoleWizardPage.test.tsx
@@ -1,8 +1,15 @@
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
-import { renderWrapper } from '@src/setupTest';
+import { renderWithAllProviders } from '@src/setupTest';
import { ToastManagerProvider } from '@src/components/ToastManager/ToastManagerContext';
+import { useValidateUserPermissionsNonSuspense } from '@src/data/hooks';
import { useValidateUsers } from '../data/hooks';
+import {
+ CONTENT_COURSE_PERMISSIONS,
+ CONTENT_LIBRARY_PERMISSIONS,
+ courseRolesMetadata,
+ libraryRolesMetadata,
+} from '../roles-permissions';
import AssignRoleWizardPage from './AssignRoleWizardPage';
jest.mock('@edx/frontend-platform/logging');
@@ -27,7 +34,20 @@ jest.mock('@edx/frontend-component-header', () => ({
StudioHeader: () => null,
}));
+jest.mock('@src/data/hooks', () => ({
+ ...jest.requireActual('@src/data/hooks'),
+ useValidateUserPermissionsNonSuspense: jest.fn(),
+}));
+
const mockUseValidateUsers = useValidateUsers as jest.Mock;
+const mockUseValidatePermissions = useValidateUserPermissionsNonSuspense as jest.Mock;
+const allowAllPermissions = {
+ data: [
+ { action: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM, allowed: true },
+ { action: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM, allowed: true },
+ ],
+ isLoading: false,
+};
const setupMocks = ({ users = '', from = '' } = {}) => {
const { useSearchParams, useNavigate } = jest.requireMock('react-router-dom');
@@ -40,7 +60,7 @@ const setupMocks = ({ users = '', from = '' } = {}) => {
return { navigate };
};
-const renderPage = () => renderWrapper(
+const renderPage = () => renderWithAllProviders(
,
@@ -53,6 +73,7 @@ describe('AssignRoleWizardPage', () => {
mutateAsync: jest.fn(),
isPending: false,
});
+ mockUseValidatePermissions.mockReturnValue(allowAllPermissions);
});
it('renders the page with the wizard and title', () => {
@@ -107,4 +128,64 @@ describe('AssignRoleWizardPage', () => {
await user.click(screen.getByRole('button', { name: /Cancel/i }));
expect(navigate).toHaveBeenCalledWith('/authz/team');
});
+
+ describe('assignable roles', () => {
+ // Each entry maps an allowed permission to the role metadata the wizard should
+ // surface for it. Add a row here as more scopes/roles are introduced.
+ const scopeRoles = [
+ { action: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM, roles: libraryRolesMetadata },
+ { action: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM, roles: courseRolesMetadata },
+ ];
+ const allRoles = scopeRoles.flatMap(({ roles }) => roles);
+
+ it('shows the roles for every allowed scope', () => {
+ setupMocks();
+ renderPage();
+ allRoles.forEach((role) => {
+ expect(screen.getByText(role.name)).toBeInTheDocument();
+ });
+ });
+
+ it.each(scopeRoles)('shows only the roles for the allowed scope %#', ({ action, roles }) => {
+ mockUseValidatePermissions.mockReturnValue({
+ data: scopeRoles.map((scope) => ({ action: scope.action, allowed: scope.action === action })),
+ isLoading: false,
+ });
+ setupMocks();
+ renderPage();
+
+ roles.forEach((role) => {
+ expect(screen.getByText(role.name)).toBeInTheDocument();
+ });
+ allRoles
+ .filter((role) => !roles.includes(role))
+ .forEach((role) => {
+ expect(screen.queryByText(role.name)).not.toBeInTheDocument();
+ });
+ });
+
+ it('shows no roles when no scope is allowed', () => {
+ mockUseValidatePermissions.mockReturnValue({
+ data: scopeRoles.map(({ action }) => ({ action, allowed: false })),
+ isLoading: false,
+ });
+ setupMocks();
+ renderPage();
+ allRoles.forEach((role) => {
+ expect(screen.queryByText(role.name)).not.toBeInTheDocument();
+ });
+ });
+
+ it('ignores allowed permissions whose action is not a known role scope', () => {
+ mockUseValidatePermissions.mockReturnValue({
+ data: [{ action: 'some.unrelated.permission', allowed: true }],
+ isLoading: false,
+ });
+ setupMocks();
+ renderPage();
+ allRoles.forEach((role) => {
+ expect(screen.queryByText(role.name)).not.toBeInTheDocument();
+ });
+ });
+ });
});
diff --git a/src/authz-module/role-assignation-wizard/AssignRoleWizardPage.tsx b/src/authz-module/role-assignation-wizard/AssignRoleWizardPage.tsx
index df3ec26d..58cc7f20 100644
--- a/src/authz-module/role-assignation-wizard/AssignRoleWizardPage.tsx
+++ b/src/authz-module/role-assignation-wizard/AssignRoleWizardPage.tsx
@@ -1,9 +1,14 @@
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n';
+import { useValidateUserPermissionsNonSuspense } from '@src/data/hooks';
import AssignRoleWizard from './AssignRoleWizard';
import AuthZLayout from '../components/AuthZLayout';
import { ROUTES } from '../constants';
import messages from './messages';
+import {
+ CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS, courseRolesMetadata, libraryRolesMetadata,
+ MANAGE_TEAM_PERMISSIONS,
+} from '../roles-permissions';
const AssignRoleWizardPage = () => {
const intl = useIntl();
@@ -18,6 +23,15 @@ const AssignRoleWizardPage = () => {
? `${ROUTES.HOME_PATH}/user/${presetUser}`
: returnTo;
+ const { data: permissionValidationResponse } = useValidateUserPermissionsNonSuspense(MANAGE_TEAM_PERMISSIONS);
+
+ const rolesAssignable = permissionValidationResponse?.flatMap((p) => {
+ if (!p.allowed) { return []; }
+ if (p.action === CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM) { return libraryRolesMetadata; }
+ if (p.action === CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM) { return courseRolesMetadata; }
+ return [];
+ });
+
return (
{
pageSubtitle=""
actions={[]}
>
- {/* TODO: pass a filtered `roles` prop once the permission-lookup API is available,
- so the wizard only shows role groups the current user can assign. */}
navigate(destination)}
initialUsers={initialUsers}
+ roles={rolesAssignable}
/>
);
diff --git a/src/authz-module/roles-permissions/RolesPermissions.test.tsx b/src/authz-module/roles-permissions/RolesPermissions.test.tsx
index a29fa0ae..db7bdd26 100644
--- a/src/authz-module/roles-permissions/RolesPermissions.test.tsx
+++ b/src/authz-module/roles-permissions/RolesPermissions.test.tsx
@@ -16,27 +16,6 @@ jest.mock('./library/utils', () => ({
]),
}));
-// Mock constants
-jest.mock('./course/constants', () => ({
- rolesObject: [
- {
- name: 'Course Admin', role: 'admin', permissions: [], userCount: 1,
- },
- ],
- coursePermissions: [],
- courseResourceTypes: [],
-}));
-
-jest.mock('./library/constants', () => ({
- rolesLibraryObject: [
- {
- name: 'Library Admin', role: 'admin', permissions: [], userCount: 1,
- },
- ],
- libraryPermissions: [],
- libraryResourceTypes: [],
-}));
-
jest.mock('@openedx/paragon', () => ({
...jest.requireActual('@openedx/paragon'),
Hyperlink: ({ children, ...props }:
diff --git a/src/authz-module/roles-permissions/index.ts b/src/authz-module/roles-permissions/index.ts
index 690fa56a..54ef22cc 100644
--- a/src/authz-module/roles-permissions/index.ts
+++ b/src/authz-module/roles-permissions/index.ts
@@ -1,3 +1,6 @@
+import { CONTENT_COURSE_PERMISSIONS } from './course/constants';
+import { CONTENT_LIBRARY_PERMISSIONS } from './library/constants';
+
export {
CONTENT_LIBRARY_PERMISSIONS,
libraryResourceTypes,
@@ -13,3 +16,8 @@ export {
rolesObject,
courseRolesMetadata,
} from './course/constants';
+
+export const MANAGE_TEAM_PERMISSIONS: { action: string }[] = [
+ { action: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM },
+ { action: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM },
+];