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 <hsinkoff@2u.com>
This commit is contained in:
Lucas Calviño
2024-01-17 11:55:03 -03:00
committed by hsinkoff
parent 1137dae97a
commit e74e1ff5aa
22 changed files with 474 additions and 112 deletions

View File

@@ -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);

View File

@@ -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 (
<PermissionDeniedAlert />
);
}
if (loadingSettingsStatus === RequestStatus.DENIED) {
return (
<div className="row justify-content-center m-6">
@@ -215,6 +230,7 @@ const AdvancedSettings = ({ intl, courseId }) => {
handleBlur={handleSettingBlur}
isEditableState={isEditableState}
setIsEditableState={setIsEditableState}
disableForm={viewOnly}
/>
);
})}

View File

@@ -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 = () => (
</AppProvider>
);
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('<AdvancedSettings />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
userId,
username: 'abc123',
administrator: true,
roles: [],
@@ -58,7 +78,9 @@ describe('<AdvancedSettings />', () => {
axiosMock
.onGet(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`)
.reply(200, advancedSettingsMock);
permissionsMockStore(userPermissionsData);
});
it('should render without errors', async () => {
const { getByText } = render(<RootWrapper />);
await waitFor(() => {
@@ -161,4 +183,29 @@ describe('<AdvancedSettings />', () => {
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(<RootWrapper />);
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(<RootWrapper />);
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(<RootWrapper />);
await waitFor(() => {
expect(getByLabelText('Advanced Module List')).toBeDisabled();
});
});
});

View File

@@ -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}
/>
</Form.Group>
</Card.Section>
@@ -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);

View File

@@ -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

View File

@@ -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('<CourseTeam />', () => {
});
});
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('<CourseTeam />', () => {
});
axiosMock
.onGet(getUserPermissionsEnabledFlagUrl)
.reply(200, { enabled: false });
.reply(200, { enabled: true });
const { queryByRole, queryByTestId } = render(<RootWrapper />);
const { queryByRole, queryByText } = render(<RootWrapper />);
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(<RootWrapper />);
await waitFor(() => {
expect(getByRole('button', { name: messages.addNewMemberButton.defaultMessage })).toBeInTheDocument();
});
});

View File

@@ -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 };

View File

@@ -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 (
<PermissionDeniedAlert />
);
}
return (
<>
<Container size="xl" className="px-4">

View File

@@ -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('<CourseUpdates />', () => {
beforeEach(() => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
@@ -79,6 +84,7 @@ describe('<CourseUpdates />', () => {
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('<CourseUpdates />', () => {
expect(queryByText(courseHandoutsMock.data)).not.toBeInTheDocument();
});
it('should shows PermissionDeniedAlert if there are no right User Permissions', async () => {
const { getByTestId } = render(<RootWrapper />);
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(<RootWrapper />);
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(<RootWrapper />);

View File

@@ -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 (
<PermissionDeniedAlert />
);
}
if (loadingStatus === RequestStatus.DENIED) {
return (
<div data-testid="under-construction-placeholder" className="row justify-contnt-center m-6">

View File

@@ -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);

View File

@@ -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 (
<PermissionDeniedAlert />
);
}
if (loadingStatus === RequestStatus.DENIED) {
return (
<div data-testid="under-construction-placeholder" className="row justify-contnt-center m-6">

View File

@@ -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: [],

View File

@@ -16,6 +16,8 @@ export const initialState = {
},
organizations: ['krisEdx', 'krisEd', 'DeveloperInc', 'importMit', 'testX', 'edX', 'developerInb'],
savingStatus: '',
userPermissions: [],
userPermissionsEnabled: false,
},
studioHome: {
loadingStatuses: {

View File

@@ -48,7 +48,6 @@ export async function createOrRerunCourse(courseData) {
/**
* Get user course roles permissions.
* @param {string} courseId
* @param {string} userId
* @returns {Promise<Object>}
*/
export async function getUserPermissions(courseId) {

View File

@@ -27,8 +27,6 @@ describe('generic api calls', () => {
administrator: true,
roles: [],
},
userPermissions: [],
userPermissionsEnabled: false,
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});

21
src/generic/hooks.jsx Normal file
View File

@@ -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 };

View File

@@ -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 (
<StudioHeader

View File

@@ -2,72 +2,112 @@ import { getConfig } from '@edx/frontend-platform';
import { getPagePath } from '../utils';
import messages from './messages';
export const getContentMenuItems = ({ studioBaseUrl, courseId, intl }) => {
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;
};

View File

@@ -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);
});
});
});

View File

@@ -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 (
<PermissionDeniedAlert />
);

View File

@@ -159,6 +159,7 @@ const StudioHome = ({ intl }) => {
<SubHeader
title={intl.formatMessage(messages.headingTitle, { studioShortName: studioShortName || 'Studio' })}
headerActions={headerButtons}
key={studioShortName}
/>
</section>
</article>