diff --git a/plugins/course-apps/proctoring/Settings.test.jsx b/plugins/course-apps/proctoring/Settings.test.jsx index 6a07c7143c..4d137efc98 100644 --- a/plugins/course-apps/proctoring/Settings.test.jsx +++ b/plugins/course-apps/proctoring/Settings.test.jsx @@ -470,10 +470,13 @@ describe('ProctoredExamSettings', () => { await waitFor(() => { screen.getByDisplayValue('mockproc'); }); - // (1) for studio settings - // (2) for course details - expect(axiosMock.history.get.length).toBe(2); - expect(axiosMock.history.get[0].url.includes('proctored_exam_settings')).toEqual(true); + // With no exam service URL configured, no request should be made to the exams + // service for provider options. (Total GET count is not asserted because the + // CourseAuthoringProvider also fetches course details and waffle flags.) + const examProvidersRequested = axiosMock.history.get.some((req) => req.url.includes('/api/v1/providers')); + expect(examProvidersRequested).toBe(false); + const studioSettingsRequested = axiosMock.history.get.some((req) => req.url.includes('proctored_exam_settings')); + expect(studioSettingsRequested).toBe(true); }); it('Selected LTI proctoring provider is shown on page load', async () => { diff --git a/src/CourseAuthoringContext.tsx b/src/CourseAuthoringContext.tsx index bc444bd7cb..6d481c5882 100644 --- a/src/CourseAuthoringContext.tsx +++ b/src/CourseAuthoringContext.tsx @@ -10,6 +10,8 @@ import { type UnitXBlock, type XBlock } from '@src/data/types'; import { CourseDetailsData } from './data/api'; import { useCourseDetails } from './data/apiHooks'; import { RequestStatusType } from './data/constants'; +import { useCourseUserPermissions } from '@src/authz/hooks'; +import { getCourseOutlinePermissions } from '@src/authz/permissionHelpers'; export type ModalState = { value?: XBlock | UnitXBlock; @@ -29,6 +31,9 @@ export type CourseAuthoringContextData = { currentUnlinkModalData?: ModalState; openUnlinkModal: (value: ModalState) => void; closeUnlinkModal: () => void; + isLoading: boolean; + canEditCourseContent: boolean; + canPublishCourseContent: boolean; }; /** @@ -58,6 +63,14 @@ export const CourseAuthoringProvider = ({ closeUnlinkModal, ] = useToggleWithValue(); + const { + canEditCourseContent, + canPublishCourseContent, + isLoading: isUserPermissionsLoading, + } = useCourseUserPermissions(courseId, getCourseOutlinePermissions(courseId)); + + const isLoading = isUserPermissionsLoading; + const getUnitUrl = (locator: string) => `/course/${courseId}/container/${locator}`; /** @@ -78,6 +91,9 @@ export const CourseAuthoringProvider = ({ openUnlinkModal, closeUnlinkModal, currentUnlinkModalData, + isLoading, + canEditCourseContent, + canPublishCourseContent, }), [ courseId, courseDetails, @@ -89,6 +105,9 @@ export const CourseAuthoringProvider = ({ openUnlinkModal, closeUnlinkModal, currentUnlinkModalData, + isLoading, + canEditCourseContent, + canPublishCourseContent, ]); return ( diff --git a/src/authz/constants.ts b/src/authz/constants.ts index 028a395c3d..5944c910d9 100644 --- a/src/authz/constants.ts +++ b/src/authz/constants.ts @@ -17,7 +17,9 @@ export const CONTENT_LIBRARY_PERMISSIONS = { export const COURSE_PERMISSIONS = { VIEW_COURSE: 'courses.view_course', + CREATE_COURSE: 'courses.create_course', EDIT_COURSE_CONTENT: 'courses.edit_course_content', + PUBLISH_COURSE_CONTENT: 'courses.publish_course_content', MANAGE_ADVANCED_SETTINGS: 'courses.manage_advanced_settings', diff --git a/src/authz/permissionHelpers.test.ts b/src/authz/permissionHelpers.test.ts index 7798b9d5ca..781becd11d 100644 --- a/src/authz/permissionHelpers.test.ts +++ b/src/authz/permissionHelpers.test.ts @@ -164,6 +164,10 @@ describe('permissionHelpers', () => { action: COURSE_PERMISSIONS.EDIT_COURSE_CONTENT, scope: courseId, }, + canPublishCourseContent: { + action: COURSE_PERMISSIONS.PUBLISH_COURSE_CONTENT, + scope: courseId, + }, }); }); }); diff --git a/src/authz/permissionHelpers.ts b/src/authz/permissionHelpers.ts index 3520e80f10..c8f301b5d1 100644 --- a/src/authz/permissionHelpers.ts +++ b/src/authz/permissionHelpers.ts @@ -75,6 +75,10 @@ export const getCourseOutlinePermissions = (courseId: string) => ({ action: COURSE_PERMISSIONS.EDIT_COURSE_CONTENT, scope: courseId, }, + canPublishCourseContent: { + action: COURSE_PERMISSIONS.PUBLISH_COURSE_CONTENT, + scope: courseId, + }, }); export const getLibraryUpdatesPermissions = (courseId: string) => ({ diff --git a/src/course-outline/outline-sidebar/info-sidebar/InfoSidebar.test.tsx b/src/course-outline/outline-sidebar/info-sidebar/InfoSidebar.test.tsx index 9f79791c92..2db6461454 100644 --- a/src/course-outline/outline-sidebar/info-sidebar/InfoSidebar.test.tsx +++ b/src/course-outline/outline-sidebar/info-sidebar/InfoSidebar.test.tsx @@ -56,6 +56,8 @@ jest.mock('@src/CourseAuthoringContext', () => ({ courseId, openUnlinkModal, getUnitUrl: jest.fn(), + canEditCourseContent: true, + canPublishCourseContent: true, }), })); diff --git a/src/course-outline/outline-sidebar/info-sidebar/sharedSettings/VisibilitySection.test.tsx b/src/course-outline/outline-sidebar/info-sidebar/sharedSettings/VisibilitySection.test.tsx index 3ec946a0cc..9607073fef 100644 --- a/src/course-outline/outline-sidebar/info-sidebar/sharedSettings/VisibilitySection.test.tsx +++ b/src/course-outline/outline-sidebar/info-sidebar/sharedSettings/VisibilitySection.test.tsx @@ -7,7 +7,9 @@ import { import userEvent from '@testing-library/user-event'; import { useCourseItemData } from '@src/course-outline/data/apiHooks'; import { VisibilityTypes } from '@src/data/constants'; +import { mockWaffleFlags } from '@src/data/apiHooks.mock'; import { VisibilitySection } from './VisibilitySection'; +import { CourseAuthoringProvider } from '@src/CourseAuthoringContext'; jest.mock('@src/course-outline/data/apiHooks', () => ({ ...jest.requireActual('@src/course-outline/data/apiHooks'), @@ -22,14 +24,30 @@ const defaultProps = { onChange: jest.fn(), }; +const WrapperProvider = ({ children }) => ( + {children} +); +const renderWithWrapper = (children) => { + render(children, { + extraWrapper: WrapperProvider, + }); +}; + +let validateUserPermissionsMock; + describe('VisibilitySection component', () => { beforeEach(() => { - initializeMocks(); + const mocks = initializeMocks(); mockUseCourseItemData.mockReturnValue({ data: undefined }); + mockWaffleFlags({ enableAuthzCourseAuthoring: true }); + validateUserPermissionsMock = mocks.validateUserPermissionsMock; + validateUserPermissionsMock.mockResolvedValue({ + canEditCourseContent: true, + }); }); it('renders title and buttons', async () => { - render(); + renderWithWrapper(); expect(await screen.findByText('Visibility')).toBeInTheDocument(); expect(await screen.findByRole('button', { name: 'Student Visible' })).toBeInTheDocument(); expect(await screen.findByRole('button', { name: 'Staff Only' })).toBeInTheDocument(); @@ -38,7 +56,7 @@ describe('VisibilitySection component', () => { it('clicking staff only calls onChange with staff and hideAfterDue false', async () => { const user = userEvent.setup(); const onChange = jest.fn(); - render(); + renderWithWrapper(); await user.click(await screen.findByRole('button', { name: 'Staff Only' })); await waitFor(async () => { @@ -50,7 +68,7 @@ describe('VisibilitySection component', () => { const user = userEvent.setup(); const onChange = jest.fn(); mockUseCourseItemData.mockReturnValue({ data: { visibilityState: VisibilityTypes.STAFF_ONLY } }); - render(); + renderWithWrapper(); await user.click(await screen.findByRole('button', { name: 'Student Visible' })); await waitFor(async () => { @@ -63,7 +81,7 @@ describe('VisibilitySection component', () => { const onChange = jest.fn(); // initial data not staff only mockUseCourseItemData.mockReturnValue({ data: { visibilityState: undefined, hideAfterDue: false } }); - render(); + renderWithWrapper(); const checkbox = await screen.findByRole('checkbox'); await user.click(checkbox); @@ -76,7 +94,7 @@ describe('VisibilitySection component', () => { const user = userEvent.setup(); const onChange = jest.fn(); mockUseCourseItemData.mockReturnValue({ data: { visibilityState: undefined, hideAfterDue: true } }); - render(); + renderWithWrapper(); await user.click(await screen.findByRole('button', { name: 'Staff Only' })); await waitFor(async () => { @@ -88,7 +106,27 @@ describe('VisibilitySection component', () => { const onChange = jest.fn(); // when item is staff only, checkbox should not be present mockUseCourseItemData.mockReturnValue({ data: { visibilityState: VisibilityTypes.STAFF_ONLY } }); - render(); + renderWithWrapper(); expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); }); + + it('disables both visibility buttons when the user cannot edit course content', async () => { + validateUserPermissionsMock.mockResolvedValue({ + canEditCourseContent: false, + }); + renderWithWrapper(); + + expect(await screen.findByRole('button', { name: 'Student Visible' })).toBeDisabled(); + expect(await screen.findByRole('button', { name: 'Staff Only' })).toBeDisabled(); + }); + + it('disables the hide-after-due checkbox when the user cannot edit course content', async () => { + validateUserPermissionsMock.mockResolvedValue({ + canEditCourseContent: false, + }); + mockUseCourseItemData.mockReturnValue({ data: { visibilityState: undefined, hideAfterDue: false } }); + renderWithWrapper(); + + expect(await screen.findByRole('checkbox')).toBeDisabled(); + }); }); diff --git a/src/course-outline/outline-sidebar/info-sidebar/sharedSettings/VisibilitySection.tsx b/src/course-outline/outline-sidebar/info-sidebar/sharedSettings/VisibilitySection.tsx index 75793104fb..fc69365bd8 100644 --- a/src/course-outline/outline-sidebar/info-sidebar/sharedSettings/VisibilitySection.tsx +++ b/src/course-outline/outline-sidebar/info-sidebar/sharedSettings/VisibilitySection.tsx @@ -7,6 +7,7 @@ import { SidebarSection } from '@src/generic/sidebar'; import { useFieldDraft } from '@src/hooks/useFieldDraft'; import { useMemo } from 'react'; import messages from '../messages'; +import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; interface Props> { itemId: string; @@ -22,6 +23,7 @@ interface State { export const VisibilitySection = ({ itemId, isSubsection, onChange }: Props) => { const intl = useIntl(); const { data: itemData } = useCourseItemData(itemId); + const { canEditCourseContent } = useCourseAuthoringContext(); const serverState = useMemo(() => ({ isVisibleToStaffOnly: itemData?.visibilityState === VisibilityTypes.STAFF_ONLY, @@ -42,12 +44,14 @@ export const VisibilitySection = ({ itemId, isSubsection, onChange }: Props) => > - {showPasteUnit && ( + {canEditCourseContent && + ( + + )} + {canEditCourseContent && showPasteUnit && ( )} diff --git a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx index 0f4ca66bff..b57deff504 100644 --- a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx +++ b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx @@ -23,7 +23,7 @@ const SequenceNavigationTabs = ({ const dispatch = useDispatch(); const navigate = useNavigate(); const sequenceId = useSelector(getSequenceId); - const { courseId } = useCourseAuthoringContext(); + const { courseId, canEditCourseContent } = useCourseAuthoringContext(); const courseUnit = useSelector(getCourseUnitData); const sequenceChildAddable = courseUnit?.ancestorInfo?.ancestors?.[0]?.actions?.childAddable; @@ -67,7 +67,7 @@ const SequenceNavigationTabs = ({ isActive={unitId === buttonUnitId} /> ))} - {sequenceChildAddable && ( + {canEditCourseContent && sequenceChildAddable && ( - {!readOnly && ( + {canEditCourseContent && !readOnly && ( - - + {canEditCourseContent && ( + + + + {intl.formatMessage(messages.cancelButton)} + + + + + )} )} diff --git a/src/generic/configure-modal/UnitTab.tsx b/src/generic/configure-modal/UnitTab.tsx index 852e9c9b99..83b01004ea 100644 --- a/src/generic/configure-modal/UnitTab.tsx +++ b/src/generic/configure-modal/UnitTab.tsx @@ -8,6 +8,7 @@ import classNames from 'classnames'; import { COURSE_BLOCK_NAMES } from '../../constants'; import messages from './messages'; +import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; export type UserPartitionInfo = { selectablePartitions: { @@ -45,16 +46,19 @@ export const DiscussionEditComponent = ({ }: { discussionEnabled: boolean; handleDiscussionChange: (e: any) => void; -}) => ( - <> - - - -

- -

- -); +}) => { + const { canEditCourseContent } = useCourseAuthoringContext(); + return ( + <> + + + +

+ +

+ + ); +}; export interface AccessEditComponentProps { selectedPartitionIndex?: number; @@ -70,6 +74,7 @@ export const AccessEditComponent = ({ selectedGroups, }: AccessEditComponentProps) => { const intl = useIntl(); + const { canEditCourseContent } = useCourseAuthoringContext(); const checkIsDeletedGroup = (group) => { const isGroupSelected = selectedGroups.includes(group.id.toString()); @@ -92,6 +97,7 @@ export const AccessEditComponent = ({ value={selectedPartitionIndex} onChange={handleSelect} data-testid="group-type-select" + disabled={!canEditCourseContent} >