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