Skip to content

Commit 37ef93f

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

16 files changed

Lines changed: 186 additions & 41 deletions

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: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ const CourseOutline = () => {
6767
isCustomRelativeDatesActive,
6868
isLoading,
6969
isLoadingDenied,
70+
canEditCourseContent,
7071
courseName,
7172
errors,
7273
loadingStatus,
@@ -94,7 +95,14 @@ const CourseOutline = () => {
9495
const [showSuccessAlert, setShowSuccessAlert] = useState(false);
9596

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

99107
const handleAddBlock = useCreateCourseBlock(courseId);
100108
const pasteMutation = usePasteItem(courseId);
@@ -249,7 +257,7 @@ const CourseOutline = () => {
249257
headerNavigationsActions={headerNavigationsActions}
250258
isDisabledReindexButton={isDisabledReindexButton}
251259
hasSections={Boolean(sections.length)}
252-
courseActions={courseActions}
260+
courseActions={headerCourseActions}
253261
errors={errors}
254262
sections={sections}
255263
/>
@@ -285,6 +293,7 @@ const CourseOutline = () => {
285293
<OutlineTree
286294
sections={sections}
287295
courseActions={courseActions}
296+
canEditCourseContent={canEditCourseContent}
288297
courseUsageKey={courseUsageKey}
289298
hasOutlineIndexError={!!errors?.outlineIndexApi}
290299
isCustomRelativeDatesActive={isCustomRelativeDatesActive}

src/course-outline/CourseOutlineContext.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import {
2828
getLastEditableSubsection,
2929
} from './state';
3030
import { useCourseAuthoringContext, type ModalState } from '@src/CourseAuthoringContext';
31+
import { useCourseUserPermissions } from '@src/authz/hooks';
32+
import { getCourseOutlinePermissions } from '@src/authz/permissionHelpers';
3133

3234
import {
3335
CourseOutline,
@@ -41,6 +43,7 @@ type CourseOutlineContextData = {
4143
courseUsageKey: string;
4244
sections: XBlock[];
4345
courseActions: XBlockActions;
46+
canEditCourseContent: boolean;
4447
statusBarData: CourseOutlineStatusBar;
4548
savingStatus: string;
4649
errors: OutlinePageErrors;
@@ -92,6 +95,11 @@ const CourseOutlineContext = createContext<CourseOutlineContextData | undefined>
9295
export const CourseOutlineProvider = ({ children }: { children?: React.ReactNode; }) => {
9396
const { courseId } = useCourseAuthoringContext();
9497

98+
const {
99+
canEditCourseContent,
100+
isLoading: isEditPermissionLoading,
101+
} = useCourseUserPermissions(courseId, getCourseOutlinePermissions(courseId));
102+
95103
// Dismissed error signatures: { [errorKey]: signatureAtTimeOfDismissal }
96104
// Dismissal applies only while the current error's payload signature matches.
97105
const [dismissedErrorSignatures, setDismissedErrorSignatures] = useState<Record<string, string>>({});
@@ -237,11 +245,12 @@ export const CourseOutlineProvider = ({ children }: { children?: React.ReactNode
237245
courseUsageKey: effectiveOutlineIndexData?.courseStructure?.id || courseId,
238246
sections: visibleSections,
239247
courseActions,
248+
canEditCourseContent,
240249
statusBarData,
241250
savingStatus,
242251
errors: mergedErrors,
243252
loadingStatus: mergedLoadingStatus,
244-
isLoading: mergedLoadingStatus.outlineIndexIsLoading,
253+
isLoading: mergedLoadingStatus.outlineIndexIsLoading || isEditPermissionLoading,
245254
isLoadingDenied: mergedLoadingStatus.outlineIndexIsDenied,
246255
isCustomRelativeDatesActive,
247256
enableProctoredExams,
@@ -273,10 +282,12 @@ export const CourseOutlineProvider = ({ children }: { children?: React.ReactNode
273282
courseId,
274283
visibleSections,
275284
courseActions,
285+
canEditCourseContent,
276286
statusBarData,
277287
savingStatus,
278288
mergedErrors,
279289
mergedLoadingStatus,
290+
isEditPermissionLoading,
280291
isCustomRelativeDatesActive,
281292
enableProctoredExams,
282293
enableTimedExams,

src/course-outline/OutlineNode.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ const OutlineNode = ({
107107
const { selectedContainerState, openContainerSidebar, setSelectedContainerState } = useOutlineSidebarContext();
108108
const { courseId, openUnlinkModal, getUnitUrl } = useCourseAuthoringContext();
109109
const duplicateMutation = useDuplicateItem(courseId);
110-
const { openPublishModal } = useCourseOutlineContext();
110+
const { openPublishModal, canEditCourseContent } = useCourseOutlineContext();
111111
const queryClient = useQueryClient();
112112
const { sharedClipboardData, showPasteUnit, copyToClipboard } = useClipboard();
113113
const intl = useIntl();
@@ -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
(
@@ -328,6 +328,7 @@ const OutlineNode = ({
328328
titleComponent={titleComponent}
329329
namePrefix={levelConfig.name}
330330
actions={actions}
331+
canEditCourseContent={canEditCourseContent}
331332
extraActionsComponent={extraActionsComponent}
332333
{...(depth === 1
333334
? { isSequential: true, proctoringExamConfigurationLink: blk.proctoringExamConfigurationLink }
@@ -352,7 +353,7 @@ const OutlineNode = ({
352353
data-testid={levelConfig.contentTestId}
353354
onClick={(e) => onClickCard(e, false)}
354355
>
355-
{depth === 0 && onOpenHighlightsModal && (
356+
{canEditCourseContent && depth === 0 && onOpenHighlightsModal && (
356357
<div className="outline-section__status mb-1">
357358
<Button
358359
className="p-0 bg-transparent"
@@ -381,14 +382,14 @@ const OutlineNode = ({
381382
})}
382383
>
383384
{children}
384-
{actions.childAddable && (
385+
{canEditCourseContent && actions.childAddable && (
385386
<OutlineAddChildButtons
386387
childType={levelConfig.containerType!}
387388
parentLocator={blk.id}
388389
grandParentLocator={depth === 1 ? parentSection?.id : undefined}
389390
/>
390391
)}
391-
{showPaste && (
392+
{canEditCourseContent && showPaste && (
392393
<PasteComponent
393394
className="mt-4 border-gray-500 rounded-0"
394395
text={intl.formatMessage(outlineNodeMessages.pasteButton)}

src/course-outline/OutlineTree.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { type Depth, LEVEL_NAMES } from './outline-level';
2121
export interface OutlineTreeProps {
2222
sections: XBlock[];
2323
courseActions: XBlockActions;
24+
canEditCourseContent: boolean;
2425
courseUsageKey: string;
2526
hasOutlineIndexError: boolean;
2627
isCustomRelativeDatesActive: boolean;
@@ -57,6 +58,7 @@ interface RenderContext {
5758
const OutlineTree = ({
5859
sections,
5960
courseActions,
61+
canEditCourseContent,
6062
courseUsageKey,
6163
hasOutlineIndexError,
6264
isCustomRelativeDatesActive,
@@ -180,7 +182,7 @@ const OutlineTree = ({
180182
)}
181183
</SortableContext>
182184
</DraggableList>
183-
{courseActions.childAddable && (
185+
{canEditCourseContent && courseActions.childAddable && (
184186
<OutlineAddChildButtons
185187
childType={ContainerType.Section}
186188
parentLocator={courseUsageKey}
@@ -190,7 +192,7 @@ const OutlineTree = ({
190192
) :
191193
(
192194
<EmptyPlaceholder>
193-
{courseActions.childAddable ?
195+
{canEditCourseContent && courseActions.childAddable ?
194196
(
195197
<OutlineAddChildButtons
196198
childType={ContainerType.Section}

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ const cardHeaderProps = {
6464
duplicable: true,
6565
unlinkable: true,
6666
},
67+
canEditCourseContent: true,
6768
};
6869

6970
const renderComponent = (props?: object, entry = '/') => {
@@ -579,4 +580,28 @@ describe('<CardHeader />', () => {
579580
await act(async () => fireEvent.click(unlinkMenuItem));
580581
expect(onClickUnlinkMock).toHaveBeenCalled();
581582
});
583+
584+
describe('canEditCourseContent permission', () => {
585+
it('renders the rename button and actions menu when canEditCourseContent is true', async () => {
586+
renderComponent({ canEditCourseContent: true });
587+
588+
expect(await screen.findByTestId('subsection-edit-button')).toBeInTheDocument();
589+
expect(await screen.findByTestId('subsection-card-header__menu')).toBeInTheDocument();
590+
});
591+
592+
it('does not render the rename button when canEditCourseContent is false', async () => {
593+
renderComponent({ canEditCourseContent: false });
594+
595+
expect(await screen.findByText(cardHeaderProps.title)).toBeInTheDocument();
596+
expect(screen.queryByTestId('subsection-edit-button')).not.toBeInTheDocument();
597+
});
598+
599+
it('does not render the actions menu when canEditCourseContent is false', async () => {
600+
renderComponent({ canEditCourseContent: false });
601+
602+
expect(await screen.findByText(cardHeaderProps.title)).toBeInTheDocument();
603+
expect(screen.queryByTestId('subsection-card-header__menu')).not.toBeInTheDocument();
604+
expect(screen.queryByTestId('subsection-card-header__menu-button')).not.toBeInTheDocument();
605+
});
606+
});
582607
});

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

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ interface CardHeaderProps {
5757
namePrefix: string;
5858
proctoringExamConfigurationLink?: string;
5959
actions: XBlockActions;
60+
canEditCourseContent: boolean;
6061
enableCopyPasteUnits?: boolean;
6162
isVertical?: boolean;
6263
isSequential?: boolean;
@@ -96,6 +97,7 @@ const CardHeader = ({
9697
titleComponent,
9798
namePrefix,
9899
actions,
100+
canEditCourseContent,
99101
enableCopyPasteUnits,
100102
isVertical,
101103
isSequential,
@@ -285,15 +287,17 @@ const CardHeader = ({
285287
(
286288
<Stack direction="horizontal" gap={2}>
287289
{titleComponent}
288-
<IconButtonWithTooltip
289-
className="item-card-button-icon"
290-
data-testid={`${namePrefix}-edit-button`}
291-
alt={intl.formatMessage(messages.altButtonRename)}
292-
tooltipContent={<div>{intl.formatMessage(messages.altButtonRename)}</div>}
293-
iconAs={EditIcon}
294-
onClick={onEditClick}
295-
disabled={editMutation.isPending}
296-
/>
290+
{ canEditCourseContent &&
291+
<IconButtonWithTooltip
292+
className="item-card-button-icon"
293+
data-testid={`${namePrefix}-edit-button`}
294+
alt={intl.formatMessage(messages.altButtonRename)}
295+
tooltipContent={<div>{intl.formatMessage(messages.altButtonRename)}</div>}
296+
iconAs={EditIcon}
297+
onClick={onEditClick}
298+
disabled={editMutation.isPending}
299+
/>
300+
}
297301
</Stack>
298302
)}
299303
<div className="ml-auto d-flex">
@@ -313,7 +317,7 @@ const CardHeader = ({
313317
onClick={onClickSync}
314318
/>
315319
)}
316-
<Dropdown data-testid={`${namePrefix}-card-header__menu`}>
320+
{canEditCourseContent && <Dropdown data-testid={`${namePrefix}-card-header__menu`}>
317321
<Dropdown.Toggle
318322
className="item-card-header__menu"
319323
id={`${namePrefix}-card-header__menu`}
@@ -412,6 +416,7 @@ const CardHeader = ({
412416
)}
413417
</Dropdown.Menu>
414418
</Dropdown>
419+
}
415420
</div>
416421
</div>
417422
</>

0 commit comments

Comments
 (0)