Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/authz/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,8 @@ export const COURSE_PERMISSIONS = {

VIEW_GRADING_SETTINGS: 'courses.view_grading_settings',
EDIT_GRADING_SETTINGS: 'courses.edit_grading_settings',

VIEW_SCHEDULE_AND_DETAILS: 'courses.view_schedule_and_details',
EDIT_SCHEDULE: 'courses.edit_schedule',
EDIT_DETAILS: 'courses.edit_details',
};
15 changes: 15 additions & 0 deletions src/authz/permissionHelpers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
import { COURSE_PERMISSIONS } from './constants';

export const getScheduleAndDetailsPermissions = (courseId: string) => ({
canViewScheduleAndDetails: {
action: COURSE_PERMISSIONS.VIEW_SCHEDULE_AND_DETAILS,
scope: courseId,
},
canEditSchedule: {
action: COURSE_PERMISSIONS.EDIT_SCHEDULE,
scope: courseId,
},
canEditDetails: {
action: COURSE_PERMISSIONS.EDIT_DETAILS,
scope: courseId,
},
});

export const getGradingPermissions = (courseId: string) => ({
canViewGradingSettings: {
action: COURSE_PERMISSIONS.VIEW_GRADING_SETTINGS,
Expand Down
2 changes: 2 additions & 0 deletions src/generic/WysiwygEditor.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const WysiwygEditor = ({
editorType,
onChange,
minHeight,
disabled = false,
}) => {
const { editorRef, refReady, setEditorRef } = prepareEditorRef();
const { courseId } = useCourseAuthoringContext();
Expand Down Expand Up @@ -64,6 +65,7 @@ export const WysiwygEditor = ({
images={{}}
enableImageUpload={false}
onEditorChange={() => ({})}
disabled={disabled}
/>
);
};
Expand Down
19 changes: 12 additions & 7 deletions src/generic/course-upload-image/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const CourseUploadImage = ({
identifierFieldText,
showImageBodyText,
customInputPlaceholder,
disabled = false,
onChange,
}) => {
const { courseId } = useParams();
Expand Down Expand Up @@ -113,13 +114,16 @@ const CourseUploadImage = ({
<Form.Label>{label}</Form.Label>
<Card>
<Card.Body className="image-body">
<Dropzone
onProcessUpload={handleProcessUpload}
inputComponent={inputComponent}
accept={{
'image/*': ['.png', '.jpeg'],
}}
/>
<div style={disabled ? { pointerEvents: 'none' } : undefined}>
<Dropzone
onProcessUpload={handleProcessUpload}
inputComponent={inputComponent}
accept={{
'image/*': ['.png', '.jpeg'],
}}
disabled={disabled}
/>
</div>
{showImageBodyText && cardImageTextBody}
</Card.Body>
<Card.Divider />
Expand All @@ -131,6 +135,7 @@ const CourseUploadImage = ({
|| intl.formatMessage(messages.uploadImageInputPlaceholder, {
identifierFieldText,
})}
disabled={disabled}
/>
</Card.Footer>
</Card>
Expand Down
92 changes: 92 additions & 0 deletions src/schedule-and-details/ScheduleAndDetails.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { executeThunk } from '@src/utils';
import genericMessages from '@src/generic/help-sidebar/messages';
import { DATE_FORMAT } from '@src/constants';
import { getCourseSettingsApiUrl } from '@src/data/api';
import { mockWaffleFlags } from '@src/data/apiHooks.mock';
import { useCourseUserPermissions } from '@src/authz/hooks';

import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import { courseDetailsMock, courseSettingsMock } from './__mocks__';
Expand All @@ -22,6 +24,26 @@ import scheduleMessages from './schedule-section/messages';
import messages from './messages';
import ScheduleAndDetails from '.';

jest.mock('@src/authz/hooks', () => ({
useCourseUserPermissions: jest.fn().mockReturnValue({
isLoading: false,
isAuthzEnabled: true,
canViewScheduleAndDetails: true,
canEditSchedule: true,
canEditDetails: true,
}),
}));

const mockPermissions = (overrides = {}) =>
jest.mocked(useCourseUserPermissions).mockReturnValue({
isLoading: false,
isAuthzEnabled: true,
canViewScheduleAndDetails: true,
canEditSchedule: true,
canEditDetails: true,
...overrides,
});

let axiosMock;
let store;
const courseId = '123';
Expand Down Expand Up @@ -169,3 +191,73 @@ describe('<ScheduleAndDetails />', () => {
expect(getByText(messages.alertFail.defaultMessage)).toBeInTheDocument();
});
});

describe('<ScheduleAndDetails /> permissions', () => {
beforeEach(() => {
jest.restoreAllMocks();
const mocks = initializeMocks();
axiosMock = mocks.axiosMock;
store = mocks.reduxStore;
axiosMock.onGet(getCourseDetailsApiUrl(courseId)).reply(200, courseDetailsMock);
axiosMock.onGet(getCourseSettingsApiUrl(courseId)).reply(200, courseSettingsMock);
axiosMock.onPut(getCourseDetailsApiUrl(courseId)).reply(200);
mockPermissions();
});

it('renders normally when authz flag is disabled (no regression)', async () => {
mockWaffleFlags({ enableAuthzCourseAuthoring: false });
const { getAllByText } = renderComponent();
await waitFor(() => {
expect(getAllByText(messages.headingTitle.defaultMessage).length).toBeGreaterThan(0);
});
});

it('renders normally when user has all permissions', async () => {
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
const { getAllByText } = renderComponent();
await waitFor(() => {
expect(getAllByText(messages.headingTitle.defaultMessage).length).toBeGreaterThan(0);
});
});

it('shows PermissionDeniedAlert when user lacks view permission', async () => {
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
mockPermissions({ canViewScheduleAndDetails: false, canEditSchedule: false, canEditDetails: false });
const { getByTestId } = renderComponent();
await waitFor(() => {
expect(getByTestId('permissionDeniedAlert')).toBeInTheDocument();
});
});

it('disables schedule date inputs when user lacks edit_schedule permission', async () => {
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
mockPermissions({ canEditSchedule: false });
const { getAllByPlaceholderText } = renderComponent();
await waitFor(() => {
const dateInputs = getAllByPlaceholderText(DATE_FORMAT.toLocaleUpperCase());
dateInputs.forEach((input) => expect(input).toBeDisabled());
});
});

it('disables pacing and details inputs when user lacks edit_details permission', async () => {
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
mockPermissions({ canEditDetails: false });
const { getAllByRole } = renderComponent();
await waitFor(() => {
const radios = getAllByRole('radio');
radios.forEach((radio) => expect(radio).toBeDisabled());
});
});

it('save button cannot be triggered when user has no edit permissions', async () => {
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
mockPermissions({ canEditSchedule: false, canEditDetails: false });
const { getAllByPlaceholderText, queryByText } = renderComponent();
// Wait for page to load
const dateInputs = await waitFor(() => getAllByPlaceholderText(DATE_FORMAT.toLocaleUpperCase()));
// All date inputs must be disabled (no edit_schedule permission)
dateInputs.forEach((input) => expect(input).toBeDisabled());
// No changes can be made so the save button never appears
expect(queryByText(messages.buttonSaveText.defaultMessage)).not.toBeInTheDocument();
});
});
22 changes: 22 additions & 0 deletions src/schedule-and-details/details-section/DetailsSection.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,26 @@ describe('<DetailsSection />', () => {
getByRole('button', { name: messages.dropdownEmpty.defaultMessage }),
).toBeInTheDocument();
});

it('disables the language dropdown toggle when isEditable is false', () => {
const { getByRole } = render(<RootWrapper {...props} isEditable={false} />);
const toggle = getByRole('button', { name: courseSettingsMock.languageOptions[1][1] });
expect(toggle).toBeDisabled();
});

it('does not call onChange when dropdown item clicked while isEditable is false', () => {
onChangeMock.mockClear();
const { getByRole } = render(<RootWrapper {...props} isEditable={false} />);
// Toggle is disabled, so clicking it does not open the dropdown
const toggle = getByRole('button', { name: courseSettingsMock.languageOptions[1][1] });
expect(toggle).toBeDisabled();
fireEvent.click(toggle);
expect(onChangeMock).not.toHaveBeenCalled();
});

it('enables the language dropdown when isEditable is true', () => {
const { getByRole } = render(<RootWrapper {...props} isEditable />);
const toggle = getByRole('button', { name: courseSettingsMock.languageOptions[1][1] });
expect(toggle).not.toBeDisabled();
});
});
5 changes: 3 additions & 2 deletions src/schedule-and-details/details-section/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const DetailsSection = ({
language,
languageOptions,
onChange,
isEditable = true,
}) => {
const intl = useIntl();
const formattedLanguage = () => {
Expand All @@ -26,14 +27,14 @@ const DetailsSection = ({
<Form.Group className="form-group-custom dropdown-language">
<Form.Label>{intl.formatMessage(messages.dropdownLabel)}</Form.Label>
<Dropdown className="bg-white">
<Dropdown.Toggle variant="outline-primary" id="languageDropdown">
<Dropdown.Toggle variant="outline-primary" id="languageDropdown" disabled={!isEditable}>
{formattedLanguage()}
</Dropdown.Toggle>
<Dropdown.Menu>
{languageOptions.map((option) => (
<Dropdown.Item
key={option[0]}
onClick={() => onChange(option[0], 'language')}
onClick={isEditable ? () => onChange(option[0], 'language') : undefined}
>
{option[1]}
</Dropdown.Item>
Expand Down
32 changes: 29 additions & 3 deletions src/schedule-and-details/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ import { STATEFUL_BUTTON_STATES } from '@src/constants';
import getPageHeadTitle from '@src/generic/utils';
import { useScrollToHashElement } from '@src/hooks';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { useCourseUserPermissions } from '@src/authz/hooks';
import { getScheduleAndDetailsPermissions } from '@src/authz/permissionHelpers';
import PermissionDeniedAlert from '@src/generic/PermissionDeniedAlert';

import {
fetchCourseSettingsQuery,
Expand Down Expand Up @@ -52,12 +55,21 @@ const ScheduleAndDetails = () => {
const courseDetails = useSelector(getCourseDetails);
const loadingDetailsStatus = useSelector(getLoadingDetailsStatus);
const loadingSettingsStatus = useSelector(getLoadingSettingsStatus);
const isLoading = loadingDetailsStatus === RequestStatus.IN_PROGRESS
|| loadingSettingsStatus === RequestStatus.IN_PROGRESS;

const { courseId, courseDetails: course } = useCourseAuthoringContext();
document.title = getPageHeadTitle(course?.name || '', intl.formatMessage(messages.headingTitle));

const {
isLoading: isLoadingUserPermissions,
canViewScheduleAndDetails,
canEditSchedule,
canEditDetails,
} = useCourseUserPermissions(courseId, getScheduleAndDetailsPermissions(courseId));

const isLoading = loadingDetailsStatus === RequestStatus.IN_PROGRESS
|| loadingSettingsStatus === RequestStatus.IN_PROGRESS
|| isLoadingUserPermissions;

const {
platformName,
isCreditCourse,
Expand Down Expand Up @@ -152,6 +164,10 @@ const ScheduleAndDetails = () => {
return <></>;
}

if (!canViewScheduleAndDetails) {
return <PermissionDeniedAlert />;
}

if (loadingDetailsStatus === RequestStatus.DENIED || loadingSettingsStatus === RequestStatus.DENIED) {
return (
<div className="row justify-content-center m-6">
Expand All @@ -160,6 +176,8 @@ const ScheduleAndDetails = () => {
);
}

const canEdit = canEditSchedule || canEditDetails;

const showCreditSection = creditEligibilityEnabled && isCreditCourse;
const showRequirementsSection = aboutPageEditable || isPrerequisiteCoursesEnabled || isEntranceExamsEnabled;
const hasErrors = !!Object.keys(errorFields).length;
Expand Down Expand Up @@ -258,6 +276,7 @@ const ScheduleAndDetails = () => {
<PacingSection
selfPaced={selfPaced}
startDate={startDate}
isEditable={canEditDetails}
onChange={handleValuesChange}
/>
<ScheduleSection
Expand All @@ -272,12 +291,14 @@ const ScheduleAndDetails = () => {
certificateAvailableDate={certificateAvailableDate}
certificatesDisplayBehavior={certificatesDisplayBehavior}
canShowCertificateAvailableDateField={canShowCertificateAvailableDateField}
isEditable={canEditSchedule}
onChange={handleValuesChange}
/>
{aboutPageEditable && (
<DetailsSection
language={language}
languageOptions={languageOptions}
isEditable={canEditDetails}
onChange={handleValuesChange}
/>
)}
Expand All @@ -298,16 +319,19 @@ const ScheduleAndDetails = () => {
shortDescriptionEditable={shortDescriptionEditable}
enableExtendedCourseDetails={enableExtendedCourseDetails}
videoThumbnailImageAssetPath={videoThumbnailImageAssetPath}
isEditable={canEditDetails}
onChange={handleValuesChange}
/>
{enableExtendedCourseDetails && (
<>
<LearningOutcomesSection
learningInfo={learningInfo}
isEditable={canEditDetails}
onChange={handleValuesChange}
/>
<InstructorsSection
instructors={instructorInfo?.instructors}
isEditable={canEditDetails}
onChange={handleValuesChange}
/>
</>
Expand All @@ -323,12 +347,14 @@ const ScheduleAndDetails = () => {
possiblePreRequisiteCourses={possiblePreRequisiteCourses}
entranceExamMinimumScorePct={entranceExamMinimumScorePct}
isPrerequisiteCoursesEnabled={isPrerequisiteCoursesEnabled}
isEditable={canEditDetails}
onChange={handleValuesChange}
/>
)}
{licensingEnabled && (
<LicenseSection
license={license}
isEditable={canEditDetails}
onChange={handleValuesChange}
/>
)}
Expand Down Expand Up @@ -376,7 +402,7 @@ const ScheduleAndDetails = () => {
<StatefulButton
key="save-button"
onClick={handleUpdateValues}
disabled={hasErrors}
disabled={hasErrors || !canEdit}
state={isQueryPending
? STATEFUL_BUTTON_STATES.pending
: STATEFUL_BUTTON_STATES.default}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,4 +110,14 @@ describe('<InstructorsSection />', () => {
}],
}, 'instructorInfo');
});

it('disables add button when isEditable is false', () => {
render(<RootWrapper {...props} isEditable={false} />);
expect(screen.getByRole('button', { name: messages.instructorAdd.defaultMessage })).toBeDisabled();
});

it('enables add button when isEditable is true', () => {
render(<RootWrapper {...props} isEditable />);
expect(screen.getByRole('button', { name: messages.instructorAdd.defaultMessage })).not.toBeDisabled();
});
});
Loading