diff --git a/plugins/course-apps/proctoring/Settings.test.jsx b/plugins/course-apps/proctoring/Settings.test.jsx index 6a07c7143c..4ee6953dad 100644 --- a/plugins/course-apps/proctoring/Settings.test.jsx +++ b/plugins/course-apps/proctoring/Settings.test.jsx @@ -472,7 +472,8 @@ describe('ProctoredExamSettings', () => { }); // (1) for studio settings // (2) for course details - expect(axiosMock.history.get.length).toBe(2); + // (3) for user course permissions + expect(axiosMock.history.get.length).toBe(3); expect(axiosMock.history.get[0].url.includes('proctored_exam_settings')).toEqual(true); }); diff --git a/src/CourseAuthoringContext.tsx b/src/CourseAuthoringContext.tsx index bc444bd7cb..941b8e7f3a 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,7 +63,14 @@ export const CourseAuthoringProvider = ({ closeUnlinkModal, ] = useToggleWithValue(); + const { + canEditCourseContent, + canPublishCourseContent, + isLoading: isUserPermissionsLoading, + } = useCourseUserPermissions(courseId, getCourseOutlinePermissions(courseId)); + const getUnitUrl = (locator: string) => `/course/${courseId}/container/${locator}`; + const isLoading = isUserPermissionsLoading; /** * Open the unit page for a given locator. @@ -78,6 +90,9 @@ export const CourseAuthoringProvider = ({ openUnlinkModal, closeUnlinkModal, currentUnlinkModalData, + isLoading, + canEditCourseContent, + canPublishCourseContent, }), [ courseId, courseDetails, @@ -89,6 +104,9 @@ export const CourseAuthoringProvider = ({ openUnlinkModal, closeUnlinkModal, currentUnlinkModalData, + canEditCourseContent, + canPublishCourseContent, + isLoading, ]); 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/CourseOutline.test.tsx b/src/course-outline/CourseOutline.test.tsx index bc97222ff7..55e28655e0 100644 --- a/src/course-outline/CourseOutline.test.tsx +++ b/src/course-outline/CourseOutline.test.tsx @@ -9,6 +9,7 @@ import { getClipboardUrl } from '@src/generic/data/api'; import { ContainerType } from '@src/generic/key-utils'; import { getDownstreamApiUrl } from '@src/generic/unlink-modal/data/api'; import { CourseAuthoringProvider } from '@src/CourseAuthoringContext'; +import { mockWaffleFlags } from '@src/data/apiHooks.mock'; import { act, fireEvent, @@ -552,6 +553,11 @@ const renderComponent = () => describe('', () => { beforeEach(async () => { const mocks = initializeMocks(); + mockWaffleFlags({ enableAuthzCourseAuthoring: false }); + mocks.validateUserPermissionsMock.mockResolvedValue({ + canEditCourseContent: true, + canPublishCourseContent: true, + }); selectedContainerId = undefined; // restore index mock — use reorder outline spec (section[0] has 2 subsections for configure/drag tests) courseOutlineIndexMock = buildTestOutline({ diff --git a/src/course-outline/CourseOutline.tsx b/src/course-outline/CourseOutline.tsx index 1bc151a171..acff556a84 100644 --- a/src/course-outline/CourseOutline.tsx +++ b/src/course-outline/CourseOutline.tsx @@ -51,6 +51,8 @@ const CourseOutline = () => { const location = useLocation(); const { courseId, + canEditCourseContent, + isLoading: isLoadingAuthoringContext, } = useCourseAuthoringContext(); const { courseUsageKey, @@ -94,7 +96,14 @@ const CourseOutline = () => { const [showSuccessAlert, setShowSuccessAlert] = useState(false); const isInternetConnectionAlertFailed = savingStatus === RequestStatus.FAILED; - const isReIndexShow = Boolean(reindexLink); + const isReIndexShow = canEditCourseContent && Boolean(reindexLink); + + // The header's "+ Add" button creates course content, so gate its visibility behind the + // edit permission. This is scoped to the header actions and leaves the outline tree unaffected. + const headerCourseActions = useMemo( + () => ({ ...courseActions, childAddable: canEditCourseContent && courseActions.childAddable }), + [courseActions, canEditCourseContent], + ); const handleAddBlock = useCreateCourseBlock(courseId); const pasteMutation = usePasteItem(courseId); @@ -169,7 +178,7 @@ const CourseOutline = () => { } }, [location, courseId, courseName]); - if (isLoading) { + if (isLoading || isLoadingAuthoringContext) { // eslint-disable-next-line react/jsx-no-useless-fragment return ( @@ -249,7 +258,7 @@ const CourseOutline = () => { headerNavigationsActions={headerNavigationsActions} isDisabledReindexButton={isDisabledReindexButton} hasSections={Boolean(sections.length)} - courseActions={courseActions} + courseActions={headerCourseActions} errors={errors} sections={sections} /> diff --git a/src/course-outline/OutlineNode.tsx b/src/course-outline/OutlineNode.tsx index 5881792589..625233e705 100644 --- a/src/course-outline/OutlineNode.tsx +++ b/src/course-outline/OutlineNode.tsx @@ -105,7 +105,7 @@ const OutlineNode = ({ const { activeId, overId } = useContext(DragContext); const { selectedContainerState, openContainerSidebar, setSelectedContainerState } = useOutlineSidebarContext(); - const { courseId, openUnlinkModal, getUnitUrl } = useCourseAuthoringContext(); + const { courseId, openUnlinkModal, getUnitUrl, canEditCourseContent } = useCourseAuthoringContext(); const duplicateMutation = useDuplicateItem(courseId); const { openPublishModal } = useCourseOutlineContext(); const queryClient = useQueryClient(); @@ -236,7 +236,7 @@ const OutlineNode = ({ else { onOrderChange(effectiveSection, getPossibleMoves!(index, 1)); } }; - const isDraggable = model.isDraggable(actions, isHeaderVisible); + const isDraggable = canEditCourseContent && model.isDraggable(actions, isHeaderVisible); const titleComponent = depth < 2 ? ( @@ -335,7 +335,6 @@ const OutlineNode = ({ {...(depth === 2 ? { isVertical: true, - enableCopyPasteUnits: blk.enableCopyPasteUnits ?? false, onClickCopy: () => copyToClipboard(blk.id), discussionEnabled: blk.discussionEnabled, discussionsSettings, @@ -352,7 +351,7 @@ const OutlineNode = ({ data-testid={levelConfig.contentTestId} onClick={(e) => onClickCard(e, false)} > - {depth === 0 && onOpenHighlightsModal && ( + {canEditCourseContent && depth === 0 && onOpenHighlightsModal && (
} - iconAs={EditIcon} - onClick={onEditClick} - disabled={editMutation.isPending} - /> + {canEditCourseContent && + ( + {intl.formatMessage(messages.altButtonRename)}} + iconAs={EditIcon} + onClick={onEditClick} + disabled={editMutation.isPending} + /> + )} )}
@@ -313,105 +314,110 @@ const CardHeader = ({ onClick={onClickSync} /> )} - - - - {isSequential && proctoringExamConfigurationLink && ( - - {intl.formatMessage(messages.menuProctoringLinkText)} - - )} - - {intl.formatMessage(messages.menuPublish)} - - - {intl.formatMessage(messages.menuConfigure)} - - {getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && ( + {canEditCourseContent && ( + + + + {isSequential && proctoringExamConfigurationLink && ( + + {intl.formatMessage(messages.menuProctoringLinkText)} + + )} + {canPublishCourseContent && + ( + + {intl.formatMessage(messages.menuPublish)} + + )} - {intl.formatMessage(messages.menuManageTags)} + {intl.formatMessage(messages.menuConfigure)} - )} + {getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && ( + + {intl.formatMessage(messages.menuManageTags)} + + )} - {isVertical && enableCopyPasteUnits && ( - - {intl.formatMessage(messages.menuCopy)} - - )} - {actions.duplicable && ( - - {intl.formatMessage(messages.menuDuplicate)} - - )} - {actions.draggable && ( - <> + {isVertical && ( + + {intl.formatMessage(messages.menuCopy)} + + )} + {actions.duplicable && ( - {intl.formatMessage(messages.menuMoveUp)} + {intl.formatMessage(messages.menuDuplicate)} + )} + {actions.draggable && ( + <> + + {intl.formatMessage(messages.menuMoveUp)} + + + {intl.formatMessage(messages.menuMoveDown)} + + + )} + {((actions.unlinkable ?? null) !== null || actions.deletable) && } + {(actions.unlinkable ?? null) !== null && ( - {intl.formatMessage(messages.menuMoveDown)} + {intl.formatMessage(messages.menuUnlink)} - - )} - {((actions.unlinkable ?? null) !== null || actions.deletable) && } - {(actions.unlinkable ?? null) !== null && ( - - {intl.formatMessage(messages.menuUnlink)} - - )} - {actions.deletable && ( - - {intl.formatMessage(messages.menuDelete)} - - )} - - + )} + {actions.deletable && ( + + + + )} + + + )}
diff --git a/src/course-outline/outline-sidebar/OutlineSidebarPagesContext.tsx b/src/course-outline/outline-sidebar/OutlineSidebarPagesContext.tsx index e95adfce4f..7f35350e83 100644 --- a/src/course-outline/outline-sidebar/OutlineSidebarPagesContext.tsx +++ b/src/course-outline/outline-sidebar/OutlineSidebarPagesContext.tsx @@ -14,25 +14,28 @@ import { OutlineAlignSidebar } from './OutlineAlignSidebar'; import OutlineHelpSidebar from './OutlineHelpSidebar'; import { InfoSidebar } from './info-sidebar/InfoSidebar'; import messages from './messages'; +import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; export type OutlineSidebarPages = { info: SidebarPage; help: SidebarPage; - add: SidebarPage; + add?: SidebarPage; align?: SidebarPage; }; -const getOutlineSidebarPages = () => ({ +const getOutlineSidebarPages = (canEditCourseContent: boolean = false) => ({ info: { component: InfoSidebar, icon: Info, title: messages.sidebarButtonInfo, }, - add: { - component: AddSidebar, - icon: Plus, - title: messages.sidebarButtonAdd, - }, + ...(canEditCourseContent && { + add: { + component: AddSidebar, + icon: Plus, + title: messages.sidebarButtonAdd, + }, + }), ...(getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && { align: { component: OutlineAlignSidebar, @@ -84,9 +87,11 @@ type OutlineSidebarPagesProviderProps = { }; export const OutlineSidebarPagesProvider = ({ children }: OutlineSidebarPagesProviderProps) => { + const { canEditCourseContent } = useCourseAuthoringContext(); + // align page is sometimes not added when getOutlineSidebarPages() is called at the top level. // So if we call it inside the hook, getConfig has updated values and align page is added. - const sidebarPages = useMemo(getOutlineSidebarPages, []); + const sidebarPages = useMemo(() => getOutlineSidebarPages(canEditCourseContent), [canEditCourseContent]); return ( diff --git a/src/course-outline/status-bar/StatusBar.test.tsx b/src/course-outline/status-bar/StatusBar.test.tsx index 597c20b7e7..21d2ea7175 100644 --- a/src/course-outline/status-bar/StatusBar.test.tsx +++ b/src/course-outline/status-bar/StatusBar.test.tsx @@ -1,8 +1,10 @@ import { VIDEO_SHARING_OPTIONS } from '@src/course-outline/constants'; import { CourseOutlineStatusBar } from '@src/course-outline/data/types'; import { initializeMocks, render, screen } from '@src/testUtils'; +import { mockWaffleFlags } from '@src/data/apiHooks.mock'; import { StatusBar, StatusBarProps } from './StatusBar'; import messages from './messages'; +import { CourseAuthoringProvider } from '@src/CourseAuthoringContext'; const courseId = 'course-v1:123'; const isLoading = false; @@ -36,6 +38,13 @@ jest.mock('@src/course-outline/data/apiHooks', () => ({ isLoading: false, }), })); + +let validateUserPermissionsMock; +const mockPermissions = (canEditCourseContent = true) => { + mockWaffleFlags({ enableAuthzCourseAuthoring: !canEditCourseContent }); + validateUserPermissionsMock.mockResolvedValue({ canEditCourseContent }); +}; + const mockOpenEnableHighlightsModal = jest.fn(); const mockHandleVideoSharingOptionChange = jest.fn(); @@ -49,13 +58,18 @@ const renderComponent = (props?: Partial) => handleVideoSharingOptionChange={mockHandleVideoSharingOptionChange} {...props} />, + { + extraWrapper: ({ children }) => {children}, + }, ); describe('', () => { beforeEach(() => { - initializeMocks(); + const mocks = initializeMocks(); + validateUserPermissionsMock = mocks.validateUserPermissionsMock; jest.useFakeTimers(); jest.setSystemTime(new Date('2013-03-05')); + mockPermissions(true); }); it('renders StatusBar component correctly', async () => { @@ -145,6 +159,29 @@ describe('', () => { expect(await screen.findByText(messages.highlightEmailsEnabled.defaultMessage)).toBeInTheDocument(); }); + it('does not render the enable highlights button when canEditCourseContent is false', async () => { + mockPermissions(false); + renderComponent(); + + // Wait for the status bar to render, then confirm the enable button is absent. + expect(await screen.findByText('Feb 05, 2013 - Apr 09, 2013')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Enable highlights emails' })).not.toBeInTheDocument(); + }); + + it('still shows the enabled highlights message when canEditCourseContent is false', async () => { + mockPermissions(false); + renderComponent({ + statusBarData: { + ...statusBarData, + highlightsEnabledForMessaging: true, + }, + }); + + // The "enabled" message is informational (not an edit action), so it renders regardless of permission. + expect(await screen.findByText(messages.highlightEmailsEnabled.defaultMessage)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Enable highlights emails' })).not.toBeInTheDocument(); + }); + it('does render video sharing dropdown if enabled', async () => { renderComponent({ statusBarData: { diff --git a/src/course-outline/status-bar/StatusBar.tsx b/src/course-outline/status-bar/StatusBar.tsx index 634292a2f1..cfed93b1dc 100644 --- a/src/course-outline/status-bar/StatusBar.tsx +++ b/src/course-outline/status-bar/StatusBar.tsx @@ -29,6 +29,7 @@ import { useHelpUrls } from '@src/help-urls/hooks'; import messages from './messages'; import { NotificationStatusIcon } from './NotificationStatusIcon'; +import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; const CourseBadge = ({ startDate, endDate }: { startDate: Moment; endDate: Moment; }) => { const now = moment().utc(); @@ -192,19 +193,23 @@ const Highlights = ({ highlightsEnabledForMessaging, openEnableHighlightsModal } }) => { const intl = useIntl(); + const { canEditCourseContent } = useCourseAuthoringContext(); + if (highlightsEnabledForMessaging) { return ( {intl.formatMessage(messages.highlightEmailsEnabled)} ); - } else { - return ( - - ); } + if (!canEditCourseContent) { + return null; + } + return ( + + ); }; const VideoSharingDropdown = ({ handleVideoSharingOptionChange, videoSharingOptions }: { diff --git a/src/generic/sidebar/Sidebar.tsx b/src/generic/sidebar/Sidebar.tsx index 35a83739a1..b4e292e916 100644 --- a/src/generic/sidebar/Sidebar.tsx +++ b/src/generic/sidebar/Sidebar.tsx @@ -88,12 +88,17 @@ export function Sidebar({ }: SidebarProps) { const intl = useIntl(); + // currentPageKey may point to a page that isn't currently available (e.g. one hidden by + // permissions or a stale value coming from the URL). Fall back to the first available page + // so the sidebar renders instead of crashing on an undefined page. + const [fallbackPageKey] = Object.keys(pages) as (keyof T)[]; + const effectivePageKey = pages[currentPageKey] ? currentPageKey : fallbackPageKey; const { component: SidebarComponent, icon: SidebarIcon, title, - } = pages[currentPageKey]; - const activeKey = isOpen ? currentPageKey : undefined; + } = pages[effectivePageKey]; + const activeKey = isOpen ? effectivePageKey : undefined; return ( diff --git a/src/studio-home/StudioHome.tsx b/src/studio-home/StudioHome.tsx index 6f99812abc..5d06648f34 100644 --- a/src/studio-home/StudioHome.tsx +++ b/src/studio-home/StudioHome.tsx @@ -1,4 +1,5 @@ import React, { useCallback } from 'react'; +import classNames from 'classnames'; import { Button, Container, @@ -85,7 +86,11 @@ const StudioHome = () => { if (canViewConsoleTeams) { headerButtons.push( -
+