Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion plugins/course-apps/proctoring/Settings.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
18 changes: 18 additions & 0 deletions src/CourseAuthoringContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -29,6 +31,9 @@ export type CourseAuthoringContextData = {
currentUnlinkModalData?: ModalState;
openUnlinkModal: (value: ModalState) => void;
closeUnlinkModal: () => void;
isLoading: boolean;
canEditCourseContent: boolean;
canPublishCourseContent: boolean;
};

/**
Expand Down Expand Up @@ -58,7 +63,14 @@ export const CourseAuthoringProvider = ({
closeUnlinkModal,
] = useToggleWithValue<ModalState>();

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.
Expand All @@ -78,6 +90,9 @@ export const CourseAuthoringProvider = ({
openUnlinkModal,
closeUnlinkModal,
currentUnlinkModalData,
isLoading,
canEditCourseContent,
canPublishCourseContent,
}), [
courseId,
courseDetails,
Expand All @@ -89,6 +104,9 @@ export const CourseAuthoringProvider = ({
openUnlinkModal,
closeUnlinkModal,
currentUnlinkModalData,
canEditCourseContent,
canPublishCourseContent,
isLoading,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
isLoading,
isLoadingPermissions,

perhaps? Just to clarify that the courseId is always "loaded", the courseDetails loading status is in courseDetailStatus, so this only affects the permissions.

]);

return (
Expand Down
2 changes: 2 additions & 0 deletions src/authz/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',

Expand Down
4 changes: 4 additions & 0 deletions src/authz/permissionHelpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,10 @@ describe('permissionHelpers', () => {
action: COURSE_PERMISSIONS.EDIT_COURSE_CONTENT,
scope: courseId,
},
canPublishCourseContent: {
action: COURSE_PERMISSIONS.PUBLISH_COURSE_CONTENT,
scope: courseId,
},
});
});
});
Expand Down
4 changes: 4 additions & 0 deletions src/authz/permissionHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand Down
6 changes: 6 additions & 0 deletions src/course-outline/CourseOutline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -552,6 +553,11 @@ const renderComponent = () =>
describe('<CourseOutline />', () => {
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({
Expand Down
15 changes: 12 additions & 3 deletions src/course-outline/CourseOutline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ const CourseOutline = () => {
const location = useLocation();
const {
courseId,
canEditCourseContent,
isLoading: isLoadingAuthoringContext,
} = useCourseAuthoringContext();
const {
courseUsageKey,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -169,7 +178,7 @@ const CourseOutline = () => {
}
}, [location, courseId, courseName]);

if (isLoading) {
if (isLoading || isLoadingAuthoringContext) {
// eslint-disable-next-line react/jsx-no-useless-fragment
return (
<Row className="m-0 mt-4 justify-content-center">
Expand Down Expand Up @@ -249,7 +258,7 @@ const CourseOutline = () => {
headerNavigationsActions={headerNavigationsActions}
isDisabledReindexButton={isDisabledReindexButton}
hasSections={Boolean(sections.length)}
courseActions={courseActions}
courseActions={headerCourseActions}
errors={errors}
sections={sections}
/>
Expand Down
11 changes: 5 additions & 6 deletions src/course-outline/OutlineNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 ?
(
Expand Down Expand Up @@ -335,7 +335,6 @@ const OutlineNode = ({
{...(depth === 2 ?
{
isVertical: true,
enableCopyPasteUnits: blk.enableCopyPasteUnits ?? false,
onClickCopy: () => copyToClipboard(blk.id),
discussionEnabled: blk.discussionEnabled,
discussionsSettings,
Expand All @@ -352,7 +351,7 @@ const OutlineNode = ({
data-testid={levelConfig.contentTestId}
onClick={(e) => onClickCard(e, false)}
>
{depth === 0 && onOpenHighlightsModal && (
{canEditCourseContent && depth === 0 && onOpenHighlightsModal && (
<div className="outline-section__status mb-1">
<Button
className="p-0 bg-transparent"
Expand Down Expand Up @@ -381,14 +380,14 @@ const OutlineNode = ({
})}
>
{children}
{actions.childAddable && (
{canEditCourseContent && actions.childAddable && (
<OutlineAddChildButtons
childType={levelConfig.containerType!}
parentLocator={blk.id}
grandParentLocator={depth === 1 ? parentSection?.id : undefined}
/>
)}
{showPaste && (
{canEditCourseContent && showPaste && (
<PasteComponent
className="mt-4 border-gray-500 rounded-0"
text={intl.formatMessage(outlineNodeMessages.pasteButton)}
Expand Down
7 changes: 5 additions & 2 deletions src/course-outline/OutlineTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
} from './drag-helper/utils';
import { applyReorderMove } from './drag-helper/utils';
import { type Depth, LEVEL_NAMES } from './outline-level';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';

export interface OutlineTreeProps {
sections: XBlock[];
Expand Down Expand Up @@ -81,6 +82,8 @@ const OutlineTree = ({
await commitSectionReorder(sectionListIds);
}, [sections, previewSections, commitSectionReorder]);

const { canEditCourseContent } = useCourseAuthoringContext();

const handleSubsectionOrderChange = useCallback(
async (section: XBlock, moveDetails: SubsectionMoveDetails | null) => {
applyReorderMove(moveDetails, section, previewSections, commitSubsectionReorder);
Expand Down Expand Up @@ -180,7 +183,7 @@ const OutlineTree = ({
)}
</SortableContext>
</DraggableList>
{courseActions.childAddable && (
{canEditCourseContent && courseActions.childAddable && (
<OutlineAddChildButtons
childType={ContainerType.Section}
parentLocator={courseUsageKey}
Expand All @@ -190,7 +193,7 @@ const OutlineTree = ({
) :
(
<EmptyPlaceholder>
{courseActions.childAddable ?
{canEditCourseContent && courseActions.childAddable ?
(
<OutlineAddChildButtons
childType={ContainerType.Section}
Expand Down
39 changes: 38 additions & 1 deletion src/course-outline/card-header/CardHeader.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
waitFor,
} from '@src/testUtils';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import { mockWaffleFlags } from '@src/data/apiHooks.mock';
import { courseId } from '@src/schedule-and-details/__mocks__/courseDetails';
import { userEvent } from '@testing-library/user-event';
import { renderCard, setupCardTestMocks } from '../__mocks__/testSetup';
Expand Down Expand Up @@ -66,6 +67,13 @@ const cardHeaderProps = {
},
};

let validateUserPermissionsMock;

const mockPermissions = (canEditCourseContent = true) => {
mockWaffleFlags({ enableAuthzCourseAuthoring: !canEditCourseContent });
validateUserPermissionsMock.mockResolvedValue({ canEditCourseContent });
};

const renderComponent = (props?: object, entry = '/') => {
const titleComponent = (
<TitleButton
Expand Down Expand Up @@ -99,7 +107,9 @@ const renderComponent = (props?: object, entry = '/') => {

describe('<CardHeader />', () => {
beforeEach(() => {
setupCardTestMocks();
const mocks = setupCardTestMocks();
validateUserPermissionsMock = mocks.validateUserPermissionsMock;
mockPermissions(true);
useUpdateCourseBlockNameMock.isPending = false;
useUpdateCourseBlockNameMock.mutate.mockClear();
useUpdateCourseBlockNameMock.mutateAsync.mockClear();
Expand Down Expand Up @@ -579,4 +589,31 @@ describe('<CardHeader />', () => {
await act(async () => fireEvent.click(unlinkMenuItem));
expect(onClickUnlinkMock).toHaveBeenCalled();
});

describe('canEditCourseContent permission', () => {
it('renders the rename button and actions menu when canEditCourseContent is true', async () => {
mockPermissions(true);
renderComponent();

expect(await screen.findByTestId('subsection-edit-button')).toBeInTheDocument();
expect(await screen.findByTestId('subsection-card-header__menu')).toBeInTheDocument();
});

it('does not render the rename button when canEditCourseContent is false', async () => {
mockPermissions(false);
renderComponent();

expect(await screen.findByText(cardHeaderProps.title)).toBeInTheDocument();
expect(screen.queryByTestId('subsection-edit-button')).not.toBeInTheDocument();
});

it('does not render the actions menu when canEditCourseContent is false', async () => {
mockPermissions(false);
renderComponent();

expect(await screen.findByText(cardHeaderProps.title)).toBeInTheDocument();
expect(screen.queryByTestId('subsection-card-header__menu')).not.toBeInTheDocument();
expect(screen.queryByTestId('subsection-card-header__menu-button')).not.toBeInTheDocument();
});
});
});
Loading