From e74e1ff5aa686c2638b6ff92826b756a161f8e6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Calvi=C3=B1o?= <61986758+lucascalvino@users.noreply.github.com> Date: Wed, 17 Jan 2024 11:55:03 -0300 Subject: [PATCH] feat: [ROLES-41] Permission checks (#718) * feat: Permission check (#718) This feature allows to fetch the User Permissions and check on every page for the right permission to allow the user to make actions or even to see the content depending on the page and the permission. Co-authored-by: hsinkoff --- src/CourseAuthoringPage.jsx | 7 + src/advanced-settings/AdvancedSettings.jsx | 16 ++ .../AdvancedSettings.test.jsx | 51 +++++- .../setting-card/SettingCard.jsx | 3 + src/course-team/CourseTeam.jsx | 8 +- src/course-team/CourseTeam.test.jsx | 33 +++- src/course-team/hooks.jsx | 21 +-- src/course-updates/CourseUpdates.jsx | 12 +- src/course-updates/CourseUpdates.test.jsx | 28 +++- src/files-and-videos/files-page/FilesPage.jsx | 11 ++ .../files-page/FilesPage.test.jsx | 46 ++++- .../videos-page/VideosPage.jsx | 11 ++ .../videos-page/VideosPage.test.jsx | 54 +++++- .../factories/mockApiResponses.jsx | 2 + src/generic/data/api.js | 1 - src/generic/data/api.test.js | 2 - src/generic/hooks.jsx | 21 +++ src/header/Header.jsx | 63 +++++-- src/header/utils.js | 158 +++++++++++------- src/header/utils.test.js | 32 +++- src/pages-and-resources/PagesAndResources.jsx | 5 +- src/studio-home/StudioHome.jsx | 1 + 22 files changed, 474 insertions(+), 112 deletions(-) create mode 100644 src/generic/hooks.jsx 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 }) => {