Skip to content

Commit e8a3ff0

Browse files
feat: adding permission checks on course outline page for course content edition
1 parent 67cee5c commit e8a3ff0

16 files changed

Lines changed: 300 additions & 130 deletions

File tree

plugins/course-apps/proctoring/Settings.test.jsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -472,7 +472,8 @@ describe('ProctoredExamSettings', () => {
472472
});
473473
// (1) for studio settings
474474
// (2) for course details
475-
expect(axiosMock.history.get.length).toBe(2);
475+
// (3) for user course permissions
476+
expect(axiosMock.history.get.length).toBe(3);
476477
expect(axiosMock.history.get[0].url.includes('proctored_exam_settings')).toEqual(true);
477478
});
478479

src/CourseAuthoringContext.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { type UnitXBlock, type XBlock } from '@src/data/types';
1010
import { CourseDetailsData } from './data/api';
1111
import { useCourseDetails } from './data/apiHooks';
1212
import { RequestStatusType } from './data/constants';
13+
import { useCourseUserPermissions } from '@src/authz/hooks';
14+
import { getCourseOutlinePermissions } from '@src/authz/permissionHelpers';
1315

1416
export type ModalState = {
1517
value?: XBlock | UnitXBlock;
@@ -29,6 +31,9 @@ export type CourseAuthoringContextData = {
2931
currentUnlinkModalData?: ModalState;
3032
openUnlinkModal: (value: ModalState) => void;
3133
closeUnlinkModal: () => void;
34+
isLoading: boolean;
35+
canEditCourseContent: boolean;
36+
canPublishCourseContent: boolean;
3237
};
3338

3439
/**
@@ -58,7 +63,14 @@ export const CourseAuthoringProvider = ({
5863
closeUnlinkModal,
5964
] = useToggleWithValue<ModalState>();
6065

66+
const {
67+
canEditCourseContent,
68+
canPublishCourseContent,
69+
isLoading: isUserPermissionsLoading,
70+
} = useCourseUserPermissions(courseId, getCourseOutlinePermissions(courseId));
71+
6172
const getUnitUrl = (locator: string) => `/course/${courseId}/container/${locator}`;
73+
const isLoading = isUserPermissionsLoading;
6274

6375
/**
6476
* Open the unit page for a given locator.
@@ -78,6 +90,9 @@ export const CourseAuthoringProvider = ({
7890
openUnlinkModal,
7991
closeUnlinkModal,
8092
currentUnlinkModalData,
93+
isLoading,
94+
canEditCourseContent,
95+
canPublishCourseContent,
8196
}), [
8297
courseId,
8398
courseDetails,
@@ -89,6 +104,9 @@ export const CourseAuthoringProvider = ({
89104
openUnlinkModal,
90105
closeUnlinkModal,
91106
currentUnlinkModalData,
107+
canEditCourseContent,
108+
canPublishCourseContent,
109+
isLoading,
92110
]);
93111

94112
return (

src/authz/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ export const CONTENT_LIBRARY_PERMISSIONS = {
1717

1818
export const COURSE_PERMISSIONS = {
1919
VIEW_COURSE: 'courses.view_course',
20+
CREATE_COURSE: 'courses.create_course',
2021
EDIT_COURSE_CONTENT: 'courses.edit_course_content',
22+
PUBLISH_COURSE_CONTENT: 'courses.publish_course_content',
2123

2224
MANAGE_ADVANCED_SETTINGS: 'courses.manage_advanced_settings',
2325

src/authz/permissionHelpers.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,10 @@ describe('permissionHelpers', () => {
164164
action: COURSE_PERMISSIONS.EDIT_COURSE_CONTENT,
165165
scope: courseId,
166166
},
167+
canPublishCourseContent: {
168+
action: COURSE_PERMISSIONS.PUBLISH_COURSE_CONTENT,
169+
scope: courseId,
170+
},
167171
});
168172
});
169173
});

src/authz/permissionHelpers.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ export const getCourseOutlinePermissions = (courseId: string) => ({
7575
action: COURSE_PERMISSIONS.EDIT_COURSE_CONTENT,
7676
scope: courseId,
7777
},
78+
canPublishCourseContent: {
79+
action: COURSE_PERMISSIONS.PUBLISH_COURSE_CONTENT,
80+
scope: courseId,
81+
},
7882
});
7983

8084
export const getLibraryUpdatesPermissions = (courseId: string) => ({

src/course-outline/CourseOutline.test.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,16 @@ jest.mock('./data/api', () => ({
188188
getTagsCount: () => jest.fn().mockResolvedValue({}),
189189
}));
190190

191+
jest.mock('@src/authz/hooks', () => ({
192+
...jest.requireActual('@src/authz/hooks'),
193+
useCourseUserPermissions: jest.fn().mockReturnValue({
194+
isLoading: false,
195+
isAuthzEnabled: false,
196+
canEditCourseContent: true,
197+
canPublishCourseContent: true,
198+
}),
199+
}));
200+
191201
jest.mock('@edx/frontend-platform/logging', () => ({
192202
logError: jest.fn(),
193203
}));

src/course-outline/CourseOutline.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ const CourseOutline = () => {
5151
const location = useLocation();
5252
const {
5353
courseId,
54+
canEditCourseContent,
55+
isLoading: isLoadingAuthoringContext,
5456
} = useCourseAuthoringContext();
5557
const {
5658
courseUsageKey,
@@ -94,7 +96,14 @@ const CourseOutline = () => {
9496
const [showSuccessAlert, setShowSuccessAlert] = useState(false);
9597

9698
const isInternetConnectionAlertFailed = savingStatus === RequestStatus.FAILED;
97-
const isReIndexShow = Boolean(reindexLink);
99+
const isReIndexShow = canEditCourseContent && Boolean(reindexLink);
100+
101+
// The header's "+ Add" button creates course content, so gate its visibility behind the
102+
// edit permission. This is scoped to the header actions and leaves the outline tree unaffected.
103+
const headerCourseActions = useMemo(
104+
() => ({ ...courseActions, childAddable: canEditCourseContent && courseActions.childAddable }),
105+
[courseActions, canEditCourseContent],
106+
);
98107

99108
const handleAddBlock = useCreateCourseBlock(courseId);
100109
const pasteMutation = usePasteItem(courseId);
@@ -169,7 +178,7 @@ const CourseOutline = () => {
169178
}
170179
}, [location, courseId, courseName]);
171180

172-
if (isLoading) {
181+
if (isLoading || isLoadingAuthoringContext) {
173182
// eslint-disable-next-line react/jsx-no-useless-fragment
174183
return (
175184
<Row className="m-0 mt-4 justify-content-center">
@@ -249,7 +258,7 @@ const CourseOutline = () => {
249258
headerNavigationsActions={headerNavigationsActions}
250259
isDisabledReindexButton={isDisabledReindexButton}
251260
hasSections={Boolean(sections.length)}
252-
courseActions={courseActions}
261+
courseActions={headerCourseActions}
253262
errors={errors}
254263
sections={sections}
255264
/>

src/course-outline/OutlineNode.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ const OutlineNode = ({
105105

106106
const { activeId, overId } = useContext(DragContext);
107107
const { selectedContainerState, openContainerSidebar, setSelectedContainerState } = useOutlineSidebarContext();
108-
const { courseId, openUnlinkModal, getUnitUrl } = useCourseAuthoringContext();
108+
const { courseId, openUnlinkModal, getUnitUrl, canEditCourseContent } = useCourseAuthoringContext();
109109
const duplicateMutation = useDuplicateItem(courseId);
110110
const { openPublishModal } = useCourseOutlineContext();
111111
const queryClient = useQueryClient();
@@ -236,7 +236,7 @@ const OutlineNode = ({
236236
else { onOrderChange(effectiveSection, getPossibleMoves!(index, 1)); }
237237
};
238238

239-
const isDraggable = model.isDraggable(actions, isHeaderVisible);
239+
const isDraggable = canEditCourseContent && model.isDraggable(actions, isHeaderVisible);
240240

241241
const titleComponent = depth < 2 ?
242242
(
@@ -352,7 +352,7 @@ const OutlineNode = ({
352352
data-testid={levelConfig.contentTestId}
353353
onClick={(e) => onClickCard(e, false)}
354354
>
355-
{depth === 0 && onOpenHighlightsModal && (
355+
{canEditCourseContent && depth === 0 && onOpenHighlightsModal && (
356356
<div className="outline-section__status mb-1">
357357
<Button
358358
className="p-0 bg-transparent"
@@ -381,14 +381,14 @@ const OutlineNode = ({
381381
})}
382382
>
383383
{children}
384-
{actions.childAddable && (
384+
{canEditCourseContent && actions.childAddable && (
385385
<OutlineAddChildButtons
386386
childType={levelConfig.containerType!}
387387
parentLocator={blk.id}
388388
grandParentLocator={depth === 1 ? parentSection?.id : undefined}
389389
/>
390390
)}
391-
{showPaste && (
391+
{canEditCourseContent && showPaste && (
392392
<PasteComponent
393393
className="mt-4 border-gray-500 rounded-0"
394394
text={intl.formatMessage(outlineNodeMessages.pasteButton)}

src/course-outline/OutlineTree.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
} from './drag-helper/utils';
1818
import { applyReorderMove } from './drag-helper/utils';
1919
import { type Depth, LEVEL_NAMES } from './outline-level';
20+
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
2021

2122
export interface OutlineTreeProps {
2223
sections: XBlock[];
@@ -81,6 +82,8 @@ const OutlineTree = ({
8182
await commitSectionReorder(sectionListIds);
8283
}, [sections, previewSections, commitSectionReorder]);
8384

85+
const { canEditCourseContent } = useCourseAuthoringContext();
86+
8487
const handleSubsectionOrderChange = useCallback(
8588
async (section: XBlock, moveDetails: SubsectionMoveDetails | null) => {
8689
applyReorderMove(moveDetails, section, previewSections, commitSubsectionReorder);
@@ -180,7 +183,7 @@ const OutlineTree = ({
180183
)}
181184
</SortableContext>
182185
</DraggableList>
183-
{courseActions.childAddable && (
186+
{canEditCourseContent && courseActions.childAddable && (
184187
<OutlineAddChildButtons
185188
childType={ContainerType.Section}
186189
parentLocator={courseUsageKey}
@@ -190,7 +193,7 @@ const OutlineTree = ({
190193
) :
191194
(
192195
<EmptyPlaceholder>
193-
{courseActions.childAddable ?
196+
{canEditCourseContent && courseActions.childAddable ?
194197
(
195198
<OutlineAddChildButtons
196199
childType={ContainerType.Section}

src/course-outline/card-header/CardHeader.test.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
waitFor,
99
} from '@src/testUtils';
1010
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
11+
import { mockWaffleFlags } from '@src/data/apiHooks.mock';
12+
import { useCourseUserPermissions } from '@src/authz/hooks';
1113
import { courseId } from '@src/schedule-and-details/__mocks__/courseDetails';
1214
import { userEvent } from '@testing-library/user-event';
1315
import { renderCard, setupCardTestMocks } from '../__mocks__/testSetup';
@@ -39,6 +41,14 @@ jest.mock('@src/course-outline/data/apiHooks', () => ({
3941
useUpdateCourseBlockName: () => useUpdateCourseBlockNameMock,
4042
}));
4143

44+
jest.mock('@src/authz/hooks', () => ({
45+
useCourseUserPermissions: jest.fn().mockReturnValue({
46+
isLoading: false,
47+
canEditCourseContent: true,
48+
canPublishCourseContent: true,
49+
}),
50+
}));
51+
4252
const cardHeaderProps = {
4353
title: 'Some title',
4454
status: ITEM_BADGE_STATUS.live,
@@ -66,6 +76,11 @@ const cardHeaderProps = {
6676
},
6777
};
6878

79+
const mockPermissions = (mockedPermissions) => {
80+
mockWaffleFlags({ enableAuthzCourseAuthoring: !!mockedPermissions });
81+
jest.mocked(useCourseUserPermissions).mockReturnValue(mockedPermissions);
82+
};
83+
6984
const renderComponent = (props?: object, entry = '/') => {
7085
const titleComponent = (
7186
<TitleButton
@@ -579,4 +594,30 @@ describe('<CardHeader />', () => {
579594
await act(async () => fireEvent.click(unlinkMenuItem));
580595
expect(onClickUnlinkMock).toHaveBeenCalled();
581596
});
597+
598+
describe('canEditCourseContent permission', () => {
599+
it('renders the rename button and actions menu when canEditCourseContent is true', async () => {
600+
renderComponent({ canEditCourseContent: true });
601+
602+
expect(await screen.findByTestId('subsection-edit-button')).toBeInTheDocument();
603+
expect(await screen.findByTestId('subsection-card-header__menu')).toBeInTheDocument();
604+
});
605+
606+
it('does not render the rename button when canEditCourseContent is false', async () => {
607+
mockPermissions({ canEditCourseContent: false });
608+
renderComponent();
609+
610+
expect(await screen.findByText(cardHeaderProps.title)).toBeInTheDocument();
611+
expect(screen.queryByTestId('subsection-edit-button')).not.toBeInTheDocument();
612+
});
613+
614+
it('does not render the actions menu when canEditCourseContent is false', async () => {
615+
mockPermissions({ canEditCourseContent: false });
616+
renderComponent();
617+
618+
expect(await screen.findByText(cardHeaderProps.title)).toBeInTheDocument();
619+
expect(screen.queryByTestId('subsection-card-header__menu')).not.toBeInTheDocument();
620+
expect(screen.queryByTestId('subsection-card-header__menu-button')).not.toBeInTheDocument();
621+
});
622+
});
582623
});

0 commit comments

Comments
 (0)