Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
1b537c3
chore: update browserslist DB (#3221)
edx-requirements-bot Aug 31, 2026
a5be74d
feat: permission checks on course outline page for content editing pa…
jacobo-dominguez-wgu Aug 31, 2026
2c14cef
fix: leave new dropdown answers empty instead of "undefined" (#3216)
ihor-romaniuk Aug 31, 2026
f5041a7
feat(authz): add permission validation for advanced settings and cert…
dcoa Sep 2, 2026
91d76aa
chore(deps): bump fast-uri from 3.1.5 to 3.1.7 (#3228)
dependabot[bot] Sep 3, 2026
c12c9a5
feat(authz): add group configuration permission validation (#3207)
dcoa Sep 5, 2026
44c0d57
feat: add in-platform transcript editor to the videos page (#3215)
abhalsod-sonata Sep 8, 2026
87ce85d
chore(deps): bump dompurify from 3.4.12 to 3.4.13 (#3190)
dependabot[bot] Sep 8, 2026
db5e480
refactor: migrate course_details and grading callers to standardized …
taimoor-ahmed-1 Sep 11, 2026
8d5f6b5
feat: add Competency Tree on the Competency Management page (#3218)
AShatsila Sep 11, 2026
faea5b7
chore(deps): bump colord from 2.9.3 to 2.10.0 (#3233)
dependabot[bot] Sep 11, 2026
f71c401
chore(deps): bump svgo from 3.3.4 to 3.3.5 (#3236)
dependabot[bot] Sep 11, 2026
7e62e77
feat: adding permissions checks related to course section and units (…
jacobo-dominguez-wgu Sep 11, 2026
2c75033
chore(deps): bump sharp and @openedx/frontend-build (#3237)
dependabot[bot] Sep 11, 2026
87cba01
fix(deps): update dependency fast-xml-parser to v5.11.1 [security] (#…
renovate[bot] Sep 11, 2026
f0fd362
fix: pass course context to library review endpoints
efortish Sep 16, 2026
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
435 changes: 275 additions & 160 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
"@openedx-plugins/course-app-teams": "file:plugins/course-apps/teams",
"@openedx-plugins/course-app-wiki": "file:plugins/course-apps/wiki",
"@openedx-plugins/course-app-xpert_unit_summary": "file:plugins/course-apps/xpert_unit_summary",
"@openedx/frontend-build": "^14.6.6",
"@openedx/frontend-build": "^14.6.17",
"@openedx/frontend-plugin-framework": "^1.8.0",
"@openedx/paragon": "^23.5.0",
"@redux-devtools/extension": "^3.3.0",
Expand All @@ -72,7 +72,7 @@
"classnames": "2.5.1",
"codemirror": "^6.0.0",
"email-validator": "2.0.4",
"fast-xml-parser": "5.3.7",
"fast-xml-parser": "5.11.1",
"file-saver": "^2.0.5",
"formik": "2.4.9",
"frontend-components-tinymce-advanced-plugins": "^1.0.3",
Expand Down
11 changes: 7 additions & 4 deletions plugins/course-apps/proctoring/Settings.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
17 changes: 17 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;
isLoadingPermissions: boolean;
canEditCourseContent: boolean;
canPublishCourseContent: boolean;
};

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

const {
canEditCourseContent,
canPublishCourseContent,
isLoading: isLoadingUserPermissions,
} = useCourseUserPermissions(courseId, getCourseOutlinePermissions(courseId));

const getUnitUrl = (locator: string) => `/course/${courseId}/container/${locator}`;

/**
Expand All @@ -78,6 +89,9 @@ export const CourseAuthoringProvider = ({
openUnlinkModal,
closeUnlinkModal,
currentUnlinkModalData,
isLoadingPermissions: isLoadingUserPermissions,
canEditCourseContent,
canPublishCourseContent,
}), [
courseId,
courseDetails,
Expand All @@ -89,6 +103,9 @@ export const CourseAuthoringProvider = ({
openUnlinkModal,
closeUnlinkModal,
currentUnlinkModalData,
canEditCourseContent,
canPublishCourseContent,
isLoadingUserPermissions,
]);

return (
Expand Down
23 changes: 21 additions & 2 deletions src/advanced-settings/AdvancedSettings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ describe('<AdvancedSettings />', () => {
jest.mocked(useCourseUserPermissions).mockReturnValue({
isLoading: false,
isAuthzEnabled: false,
canViewAdvancedSettings: true,
canManageAdvancedSettings: true,
} as ReturnType<typeof useCourseUserPermissions>);
});
Expand Down Expand Up @@ -175,6 +176,7 @@ describe('<AdvancedSettings />', () => {
jest.mocked(useCourseUserPermissions).mockReturnValue({
isLoading: false,
isAuthzEnabled: true,
canViewAdvancedSettings: true,
canManageAdvancedSettings: true,
} as ReturnType<typeof useCourseUserPermissions>);
render();
Expand All @@ -192,7 +194,7 @@ describe('<AdvancedSettings />', () => {
jest.mocked(useCourseUserPermissions).mockReturnValue({
isLoading: false,
isAuthzEnabled: true,
canManageAdvancedSettings: false,
canViewAdvancedSettings: false,
} as ReturnType<typeof useCourseUserPermissions>);
render();
expect(await screen.findByTestId('permissionDeniedAlert')).toBeInTheDocument();
Expand All @@ -203,7 +205,7 @@ describe('<AdvancedSettings />', () => {
jest.mocked(useCourseUserPermissions).mockReturnValue({
isLoading: false,
isAuthzEnabled: true,
canManageAdvancedSettings: false,
canViewAdvancedSettings: false,
} as ReturnType<typeof useCourseUserPermissions>);
axiosMock
.onGet(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`)
Expand All @@ -212,4 +214,21 @@ describe('<AdvancedSettings />', () => {
expect(await screen.findByTestId('permissionDeniedAlert')).toBeInTheDocument();
expect(screen.queryByText(/Under Construction/i)).not.toBeInTheDocument();
});

it('should show view-only alert and disable editing when user has view but not manage permission', async () => {
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
jest.mocked(useCourseUserPermissions).mockReturnValue({
isLoading: false,
isAuthzEnabled: true,
canViewAdvancedSettings: true,
canManageAdvancedSettings: false,
} as ReturnType<typeof useCourseUserPermissions>);
render();
expect(await screen.findByTestId('viewOnlyPermissionsAlert')).toBeInTheDocument();
expect(await screen.findByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
const textarea = screen.getByLabelText(/Advanced Module List/i);
expect(textarea).toBeDisabled();
expect(screen.queryByText(messages.buttonSaveText.defaultMessage)).not.toBeInTheDocument();
expect(screen.queryByText(messages.buttonCancelText.defaultMessage)).not.toBeInTheDocument();
});
});
6 changes: 5 additions & 1 deletion src/advanced-settings/AdvancedSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { useCourseUserPermissions } from '@src/authz/hooks';
import { getAdvancedSettingsPermissions } from '@src/authz/permissionHelpers';
import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert';
import ViewOnlyPermissionsAlert from '@src/generic/ViewOnlyPermissionsAlert';
import AlertProctoringError from '@src/generic/AlertProctoringError';
import { LoadingSpinner } from '@src/generic/Loading';
import InternetConnectionAlert from '@src/generic/internet-connection-alert';
Expand Down Expand Up @@ -45,6 +46,7 @@ const AdvancedSettings = () => {

const {
isLoading: isLoadingUserPermissions,
canViewAdvancedSettings,
canManageAdvancedSettings,
} = useCourseUserPermissions(courseId, getAdvancedSettingsPermissions(courseId));

Expand Down Expand Up @@ -102,7 +104,7 @@ const AdvancedSettings = () => {
);
}

if (!canManageAdvancedSettings) {
if (!canViewAdvancedSettings) {
return <PermissionDeniedAlert />;
}

Expand Down Expand Up @@ -201,6 +203,7 @@ const AdvancedSettings = () => {
subtitle={intl.formatMessage(messages.headingSubtitle)}
title={intl.formatMessage(messages.headingTitle)}
contentTitle={intl.formatMessage(messages.policy)}
banner={!canManageAdvancedSettings ? <ViewOnlyPermissionsAlert /> : null}
/>
<article>
<div>
Expand Down Expand Up @@ -246,6 +249,7 @@ const AdvancedSettings = () => {
handleBlur={handleSettingBlur}
isEditableState={isEditableState}
setIsEditableState={setIsEditableState}
disabled={!canManageAdvancedSettings}
/>
);
})}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { fireEvent, render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import {
fireEvent,
initializeMocks,
render,
screen,
userEvent,
waitFor,
} from '@src/testUtils';

import SettingCard from './SettingCard';
import SettingCard, { type SettingCardProps } from './SettingCard';
import messages from './messages';

const setEdited = jest.fn();
Expand All @@ -25,10 +30,9 @@ jest.mock('react-textarea-autosize', () =>
/>
)));

const RootWrapper = () => (
<IntlProvider locale="en">
const renderComponent = (props: Partial<SettingCardProps> = {}) =>
render(
<SettingCard
isOn
name="settingName"
setEdited={setEdited}
setIsEditableState={setIsEditableState}
Expand All @@ -37,48 +41,42 @@ const RootWrapper = () => (
handleBlur={handleBlur}
isEditableState
saveSettingsPrompt={false}
/>
</IntlProvider>
);
{...props}
/>,
);

describe('<SettingCard />', () => {
afterEach(() => jest.clearAllMocks());
beforeEach(() => {
initializeMocks();
});

it('renders the setting card with the provided data', () => {
const { getByText, getByLabelText } = render(<RootWrapper />);
const cardTitle = getByText(/Setting Name/i);
const input = getByLabelText(/Setting Name/i);
renderComponent();
const cardTitle = screen.getByText(/Setting Name/i);
const input = screen.getByLabelText(/Setting Name/i);
expect(cardTitle).toBeInTheDocument();
expect(input).toBeInTheDocument();
expect(input.value).toBe(JSON.stringify(settingData.value, null, 4));
expect(input).toHaveValue(JSON.stringify(settingData.value, null, 4));
});

it('displays the deprecated status when the setting is deprecated', () => {
const deprecatedSettingData = { ...settingData, deprecated: true };
const { getByText } = render(
<IntlProvider locale="en">
<SettingCard
isOn
name="settingName"
setEdited={setEdited}
setIsEditableState={setIsEditableState}
showSaveSettingsPrompt={showSaveSettingsPrompt}
settingData={deprecatedSettingData}
handleBlur={handleBlur}
isEditable={false}
saveSettingsPrompt
/>
</IntlProvider>,
);
const deprecatedStatus = getByText(messages.deprecated.defaultMessage);
expect(deprecatedStatus).toBeInTheDocument();
renderComponent({
settingData: { ...settingData, deprecated: true },
isEditableState: false,
saveSettingsPrompt: true,
});
expect(screen.getByText(messages.deprecated.defaultMessage)).toBeInTheDocument();
});

it('does not display the deprecated status when the setting is not deprecated', () => {
const { queryByText } = render(<RootWrapper />);
expect(queryByText(messages.deprecated.defaultMessage)).toBeNull();
renderComponent();
expect(screen.queryByText(messages.deprecated.defaultMessage)).toBeNull();
});

it('calls setEdited on blur', async () => {
const user = userEvent.setup();
const { getByLabelText } = render(<RootWrapper />);
const inputBox = getByLabelText(/Setting Name/i);
renderComponent();
const inputBox = screen.getByLabelText(/Setting Name/i);
fireEvent.focus(inputBox);
await user.clear(inputBox);
await user.type(inputBox, '3, 2, 1');
Expand All @@ -91,4 +89,9 @@ describe('<SettingCard />', () => {
expect(handleBlur).toHaveBeenCalled();
});
});

it('disables the setting input when `disabled` is true', () => {
renderComponent({ disabled: true });
expect(screen.getByLabelText(/Setting Name/i)).toBeDisabled();
});
});
46 changes: 21 additions & 25 deletions src/advanced-settings/setting-card/SettingCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,29 @@ import {
useToggle,
} from '@openedx/paragon';
import { InfoOutline, Warning } from '@openedx/paragon/icons';
import PropTypes from 'prop-types';
import { capitalize } from 'lodash';
import { useIntl } from '@edx/frontend-platform/i18n';
import TextareaAutosize from 'react-textarea-autosize';

import messages from './messages';

export interface SettingCardProps {
name: string;
settingData: {
deprecated?: boolean;
help?: string;
displayName?: string;
value?: unknown;
};
handleBlur: () => void;
setEdited: React.Dispatch<React.SetStateAction<Record<string, unknown>>>;
showSaveSettingsPrompt: (show: boolean) => void;
saveSettingsPrompt: boolean;
isEditableState: boolean;
setIsEditableState: (isEditable: boolean) => void;
disabled?: boolean;
}

const SettingCard = ({
name,
settingData,
Expand All @@ -25,7 +41,8 @@ const SettingCard = ({
saveSettingsPrompt,
isEditableState,
setIsEditableState,
}) => {
disabled = false,
}: SettingCardProps) => {
const intl = useIntl();
const { deprecated, help, displayName } = settingData;
const initialValue = JSON.stringify(settingData.value, null, 4);
Expand Down Expand Up @@ -83,7 +100,7 @@ const SettingCard = ({
<div
className="p-2 x-small rounded modal-popup-content"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: help }}
dangerouslySetInnerHTML={{ __html: help ?? '' }}
/>
</ModalPopup>
<ActionRow.Spacer />
Expand All @@ -99,6 +116,7 @@ const SettingCard = ({
onChange={handleSettingChange}
aria-label={displayName}
onBlur={handleCardBlur}
disabled={disabled}
/>
</Form.Group>
</Card.Section>
Expand All @@ -113,26 +131,4 @@ const SettingCard = ({
);
};

SettingCard.propTypes = {
settingData: PropTypes.shape({
deprecated: PropTypes.bool,
help: PropTypes.string,
displayName: PropTypes.string,
value: PropTypes.oneOfType([
PropTypes.string,
PropTypes.bool,
PropTypes.number,
PropTypes.object,
PropTypes.array,
]),
}).isRequired,
setEdited: PropTypes.func.isRequired,
showSaveSettingsPrompt: PropTypes.func.isRequired,
name: PropTypes.string.isRequired,
handleBlur: PropTypes.func.isRequired,
saveSettingsPrompt: PropTypes.bool.isRequired,
isEditableState: PropTypes.bool.isRequired,
setIsEditableState: PropTypes.func.isRequired,
};

export default SettingCard;
Loading
Loading