diff --git a/src/authz/constants.ts b/src/authz/constants.ts
index aa148ea08b..05b0a6783d 100644
--- a/src/authz/constants.ts
+++ b/src/authz/constants.ts
@@ -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',
};
diff --git a/src/authz/permissionHelpers.ts b/src/authz/permissionHelpers.ts
index 76585ea0bf..6423f1b0df 100644
--- a/src/authz/permissionHelpers.ts
+++ b/src/authz/permissionHelpers.ts
@@ -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,
diff --git a/src/generic/WysiwygEditor.jsx b/src/generic/WysiwygEditor.jsx
index cd1c0d9e2b..876d49f53a 100644
--- a/src/generic/WysiwygEditor.jsx
+++ b/src/generic/WysiwygEditor.jsx
@@ -14,6 +14,7 @@ export const WysiwygEditor = ({
editorType,
onChange,
minHeight,
+ disabled = false,
}) => {
const { editorRef, refReady, setEditorRef } = prepareEditorRef();
const { courseId } = useCourseAuthoringContext();
@@ -64,6 +65,7 @@ export const WysiwygEditor = ({
images={{}}
enableImageUpload={false}
onEditorChange={() => ({})}
+ disabled={disabled}
/>
);
};
diff --git a/src/generic/course-upload-image/index.jsx b/src/generic/course-upload-image/index.jsx
index 387b3ae1c2..aaf4489163 100644
--- a/src/generic/course-upload-image/index.jsx
+++ b/src/generic/course-upload-image/index.jsx
@@ -27,6 +27,7 @@ const CourseUploadImage = ({
identifierFieldText,
showImageBodyText,
customInputPlaceholder,
+ disabled = false,
onChange,
}) => {
const { courseId } = useParams();
@@ -113,13 +114,16 @@ const CourseUploadImage = ({
{label}
-
+
+
+
{showImageBodyText && cardImageTextBody}
@@ -131,6 +135,7 @@ const CourseUploadImage = ({
|| intl.formatMessage(messages.uploadImageInputPlaceholder, {
identifierFieldText,
})}
+ disabled={disabled}
/>
diff --git a/src/schedule-and-details/ScheduleAndDetails.test.jsx b/src/schedule-and-details/ScheduleAndDetails.test.jsx
index f1801b53e8..15711d606a 100644
--- a/src/schedule-and-details/ScheduleAndDetails.test.jsx
+++ b/src/schedule-and-details/ScheduleAndDetails.test.jsx
@@ -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__';
@@ -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';
@@ -169,3 +191,73 @@ describe('', () => {
expect(getByText(messages.alertFail.defaultMessage)).toBeInTheDocument();
});
});
+
+describe(' 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();
+ });
+});
diff --git a/src/schedule-and-details/details-section/DetailsSection.test.jsx b/src/schedule-and-details/details-section/DetailsSection.test.jsx
index 7f9f49b883..0883b5f46c 100644
--- a/src/schedule-and-details/details-section/DetailsSection.test.jsx
+++ b/src/schedule-and-details/details-section/DetailsSection.test.jsx
@@ -57,4 +57,26 @@ describe('', () => {
getByRole('button', { name: messages.dropdownEmpty.defaultMessage }),
).toBeInTheDocument();
});
+
+ it('disables the language dropdown toggle when isEditable is false', () => {
+ const { getByRole } = render();
+ 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();
+ // 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();
+ const toggle = getByRole('button', { name: courseSettingsMock.languageOptions[1][1] });
+ expect(toggle).not.toBeDisabled();
+ });
});
diff --git a/src/schedule-and-details/details-section/index.jsx b/src/schedule-and-details/details-section/index.jsx
index b165a3f3f6..f4099849da 100644
--- a/src/schedule-and-details/details-section/index.jsx
+++ b/src/schedule-and-details/details-section/index.jsx
@@ -10,6 +10,7 @@ const DetailsSection = ({
language,
languageOptions,
onChange,
+ isEditable = true,
}) => {
const intl = useIntl();
const formattedLanguage = () => {
@@ -26,14 +27,14 @@ const DetailsSection = ({
{intl.formatMessage(messages.dropdownLabel)}
-
+
{formattedLanguage()}
{languageOptions.map((option) => (
onChange(option[0], 'language')}
+ onClick={isEditable ? () => onChange(option[0], 'language') : undefined}
>
{option[1]}
diff --git a/src/schedule-and-details/index.jsx b/src/schedule-and-details/index.jsx
index 2dcc26d1f8..7df72729d4 100644
--- a/src/schedule-and-details/index.jsx
+++ b/src/schedule-and-details/index.jsx
@@ -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,
@@ -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,
@@ -152,6 +164,10 @@ const ScheduleAndDetails = () => {
return <>>;
}
+ if (!canViewScheduleAndDetails) {
+ return ;
+ }
+
if (loadingDetailsStatus === RequestStatus.DENIED || loadingSettingsStatus === RequestStatus.DENIED) {
return (
@@ -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;
@@ -258,6 +276,7 @@ const ScheduleAndDetails = () => {
{
certificateAvailableDate={certificateAvailableDate}
certificatesDisplayBehavior={certificatesDisplayBehavior}
canShowCertificateAvailableDateField={canShowCertificateAvailableDateField}
+ isEditable={canEditSchedule}
onChange={handleValuesChange}
/>
{aboutPageEditable && (
)}
@@ -298,16 +319,19 @@ const ScheduleAndDetails = () => {
shortDescriptionEditable={shortDescriptionEditable}
enableExtendedCourseDetails={enableExtendedCourseDetails}
videoThumbnailImageAssetPath={videoThumbnailImageAssetPath}
+ isEditable={canEditDetails}
onChange={handleValuesChange}
/>
{enableExtendedCourseDetails && (
<>
>
@@ -323,12 +347,14 @@ const ScheduleAndDetails = () => {
possiblePreRequisiteCourses={possiblePreRequisiteCourses}
entranceExamMinimumScorePct={entranceExamMinimumScorePct}
isPrerequisiteCoursesEnabled={isPrerequisiteCoursesEnabled}
+ isEditable={canEditDetails}
onChange={handleValuesChange}
/>
)}
{licensingEnabled && (
)}
@@ -376,7 +402,7 @@ const ScheduleAndDetails = () => {
', () => {
}],
}, 'instructorInfo');
});
+
+ it('disables add button when isEditable is false', () => {
+ render();
+ expect(screen.getByRole('button', { name: messages.instructorAdd.defaultMessage })).toBeDisabled();
+ });
+
+ it('enables add button when isEditable is true', () => {
+ render();
+ expect(screen.getByRole('button', { name: messages.instructorAdd.defaultMessage })).not.toBeDisabled();
+ });
});
diff --git a/src/schedule-and-details/instructors-section/index.jsx b/src/schedule-and-details/instructors-section/index.jsx
index 99dde50d62..517a33f3e7 100644
--- a/src/schedule-and-details/instructors-section/index.jsx
+++ b/src/schedule-and-details/instructors-section/index.jsx
@@ -9,7 +9,7 @@ import InstructorContainer from './instructor-container';
import SectionSubHeader from '../../generic/section-sub-header';
import messages from './messages';
-const InstructorsSection = ({ instructors, onChange }) => {
+const InstructorsSection = ({ instructors, isEditable = true, onChange }) => {
const intl = useIntl();
const newInstructor = {
bio: '',
@@ -64,12 +64,13 @@ const InstructorsSection = ({ instructors, onChange }) => {
instructor={instructor}
key={uuid}
idx={idx}
+ isEditable={isEditable}
onDelete={handleDelete}
onChange={handleChange}
/>
))}
-