diff --git a/src/CourseAuthoringPage.jsx b/src/CourseAuthoringPage.jsx index c442f9406..c6feb9ebb 100644 --- a/src/CourseAuthoringPage.jsx +++ b/src/CourseAuthoringPage.jsx @@ -14,6 +14,8 @@ import PermissionDeniedAlert from './generic/PermissionDeniedAlert'; import { getCourseAppsApiStatus } from './pages-and-resources/data/selectors'; import { RequestStatus } from './data/constants'; import Loading from './generic/Loading'; +import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from './generic/data/thunks'; +import { getUserPermissions } from './generic/data/selectors'; const AppHeader = ({ courseNumber, courseOrg, courseTitle, courseId, @@ -40,9 +42,14 @@ AppHeader.defaultProps = { const CourseAuthoringPage = ({ courseId, children }) => { const dispatch = useDispatch(); + const userPermissions = useSelector(getUserPermissions); useEffect(() => { dispatch(fetchCourseDetail(courseId)); + dispatch(fetchUserPermissionsEnabledFlag()); + if (!userPermissions) { + dispatch(fetchUserPermissionsQuery(courseId)); + } }, [courseId]); const courseDetail = useModel('courseDetails', courseId); diff --git a/src/advanced-settings/AdvancedSettings.jsx b/src/advanced-settings/AdvancedSettings.jsx index 7611593a9..19452d2b3 100644 --- a/src/advanced-settings/AdvancedSettings.jsx +++ b/src/advanced-settings/AdvancedSettings.jsx @@ -25,6 +25,9 @@ import validateAdvancedSettingsData from './utils'; import messages from './messages'; import ModalError from './modal-error/ModalError'; import getPageHeadTitle from '../generic/utils'; +import { useUserPermissions } from '../generic/hooks'; +import { getUserPermissionsEnabled } from '../generic/data/selectors'; +import PermissionDeniedAlert from '../generic/PermissionDeniedAlert'; const AdvancedSettings = ({ intl, courseId }) => { const dispatch = useDispatch(); @@ -41,6 +44,13 @@ const AdvancedSettings = ({ intl, courseId }) => { const courseDetails = useModel('courseDetails', courseId); document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle)); + const { checkPermission } = useUserPermissions(); + const userPermissionsEnabled = useSelector(getUserPermissionsEnabled); + const viewOnly = checkPermission('view_course_settings'); + const showPermissionDeniedAlert = userPermissionsEnabled && ( + !checkPermission('manage_advanced_settings') && !checkPermission('view_course_settings') + ); + useEffect(() => { dispatch(fetchCourseAppSettings(courseId)); dispatch(fetchProctoringExamErrors(courseId)); @@ -83,6 +93,11 @@ const AdvancedSettings = ({ intl, courseId }) => { // eslint-disable-next-line react/jsx-no-useless-fragment return <>; } + if (showPermissionDeniedAlert) { + return ( + + ); + } if (loadingSettingsStatus === RequestStatus.DENIED) { return (
@@ -215,6 +230,7 @@ const AdvancedSettings = ({ intl, courseId }) => { handleBlur={handleSettingBlur} isEditableState={isEditableState} setIsEditableState={setIsEditableState} + disableForm={viewOnly} /> ); })} diff --git a/src/advanced-settings/AdvancedSettings.test.jsx b/src/advanced-settings/AdvancedSettings.test.jsx index cc5144c64..3f4f5fd2a 100644 --- a/src/advanced-settings/AdvancedSettings.test.jsx +++ b/src/advanced-settings/AdvancedSettings.test.jsx @@ -3,7 +3,11 @@ import { initializeMockApp } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n'; import { AppProvider } from '@edx/frontend-platform/react'; -import { render, fireEvent, waitFor } from '@testing-library/react'; +import { + render, + fireEvent, + waitFor, +} from '@testing-library/react'; import MockAdapter from 'axios-mock-adapter'; import initializeStore from '../store'; @@ -13,11 +17,15 @@ import { getCourseAdvancedSettingsApiUrl } from './data/api'; import { updateCourseAppSetting } from './data/thunks'; import AdvancedSettings from './AdvancedSettings'; import messages from './messages'; +import { getUserPermissionsUrl, getUserPermissionsEnabledFlagUrl } from '../generic/data/api'; +import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../generic/data/thunks'; let axiosMock; let store; const mockPathname = '/foo-bar'; const courseId = '123'; +const userId = 3; +const userPermissionsData = { permissions: ['view_course_settings', 'manage_advanced_settings'] }; // Mock the TextareaAutosize component jest.mock('react-textarea-autosize', () => jest.fn((props) => ( @@ -43,11 +51,23 @@ const RootWrapper = () => ( ); +const permissionsMockStore = async (permissions) => { + axiosMock.onGet(getUserPermissionsUrl(courseId, userId)).reply(200, permissions); + axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true }); + await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch); + await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch); +}; + +const permissionDisabledMockStore = async () => { + axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: false }); + await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch); +}; + describe('', () => { beforeEach(() => { initializeMockApp({ authenticatedUser: { - userId: 3, + userId, username: 'abc123', administrator: true, roles: [], @@ -58,7 +78,9 @@ describe('', () => { axiosMock .onGet(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`) .reply(200, advancedSettingsMock); + permissionsMockStore(userPermissionsData); }); + it('should render without errors', async () => { const { getByText } = render(); await waitFor(() => { @@ -161,4 +183,29 @@ describe('', () => { await executeThunk(updateCourseAppSetting(courseId, [3, 2, 1]), store.dispatch); expect(getByText('Your policy changes have been saved.')).toBeInTheDocument(); }); + it('should shows the PermissionDeniedAlert when there are not the right user permissions', async () => { + const permissionsData = { permissions: ['view'] }; + await permissionsMockStore(permissionsData); + + const { queryByText } = render(); + await waitFor(() => { + const permissionDeniedAlert = queryByText('You are not authorized to view this page. If you feel you should have access, please reach out to your course team admin to be given access.'); + expect(permissionDeniedAlert).toBeInTheDocument(); + }); + }); + it('should not show the PermissionDeniedAlert when the User Permissions Flag is not enabled', async () => { + await permissionDisabledMockStore(); + + const { queryByText } = render(); + const permissionDeniedAlert = queryByText('You are not authorized to view this page. If you feel you should have access, please reach out to your course team admin to be given access.'); + expect(permissionDeniedAlert).not.toBeInTheDocument(); + }); + it('should be view only if the permission is set for viewOnly', async () => { + const permissions = { permissions: ['view_course_settings'] }; + await permissionsMockStore(permissions); + const { getByLabelText } = render(); + await waitFor(() => { + expect(getByLabelText('Advanced Module List')).toBeDisabled(); + }); + }); }); diff --git a/src/advanced-settings/setting-card/SettingCard.jsx b/src/advanced-settings/setting-card/SettingCard.jsx index e522963f8..bc1cf89dd 100644 --- a/src/advanced-settings/setting-card/SettingCard.jsx +++ b/src/advanced-settings/setting-card/SettingCard.jsx @@ -27,6 +27,7 @@ const SettingCard = ({ setIsEditableState, // injected intl, + disableForm, }) => { const { deprecated, help, displayName } = settingData; const initialValue = JSON.stringify(settingData.value, null, 4); @@ -100,6 +101,7 @@ const SettingCard = ({ onChange={handleSettingChange} aria-label={displayName} onBlur={handleCardBlur} + disabled={disableForm} /> @@ -135,6 +137,7 @@ SettingCard.propTypes = { saveSettingsPrompt: PropTypes.bool.isRequired, isEditableState: PropTypes.bool.isRequired, setIsEditableState: PropTypes.func.isRequired, + disableForm: PropTypes.bool.isRequired, }; export default injectIntl(SettingCard); diff --git a/src/course-team/CourseTeam.jsx b/src/course-team/CourseTeam.jsx index 937a76620..48282fdd0 100644 --- a/src/course-team/CourseTeam.jsx +++ b/src/course-team/CourseTeam.jsx @@ -18,12 +18,14 @@ import AddUserForm from './add-user-form/AddUserForm'; import AddTeamMember from './add-team-member/AddTeamMember'; import CourseTeamMember from './course-team-member/CourseTeamMember'; import InfoModal from './info-modal/InfoModal'; -import { useCourseTeam, useUserPermissions } from './hooks'; +import { useCourseTeam } from './hooks'; +import { useUserPermissions } from '../generic/hooks'; import getPageHeadTitle from '../generic/utils'; const CourseTeam = ({ courseId }) => { const intl = useIntl(); const courseDetails = useModel('courseDetails', courseId); + document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle)); const { @@ -55,10 +57,10 @@ const CourseTeam = ({ courseId }) => { } = useCourseTeam({ intl, courseId }); const { - hasPermissions, + checkPermission, } = useUserPermissions(); - const hasManageAllUsersPerm = hasPermissions('manage_all_users'); + const hasManageAllUsersPerm = checkPermission('manage_all_users'); if (isLoading) { // eslint-disable-next-line react/jsx-no-useless-fragment diff --git a/src/course-team/CourseTeam.test.jsx b/src/course-team/CourseTeam.test.jsx index 7bff97e08..c770808da 100644 --- a/src/course-team/CourseTeam.test.jsx +++ b/src/course-team/CourseTeam.test.jsx @@ -15,6 +15,7 @@ import initializeStore from '../store'; import { courseTeamMock, courseTeamWithOneUser, courseTeamWithoutUsers } from './__mocks__'; import { getCourseTeamApiUrl, updateCourseTeamUserApiUrl } from './data/api'; import { getUserPermissionsUrl, getUserPermissionsEnabledFlagUrl } from '../generic/data/api'; +import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../generic/data/thunks'; import CourseTeam from './CourseTeam'; import messages from './messages'; import { USER_ROLES } from '../constants'; @@ -180,7 +181,7 @@ describe('', () => { }); }); - it('not displays "Add New Member" and AddTeamMember component when isAllowActions or hasManageAllUsersPerm is false', async () => { + it('not displays "Add New Member" and AddTeamMember component when isAllowActions is false and hasManageAllUsersPerm is false', async () => { cleanup(); axiosMock .onGet(getCourseTeamApiUrl(courseId)) @@ -190,13 +191,37 @@ describe('', () => { }); axiosMock .onGet(getUserPermissionsEnabledFlagUrl) - .reply(200, { enabled: false }); + .reply(200, { enabled: true }); - const { queryByRole, queryByTestId } = render(); + const { queryByRole, queryByText } = render(); await waitFor(() => { expect(queryByRole('button', { name: messages.addNewMemberButton.defaultMessage })).not.toBeInTheDocument(); - expect(queryByTestId('add-team-member')).not.toBeInTheDocument(); + expect(queryByText('add-team-member')).not.toBeInTheDocument(); + }); + }); + + it('displays "Add New Member" and AddTeamMember component when hasManageAllUsersPerm is true', async () => { + cleanup(); + axiosMock + .onGet(getCourseTeamApiUrl(courseId)) + .reply(200, { + ...courseTeamWithOneUser, + allowActions: false, + }); + axiosMock + .onGet(getUserPermissionsEnabledFlagUrl) + .reply(200, { enabled: true }); + axiosMock + .onGet(getUserPermissionsUrl(courseId, userId)) + .reply(200, userPermissionsData); + await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch); + await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch); + + const { getByRole } = render(); + + await waitFor(() => { + expect(getByRole('button', { name: messages.addNewMemberButton.defaultMessage })).toBeInTheDocument(); }); }); diff --git a/src/course-team/hooks.jsx b/src/course-team/hooks.jsx index 6ae70d452..6fceb5aac 100644 --- a/src/course-team/hooks.jsx +++ b/src/course-team/hooks.jsx @@ -6,8 +6,6 @@ import { useToggle } from '@edx/paragon'; import { USER_ROLES } from '../constants'; import { RequestStatus } from '../data/constants'; import { useModel } from '../generic/model-store'; -import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../generic/data/thunks'; -import { getUserPermissions, getUserPermissionsEnabled } from '../generic/data/selectors'; import { changeRoleTeamUserQuery, createCourseTeamQuery, @@ -98,8 +96,6 @@ const useCourseTeam = ({ courseId }) => { useEffect(() => { dispatch(fetchCourseTeamQuery(courseId)); - dispatch(fetchUserPermissionsEnabledFlag()); - dispatch(fetchUserPermissionsQuery(courseId)); }, [courseId]); useEffect(() => { @@ -139,20 +135,5 @@ const useCourseTeam = ({ courseId }) => { }; }; -const useUserPermissions = () => { - const userPermissionsEnabled = useSelector(getUserPermissionsEnabled); - const userPermissions = useSelector(getUserPermissions); - const hasPermissions = (checkPermissions) => { - if (userPermissionsEnabled) { - return userPermissions?.includes(checkPermissions); - } - return false; - }; - - return { - hasPermissions, - }; -}; - // eslint-disable-next-line import/prefer-default-export -export { useCourseTeam, useUserPermissions }; +export { useCourseTeam }; diff --git a/src/course-updates/CourseUpdates.jsx b/src/course-updates/CourseUpdates.jsx index ce63ade49..85b6b7db9 100644 --- a/src/course-updates/CourseUpdates.jsx +++ b/src/course-updates/CourseUpdates.jsx @@ -8,12 +8,12 @@ import { } from '@edx/paragon'; import { Add as AddIcon } from '@edx/paragon/icons'; import { useSelector } from 'react-redux'; - import { useModel } from '../generic/model-store'; import { getProcessingNotification } from '../generic/processing-notification/data/selectors'; import ProcessingNotification from '../generic/processing-notification'; import SubHeader from '../generic/sub-header/SubHeader'; import InternetConnectionAlert from '../generic/internet-connection-alert'; +import PermissionDeniedAlert from '../generic/PermissionDeniedAlert'; import { RequestStatus } from '../data/constants'; import CourseHandouts from './course-handouts/CourseHandouts'; import CourseUpdate from './course-update/CourseUpdate'; @@ -25,6 +25,8 @@ import { useCourseUpdates } from './hooks'; import { getLoadingStatuses, getSavingStatuses } from './data/selectors'; import { matchesAnyStatus } from './utils'; import getPageHeadTitle from '../generic/utils'; +import { getUserPermissionsEnabled } from '../generic/data/selectors'; +import { useUserPermissions } from '../generic/hooks'; const CourseUpdates = ({ courseId }) => { const intl = useIntl(); @@ -60,7 +62,15 @@ const CourseUpdates = ({ courseId }) => { const anyStatusFailed = matchesAnyStatus({ ...loadingStatuses, ...savingStatuses }, RequestStatus.FAILED); const anyStatusInProgress = matchesAnyStatus({ ...loadingStatuses, ...savingStatuses }, RequestStatus.IN_PROGRESS); const anyStatusPending = matchesAnyStatus({ ...loadingStatuses, ...savingStatuses }, RequestStatus.PENDING); + const { checkPermission } = useUserPermissions(); + const userPermissionsEnabled = useSelector(getUserPermissionsEnabled); + const showPermissionDeniedAlert = userPermissionsEnabled && !checkPermission('manage_content'); + if (showPermissionDeniedAlert) { + return ( + + ); + } return ( <> diff --git a/src/course-updates/CourseUpdates.test.jsx b/src/course-updates/CourseUpdates.test.jsx index d136665e8..9be8cc4fc 100644 --- a/src/course-updates/CourseUpdates.test.jsx +++ b/src/course-updates/CourseUpdates.test.jsx @@ -22,11 +22,16 @@ import { executeThunk } from '../utils'; import { courseUpdatesMock, courseHandoutsMock } from './__mocks__'; import CourseUpdates from './CourseUpdates'; import messages from './messages'; +import { getUserPermissionsUrl, getUserPermissionsEnabledFlagUrl } from '../generic/data/api'; +import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../generic/data/thunks'; let axiosMock; let store; const mockPathname = '/foo-bar'; const courseId = '123'; +const userId = 3; +const userPermissionsData = { permissions: ['manage_content'] }; +const wrongUserPermissionsData = { permissions: ['wrong_permission'] }; jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), @@ -61,7 +66,7 @@ const RootWrapper = () => ( ); describe('', () => { - beforeEach(() => { + beforeEach(async () => { initializeMockApp({ authenticatedUser: { userId: 3, @@ -79,6 +84,7 @@ describe('', () => { axiosMock .onGet(getCourseHandoutApiUrl(courseId)) .reply(200, courseHandoutsMock); + axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: false }); }); it('render CourseUpdates component correctly', async () => { @@ -162,6 +168,26 @@ describe('', () => { expect(queryByText(courseHandoutsMock.data)).not.toBeInTheDocument(); }); + it('should shows PermissionDeniedAlert if there are no right User Permissions', async () => { + const { getByTestId } = render(); + + axiosMock.onGet(getUserPermissionsUrl(courseId, userId)).reply(200, wrongUserPermissionsData); + axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true }); + await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch); + await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch); + expect(getByTestId('permissionDeniedAlert')).toBeVisible(); + }); + + it('should not show PermissionDeniedAlert if User Permissions are the correct ones', async () => { + const { queryByTestId } = render(); + + axiosMock.onGet(getUserPermissionsUrl(courseId, userId)).reply(200, userPermissionsData); + axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true }); + await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch); + await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch); + expect(queryByTestId('permissionDeniedAlert')).not.toBeInTheDocument(); + }); + it('Add new update form is visible after clicking "New update" button', async () => { const { getByText, getByRole, getAllByTestId } = render(); diff --git a/src/files-and-videos/files-page/FilesPage.jsx b/src/files-and-videos/files-page/FilesPage.jsx index 1d9c7872b..82f64c040 100644 --- a/src/files-and-videos/files-page/FilesPage.jsx +++ b/src/files-and-videos/files-page/FilesPage.jsx @@ -30,6 +30,9 @@ import { import { getFileSizeToClosestByte } from '../../utils'; import FileThumbnail from './FileThumbnail'; import FileInfoModalSidebar from './FileInfoModalSidebar'; +import { useUserPermissions } from '../../generic/hooks'; +import { getUserPermissionsEnabled } from '../../generic/data/selectors'; +import PermissionDeniedAlert from '../../generic/PermissionDeniedAlert'; const FilesPage = ({ courseId, @@ -39,6 +42,9 @@ const FilesPage = ({ const dispatch = useDispatch(); const courseDetails = useModel('courseDetails', courseId); document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.heading)); + const { checkPermission } = useUserPermissions(); + const userPermissionsEnabled = useSelector(getUserPermissionsEnabled); + const showPermissionDeniedAlert = userPermissionsEnabled && !checkPermission('manage_content'); useEffect(() => { dispatch(fetchAssets(courseId)); @@ -160,6 +166,11 @@ const FilesPage = ({ { ...accessColumn }, ]; + if (showPermissionDeniedAlert) { + return ( + + ); + } if (loadingStatus === RequestStatus.DENIED) { return (
diff --git a/src/files-and-videos/files-page/FilesPage.test.jsx b/src/files-and-videos/files-page/FilesPage.test.jsx index ec15f993f..a4dba005d 100644 --- a/src/files-and-videos/files-page/FilesPage.test.jsx +++ b/src/files-and-videos/files-page/FilesPage.test.jsx @@ -39,10 +39,16 @@ import { } from './data/thunks'; import { getAssetsUrl } from './data/api'; import messages from '../generic/messages'; +import { getUserPermissionsUrl, getUserPermissionsEnabledFlagUrl } from '../../generic/data/api'; +import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../../generic/data/thunks'; let axiosMock; let store; let file; +const userId = 3; +const wrongUserPermissionsData = { permissions: ['wrong_permission'] }; +const userPermissionsData = { permissions: ['manage_content'] }; + ReactDOM.createPortal = jest.fn(node => node); jest.mock('file-saver'); @@ -68,6 +74,8 @@ const mockStore = async ( } renderComponent(); await executeThunk(fetchAssets(courseId), store.dispatch); + await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch); + await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch); }; const emptyMockStore = async (status) => { @@ -75,6 +83,27 @@ const emptyMockStore = async (status) => { axiosMock.onGet(fetchAssetsUrl).reply(getStatusValue(status), generateEmptyApiResponse()); renderComponent(); await executeThunk(fetchAssets(courseId), store.dispatch); + await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch); + await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch); +}; + +const wrongUserPermissionsMockStore = async () => { + axiosMock.onGet(getUserPermissionsUrl(courseId, userId)).reply(200, wrongUserPermissionsData); + axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true }); + await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch); + await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch); +}; + +const disabledUserPermissionsFlagMockStore = async () => { + axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: false }); + await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch); +}; + +const userPermissionsMockStore = async () => { + axiosMock.onGet(getUserPermissionsUrl(courseId, userId)).reply(200, userPermissionsData); + axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true }); + await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch); + await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch); }; describe('FilesAndUploads', () => { @@ -82,7 +111,7 @@ describe('FilesAndUploads', () => { beforeEach(async () => { initializeMockApp({ authenticatedUser: { - userId: 3, + userId, username: 'abc123', administrator: false, roles: [], @@ -100,6 +129,21 @@ describe('FilesAndUploads', () => { file = new File(['(⌐□_□)'], 'download.png', { type: 'image/png' }); }); + it('should shows PermissionDeniedAlert if there are no right User Permissions', async () => { + renderComponent(); + await wrongUserPermissionsMockStore(); + expect(screen.getByTestId('permissionDeniedAlert')).toBeVisible(); + }); + it('should not show PermissionDeniedAlert if User Permissions Flag is not enabled', async () => { + renderComponent(); + await disabledUserPermissionsFlagMockStore(); + expect(screen.queryByText('permissionDeniedAlert')).not.toBeInTheDocument(); + }); + it('should not show PermissionDeniedAlert if User Permissions Flag is enabled and permissions are correct', async () => { + renderComponent(); + await userPermissionsMockStore(); + expect(screen.queryByText('permissionDeniedAlert')).not.toBeInTheDocument(); + }); it('should return placeholder component', async () => { await mockStore(RequestStatus.DENIED); diff --git a/src/files-and-videos/videos-page/VideosPage.jsx b/src/files-and-videos/videos-page/VideosPage.jsx index ff7468999..48e123f3e 100644 --- a/src/files-and-videos/videos-page/VideosPage.jsx +++ b/src/files-and-videos/videos-page/VideosPage.jsx @@ -43,6 +43,9 @@ import VideoThumbnail from './VideoThumbnail'; import { getFormattedDuration, resampleFile } from './data/utils'; import FILES_AND_UPLOAD_TYPE_FILTERS from '../generic/constants'; import VideoInfoModalSidebar from './info-sidebar'; +import { useUserPermissions } from '../../generic/hooks'; +import { getUserPermissionsEnabled } from '../../generic/data/selectors'; +import PermissionDeniedAlert from '../../generic/PermissionDeniedAlert'; const VideosPage = ({ courseId, @@ -53,6 +56,9 @@ const VideosPage = ({ const [isTranscriptSettingsOpen, openTranscriptSettings, closeTranscriptSettings] = useToggle(false); const courseDetails = useModel('courseDetails', courseId); document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.heading)); + const { checkPermission } = useUserPermissions(); + const userPermissionsEnabled = useSelector(getUserPermissionsEnabled); + const showPermissionDeniedAlert = userPermissionsEnabled && !checkPermission('manage_content'); useEffect(() => { dispatch(fetchVideos(courseId)); @@ -179,6 +185,11 @@ const VideosPage = ({ { ...processingStatusColumn }, ]; + if (showPermissionDeniedAlert) { + return ( + + ); + } if (loadingStatus === RequestStatus.DENIED) { return (
diff --git a/src/files-and-videos/videos-page/VideosPage.test.jsx b/src/files-and-videos/videos-page/VideosPage.test.jsx index b4ce8ca07..38a3bceae 100644 --- a/src/files-and-videos/videos-page/VideosPage.test.jsx +++ b/src/files-and-videos/videos-page/VideosPage.test.jsx @@ -39,10 +39,15 @@ import { import { getVideosUrl, getCourseVideosApiUrl, getApiBaseUrl } from './data/api'; import videoMessages from './messages'; import messages from '../generic/messages'; +import { getUserPermissionsUrl, getUserPermissionsEnabledFlagUrl } from '../../generic/data/api'; +import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../../generic/data/thunks'; let axiosMock; let store; let file; +const userId = 3; +const wrongUserPermissionsData = { permissions: ['wrong_permission'] }; +const userPermissionsData = { permissions: ['manage_content'] }; jest.mock('file-saver'); const renderComponent = () => { @@ -55,9 +60,7 @@ const renderComponent = () => { ); }; -const mockStore = async ( - status, -) => { +const mockStore = async (status) => { const fetchVideosUrl = getVideosUrl(courseId); const videosData = generateFetchVideosApiResponse(); axiosMock.onGet(fetchVideosUrl).reply(getStatusValue(status), videosData); @@ -68,6 +71,8 @@ const mockStore = async ( renderComponent(); await executeThunk(fetchVideos(courseId), store.dispatch); + await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch); + await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch); }; const emptyMockStore = async (status) => { @@ -75,6 +80,27 @@ const emptyMockStore = async (status) => { axiosMock.onGet(fetchVideosUrl).reply(getStatusValue(status), generateEmptyApiResponse()); renderComponent(); await executeThunk(fetchVideos(courseId), store.dispatch); + await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch); + await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch); +}; + +const wrongUserPermissionsMockStore = async () => { + axiosMock.onGet(getUserPermissionsUrl(courseId, userId)).reply(200, wrongUserPermissionsData); + axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true }); + await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch); + await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch); +}; + +const disabledUserPermissionsFlagMockStore = async () => { + axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: false }); + await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch); +}; + +const userPermissionsMockStore = async () => { + axiosMock.onGet(getUserPermissionsUrl(courseId, userId)).reply(200, userPermissionsData); + axiosMock.onGet(getUserPermissionsEnabledFlagUrl).reply(200, { enabled: true }); + await executeThunk(fetchUserPermissionsQuery(courseId), store.dispatch); + await executeThunk(fetchUserPermissionsEnabledFlag(), store.dispatch); }; describe('Videos page', () => { @@ -82,7 +108,7 @@ describe('Videos page', () => { beforeEach(async () => { initializeMockApp({ authenticatedUser: { - userId: 3, + userId, username: 'abc123', administrator: false, roles: [], @@ -146,13 +172,31 @@ describe('Videos page', () => { expect(screen.getByTestId('files-data-table')).toBeVisible(); }); + + it('should shows PermissionDeniedAlert if there are no right User Permissions', async () => { + renderComponent(); + await wrongUserPermissionsMockStore(); + expect(screen.getByTestId('permissionDeniedAlert')).toBeVisible(); + }); + + it('should not show PermissionDeniedAlert if User Permissions Flag is not enabled', async () => { + renderComponent(); + await disabledUserPermissionsFlagMockStore(); + expect(screen.queryByText('permissionDeniedAlert')).not.toBeInTheDocument(); + }); + + it('should not show PermissionDeniedAlert if User Permissions Flag is enabled and permission is correct', async () => { + renderComponent(); + await userPermissionsMockStore(); + expect(screen.queryByText('permissionDeniedAlert')).not.toBeInTheDocument(); + }); }); describe('valid videos', () => { beforeEach(async () => { initializeMockApp({ authenticatedUser: { - userId: 3, + userId, username: 'abc123', administrator: false, roles: [], diff --git a/src/generic/create-or-rerun-course/factories/mockApiResponses.jsx b/src/generic/create-or-rerun-course/factories/mockApiResponses.jsx index ca3e0b347..c559779b9 100644 --- a/src/generic/create-or-rerun-course/factories/mockApiResponses.jsx +++ b/src/generic/create-or-rerun-course/factories/mockApiResponses.jsx @@ -16,6 +16,8 @@ export const initialState = { }, organizations: ['krisEdx', 'krisEd', 'DeveloperInc', 'importMit', 'testX', 'edX', 'developerInb'], savingStatus: '', + userPermissions: [], + userPermissionsEnabled: false, }, studioHome: { loadingStatuses: { diff --git a/src/generic/data/api.js b/src/generic/data/api.js index 71a10e34e..47d9dca4b 100644 --- a/src/generic/data/api.js +++ b/src/generic/data/api.js @@ -48,7 +48,6 @@ export async function createOrRerunCourse(courseData) { /** * Get user course roles permissions. * @param {string} courseId - * @param {string} userId * @returns {Promise} */ export async function getUserPermissions(courseId) { diff --git a/src/generic/data/api.test.js b/src/generic/data/api.test.js index 5a64394bb..f43b67aae 100644 --- a/src/generic/data/api.test.js +++ b/src/generic/data/api.test.js @@ -27,8 +27,6 @@ describe('generic api calls', () => { administrator: true, roles: [], }, - userPermissions: [], - userPermissionsEnabled: false, }); axiosMock = new MockAdapter(getAuthenticatedHttpClient()); }); diff --git a/src/generic/hooks.jsx b/src/generic/hooks.jsx new file mode 100644 index 000000000..77a6196ca --- /dev/null +++ b/src/generic/hooks.jsx @@ -0,0 +1,21 @@ +import { useSelector } from 'react-redux'; +import { getUserPermissions, getUserPermissionsEnabled } from './data/selectors'; + +const useUserPermissions = () => { + const userPermissionsEnabled = useSelector(getUserPermissionsEnabled); + const userPermissions = useSelector(getUserPermissions); + + const checkPermission = (permission) => { + if (!userPermissionsEnabled || !Array.isArray(userPermissions)) { + return false; + } + return userPermissions.includes(permission); + }; + + return { + checkPermission, + }; +}; + +// eslint-disable-next-line import/prefer-default-export +export { useUserPermissions }; diff --git a/src/header/Header.jsx b/src/header/Header.jsx index 6cb4147f0..2b7d96f07 100644 --- a/src/header/Header.jsx +++ b/src/header/Header.jsx @@ -1,10 +1,14 @@ -import React from 'react'; +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; import PropTypes from 'prop-types'; import { getConfig } from '@edx/frontend-platform'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { StudioHeader } from '@edx/frontend-component-header'; import { getContentMenuItems, getSettingMenuItems, getToolsMenuItems } from './utils'; +import { useUserPermissions } from '../generic/hooks'; +import { getUserPermissions, getUserPermissionsEnabled } from '../generic/data/selectors'; +import { fetchUserPermissionsQuery, fetchUserPermissionsEnabledFlag } from '../generic/data/thunks'; import messages from './messages'; const Header = ({ @@ -16,24 +20,63 @@ const Header = ({ // injected intl, }) => { + const dispatch = useDispatch(); + const { checkPermission } = useUserPermissions(); + const userPermissions = useSelector(getUserPermissions); + const userPermissionsEnabled = useSelector(getUserPermissionsEnabled); + const hasContentPermissions = !userPermissionsEnabled || (userPermissionsEnabled && checkPermission('manage_content')); + const hasSettingsPermissions = !userPermissionsEnabled + || (userPermissionsEnabled && (checkPermission('manage_advanced_settings') || checkPermission('view_course_settings'))); + const hasToolsPermissions = !userPermissionsEnabled + || (userPermissionsEnabled && (checkPermission('manage_course_settings') || checkPermission('view_course_settings'))); const studioBaseUrl = getConfig().STUDIO_BASE_URL; - const mainMenuDropdowns = [ - { - id: `${intl.formatMessage(messages['header.links.content'])}-dropdown-menu`, - buttonTitle: intl.formatMessage(messages['header.links.content']), - items: getContentMenuItems({ studioBaseUrl, courseId, intl }), - }, + const contentMenu = getContentMenuItems({ + studioBaseUrl, + courseId, + intl, + hasContentPermissions, + }); + const mainMenuDropdowns = []; + + useEffect(() => { + dispatch(fetchUserPermissionsEnabledFlag()); + if (!userPermissions) { + dispatch(fetchUserPermissionsQuery(courseId)); + } + }, [courseId]); + + if (contentMenu.length > 0) { + mainMenuDropdowns.push( + { + id: `${intl.formatMessage(messages['header.links.content'])}-dropdown-menu`, + buttonTitle: intl.formatMessage(messages['header.links.content']), + items: contentMenu, + }, + ); + } + mainMenuDropdowns.push( { id: `${intl.formatMessage(messages['header.links.settings'])}-dropdown-menu`, buttonTitle: intl.formatMessage(messages['header.links.settings']), - items: getSettingMenuItems({ studioBaseUrl, courseId, intl }), + items: getSettingMenuItems({ + studioBaseUrl, + courseId, + intl, + hasSettingsPermissions, + }), }, { id: `${intl.formatMessage(messages['header.links.tools'])}-dropdown-menu`, buttonTitle: intl.formatMessage(messages['header.links.tools']), - items: getToolsMenuItems({ studioBaseUrl, courseId, intl }), + items: getToolsMenuItems({ + studioBaseUrl, + courseId, + intl, + hasToolsPermissions, + }), }, - ]; + ); + const outlineLink = `${studioBaseUrl}/course/${courseId}`; return ( { - const items = [ - { - href: `${studioBaseUrl}/course/${courseId}`, - title: intl.formatMessage(messages['header.links.outline']), - }, - { - href: `${studioBaseUrl}/course_info/${courseId}`, - title: intl.formatMessage(messages['header.links.updates']), - }, - { - href: getPagePath(courseId, 'true', 'tabs'), - title: intl.formatMessage(messages['header.links.pages']), - }, - { - href: `${studioBaseUrl}/assets/${courseId}`, - title: intl.formatMessage(messages['header.links.filesAndUploads']), - }, - ]; +export const getContentMenuItems = ({ + studioBaseUrl, + courseId, + intl, + hasContentPermissions, +}) => { + const items = []; + + if (hasContentPermissions) { + items.push( + { + href: `${studioBaseUrl}/course/${courseId}`, + title: intl.formatMessage(messages['header.links.outline']), + }, + { + href: `${studioBaseUrl}/course_info/${courseId}`, + title: intl.formatMessage(messages['header.links.updates']), + }, + { + href: getPagePath(courseId, 'true', 'tabs'), + title: intl.formatMessage(messages['header.links.pages']), + }, + { + href: `${studioBaseUrl}/assets/${courseId}`, + title: intl.formatMessage(messages['header.links.filesAndUploads']), + }, + ); + } if (getConfig().ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN === 'true') { items.push({ href: `${studioBaseUrl}/videos/${courseId}`, title: intl.formatMessage(messages['header.links.videoUploads']), - }); + } + ); } return items; }; -export const getSettingMenuItems = ({ studioBaseUrl, courseId, intl }) => ([ - { - href: `${studioBaseUrl}/settings/details/${courseId}`, - title: intl.formatMessage(messages['header.links.scheduleAndDetails']), - }, - { - href: `${studioBaseUrl}/settings/grading/${courseId}`, - title: intl.formatMessage(messages['header.links.grading']), - }, - { - href: `${studioBaseUrl}/course_team/${courseId}`, - title: intl.formatMessage(messages['header.links.courseTeam']), - }, - { - href: `${studioBaseUrl}/group_configurations/${courseId}`, - title: intl.formatMessage(messages['header.links.groupConfigurations']), - }, - { - href: `${studioBaseUrl}/settings/advanced/${courseId}`, - title: intl.formatMessage(messages['header.links.advancedSettings']), - }, - { - href: `${studioBaseUrl}/certificates/${courseId}`, - title: intl.formatMessage(messages['header.links.certificates']), - }, -]); +export const getSettingMenuItems = ({ + studioBaseUrl, + courseId, + intl, + hasSettingsPermissions, +}) => { + const items = []; -export const getToolsMenuItems = ({ studioBaseUrl, courseId, intl }) => ([ - { - href: `${studioBaseUrl}/import/${courseId}`, - title: intl.formatMessage(messages['header.links.import']), - }, - { - href: `${studioBaseUrl}/export/${courseId}`, - title: intl.formatMessage(messages['header.links.export']), - }, { - href: `${studioBaseUrl}/checklists/${courseId}`, - title: intl.formatMessage(messages['header.links.checklists']), - }, -]); + items.push( + { + href: `${studioBaseUrl}/settings/details/${courseId}`, + title: intl.formatMessage(messages['header.links.scheduleAndDetails']), + }, + { + href: `${studioBaseUrl}/settings/grading/${courseId}`, + title: intl.formatMessage(messages['header.links.grading']), + }, + { + href: `${studioBaseUrl}/course_team/${courseId}`, + title: intl.formatMessage(messages['header.links.courseTeam']), + }, + { + href: `${studioBaseUrl}/group_configurations/course-v1:${courseId}`, + title: intl.formatMessage(messages['header.links.groupConfigurations']), + }, + ); + if (hasSettingsPermissions) { + items.push( + { + href: `${studioBaseUrl}/settings/advanced/${courseId}`, + title: intl.formatMessage(messages['header.links.advancedSettings']), + }, + ); + } + items.push( + { + href: `${studioBaseUrl}/certificates/${courseId}`, + title: intl.formatMessage(messages['header.links.certificates']), + }, + ); + return items; +}; + +export const getToolsMenuItems = ({ + studioBaseUrl, + courseId, + intl, + hasToolsPermissions, +}) => { + const items = []; + items.push( + { + href: `${studioBaseUrl}/import/${courseId}`, + title: intl.formatMessage(messages['header.links.import']), + }, + { + href: `${studioBaseUrl}/export/${courseId}`, + title: intl.formatMessage(messages['header.links.export']), + }, + ); + if (hasToolsPermissions) { + items.push( + { + href: `${studioBaseUrl}/checklists/${courseId}`, + title: intl.formatMessage(messages['header.links.checklists']), + }, + ); + } + return items; +}; diff --git a/src/header/utils.test.js b/src/header/utils.test.js index 35072db88..5eab759d5 100644 --- a/src/header/utils.test.js +++ b/src/header/utils.test.js @@ -1,13 +1,16 @@ import { getConfig, setConfig } from '@edx/frontend-platform'; -import { getContentMenuItems } from './utils'; +import { getContentMenuItems, getSettingMenuItems, getToolsMenuItems } from './utils'; -const props = { +const baseProps = { studioBaseUrl: 'UrLSTuiO', courseId: '123', intl: { formatMessage: jest.fn(), }, }; +const contentProps = { ...baseProps, hasContentPermissions: true }; +const settingProps = { ...baseProps, hasSettingsPermissions: true }; +const toolsProps = { ...baseProps, hasToolsPermissions: true }; describe('header utils', () => { describe('getContentMenuItems', () => { @@ -27,5 +30,30 @@ describe('header utils', () => { const actualItems = getContentMenuItems(props); expect(actualItems).toHaveLength(4); }); + it('should include only Video Uploads option', () => { + process.env.ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN = 'true'; + const actualItems = getContentMenuItems({ ...baseProps, hasContentPermissions: false }); + expect(actualItems).toHaveLength(1); + }); + }); + describe('getSettingMenuItems', async () => { + it('should include all options', () => { + const actualItems = getSettingMenuItems(settingProps); + expect(actualItems).toHaveLength(6); + }); + it('should not include Advanced Settings option', () => { + const actualItems = getSettingMenuItems({ ...baseProps, hasSettingsPermissions: false }); + expect(actualItems).toHaveLength(5); + }); + }); + describe('getToolsMenuItems', async () => { + it('should include all options', () => { + const actualItems = getToolsMenuItems(toolsProps); + expect(actualItems).toHaveLength(3); + }); + it('should not include Checklist option', () => { + const actualItems = getToolsMenuItems({ ...baseProps, hasToolsPermissions: false }); + expect(actualItems).toHaveLength(2); + }); }); }); diff --git a/src/pages-and-resources/PagesAndResources.jsx b/src/pages-and-resources/PagesAndResources.jsx index cc3a15dd0..3159701b3 100644 --- a/src/pages-and-resources/PagesAndResources.jsx +++ b/src/pages-and-resources/PagesAndResources.jsx @@ -26,6 +26,7 @@ import { RequestStatus } from '../data/constants'; import SettingsComponent from './SettingsComponent'; import PermissionDeniedAlert from '../generic/PermissionDeniedAlert'; import getPageHeadTitle from '../generic/utils'; +import { useUserPermissions } from '../generic/hooks'; const PagesAndResources = ({ courseId, intl }) => { const courseDetails = useModel('courseDetails', courseId); @@ -73,12 +74,14 @@ const PagesAndResources = ({ courseId, intl }) => { contentPermissionsPages.push(page); } + const { checkPermission } = useUserPermissions(); + if (loadingStatus === RequestStatus.IN_PROGRESS) { // eslint-disable-next-line react/jsx-no-useless-fragment return <>; } - if (courseAppsApiStatus === RequestStatus.DENIED) { + if (courseAppsApiStatus === RequestStatus.DENIED || !checkPermission('manage_content')) { return ( ); diff --git a/src/studio-home/StudioHome.jsx b/src/studio-home/StudioHome.jsx index ec39c13f7..3e74210e2 100644 --- a/src/studio-home/StudioHome.jsx +++ b/src/studio-home/StudioHome.jsx @@ -159,6 +159,7 @@ const StudioHome = ({ intl }) => {