diff --git a/plugins/course-apps/progress/Settings.jsx b/plugins/course-apps/progress/Settings.jsx
index 1f01c56c79..eed52cdfba 100644
--- a/plugins/course-apps/progress/Settings.jsx
+++ b/plugins/course-apps/progress/Settings.jsx
@@ -1,17 +1,19 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
-import React from 'react';
+import React, { useContext } from 'react';
import * as Yup from 'yup';
import { getConfig } from '@edx/frontend-platform';
import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup';
import { useAppSetting } from 'CourseAuthoring/utils';
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
+import { PagesAndResourcesContext } from 'CourseAuthoring/pages-and-resources';
import messages from './messages';
const ProgressSettings = ({ onClose }) => {
const intl = useIntl();
const [disableProgressGraph, saveSetting] = useAppSetting('disableProgressGraph');
const showProgressGraphSetting = getConfig().ENABLE_PROGRESS_GRAPH_SETTINGS.toString().toLowerCase() === 'true';
+ const { isEditable = false } = useContext(PagesAndResourcesContext);
const handleSettingsSave = async (values) => {
if (showProgressGraphSetting) { await saveSetting(!values.enableProgressGraph); }
@@ -39,6 +41,7 @@ const ProgressSettings = ({ onClose }) => {
onChange={handleChange}
onBlur={handleBlur}
checked={values.enableProgressGraph}
+ disabled={!isEditable}
/>
)
)}
diff --git a/src/CourseAuthoringPage.test.tsx b/src/CourseAuthoringPage.test.tsx
index d7339a283f..8a221f1a8c 100644
--- a/src/CourseAuthoringPage.test.tsx
+++ b/src/CourseAuthoringPage.test.tsx
@@ -108,14 +108,20 @@ describe('Course authoring page', () => {
axiosMock.onGet(
`${courseAppsApiUrl}/${courseId}`,
- ).reply(403);
+ ).reply(403, { response: { status: 403 } });
await executeThunk(fetchCourseApps(courseId), store.dispatch);
};
test('renders PermissionDeniedAlert when courseAppsApiStatus is DENIED', async () => {
mockPathname = '/editor/';
await mockStoreDenied();
- const wrapper = renderComponent();
+ // Test PagesAndResources (which has the PermissionDeniedAlert logic),
+ // not CourseAuthoringPage which is just the layout wrapper
+ const wrapper = renderComponent(
+
+
+ ,
+ );
expect(await wrapper.findByTestId('permissionDeniedAlert')).toBeInTheDocument();
});
});
diff --git a/src/CourseAuthoringPage.tsx b/src/CourseAuthoringPage.tsx
index 7e6d4055bd..4d6da9c65b 100644
--- a/src/CourseAuthoringPage.tsx
+++ b/src/CourseAuthoringPage.tsx
@@ -1,5 +1,5 @@
import React, { useEffect } from 'react';
-import { useDispatch, useSelector } from 'react-redux';
+import { useDispatch } from 'react-redux';
import {
useLocation,
@@ -7,9 +7,7 @@ import {
import { StudioFooterSlot } from '@edx/frontend-component-footer';
import Header from './header';
import NotFoundAlert from './generic/NotFoundAlert';
-import PermissionDeniedAlert from './generic/PermissionDeniedAlert';
import { fetchOnlyStudioHomeData } from './studio-home/data/thunks';
-import { getCourseAppsApiStatus } from './pages-and-resources/data/selectors';
import { RequestStatus } from './data/constants';
import Loading from './generic/Loading';
import { useCourseAuthoringContext } from './CourseAuthoringContext';
@@ -30,16 +28,12 @@ const CourseAuthoringPage = ({ children }: Props) => {
const courseOrg = courseDetails?.org;
const courseTitle = courseDetails?.name;
const inProgress = courseDetailStatus === RequestStatus.IN_PROGRESS || courseDetailStatus === RequestStatus.PENDING;
- const courseAppsApiStatus = useSelector(getCourseAppsApiStatus);
const { pathname } = useLocation();
const isEditor = pathname.includes('/editor');
if (courseDetailStatus === RequestStatus.NOT_FOUND && !isEditor) {
return ;
}
- if (courseAppsApiStatus === RequestStatus.DENIED) {
- return ;
- }
return (
{
diff --git a/src/advanced-settings/AdvancedSettings.test.tsx b/src/advanced-settings/AdvancedSettings.test.tsx
index 8b9d73aa70..5981fa43ea 100644
--- a/src/advanced-settings/AdvancedSettings.test.tsx
+++ b/src/advanced-settings/AdvancedSettings.test.tsx
@@ -191,4 +191,25 @@ describe('
', () => {
render();
expect(await screen.findByTestId('permissionDeniedAlert')).toBeInTheDocument();
});
+
+ it('should render settings in read-only mode when user has VIEW but not MANAGE permissions (auditor)', async () => {
+ mockWaffleFlags({ enableAuthzCourseAuthoring: true });
+ jest.mocked(useUserPermissions).mockReturnValue({
+ isLoading: false,
+ data: { canViewAdvancedSettings: true, canManageAdvancedSettings: false },
+ } as unknown as ReturnType
);
+ render();
+ const textarea = await screen.findByLabelText(/Advanced Module List/i);
+ expect(textarea).toBeDisabled();
+ });
+
+ it('should show permission denied when user has NO permissions (null data)', async () => {
+ mockWaffleFlags({ enableAuthzCourseAuthoring: true });
+ jest.mocked(useUserPermissions).mockReturnValue({
+ isLoading: false,
+ data: null,
+ } as unknown as ReturnType);
+ render();
+ expect(await screen.findByTestId('permissionDeniedAlert')).toBeInTheDocument();
+ });
});
diff --git a/src/advanced-settings/AdvancedSettings.tsx b/src/advanced-settings/AdvancedSettings.tsx
index ef5d37d921..bb64afa6f5 100644
--- a/src/advanced-settings/AdvancedSettings.tsx
+++ b/src/advanced-settings/AdvancedSettings.tsx
@@ -10,9 +10,6 @@ import {
import { CheckCircle, Info, Warning } from '@openedx/paragon/icons';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
-import { useWaffleFlags } from '@src/data/apiHooks';
-import { useUserPermissions } from '@src/authz/data/apiHooks';
-import { COURSE_PERMISSIONS } from '@src/authz/constants';
import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert';
import AlertProctoringError from '@src/generic/AlertProctoringError';
import { LoadingSpinner } from '@src/generic/Loading';
@@ -30,6 +27,8 @@ import validateAdvancedSettingsData from './utils';
import messages from './messages';
import ModalError from './modal-error/ModalError';
import { useCourseAdvancedSettings, useProctoringExamErrors, useUpdateCourseAdvancedSettings } from './data/apiHooks';
+import { useCourseUserPermissions } from '@src/authz/hooks';
+import { getAdvancedSettingsPermissions } from '@src/authz/permissionHelpers';
const AdvancedSettings = () => {
const intl = useIntl();
@@ -44,14 +43,12 @@ const AdvancedSettings = () => {
const { courseId, courseDetails } = useCourseAuthoringContext();
- const waffleFlags = useWaffleFlags(courseId);
- const isAuthzEnabled = waffleFlags.enableAuthzCourseAuthoring;
- const { isLoading: isLoadingUserPermissions, data: userPermissions } = useUserPermissions({
- canManageAdvancedSettings: {
- action: COURSE_PERMISSIONS.MANAGE_ADVANCED_SETTINGS,
- scope: courseId,
- },
- }, isAuthzEnabled);
+ const {
+ isLoading: isLoadingUserPermissions,
+ isAuthzEnabled,
+ canViewAdvancedSettings,
+ canManageAdvancedSettings,
+ } = useCourseUserPermissions(courseId, getAdvancedSettingsPermissions(courseId));
const {
data: advancedSettingsData = {},
@@ -72,6 +69,11 @@ const AdvancedSettings = () => {
} = updateMutation;
const isLoading = isPendingSettingsStatus || (isAuthzEnabled && isLoadingUserPermissions);
+
+ const isEditable = !isAuthzEnabled
+ || isLoadingUserPermissions
+ || canManageAdvancedSettings;
+
const updateSettingsButtonState = {
labels: {
default: intl.formatMessage(messages.buttonSaveText),
@@ -148,10 +150,11 @@ const AdvancedSettings = () => {
showSaveSettingsPrompt(true);
};
- // Show permission denied alert when authz is enabled and user doesn't have permission
+ // Show permission denied alert when authz is enabled and user doesn't have VIEW or MANAGE
const authzIsEnabledAndNoPermission = isAuthzEnabled
&& !isLoadingUserPermissions
- && !userPermissions?.canManageAdvancedSettings;
+ && !canViewAdvancedSettings
+ && !canManageAdvancedSettings;
if (authzIsEnabledAndNoPermission) {
return ;
@@ -213,7 +216,7 @@ const AdvancedSettings = () => {
/>
-
+
{
handleBlur={handleSettingBlur}
isEditableState={isEditableState}
setIsEditableState={setIsEditableState}
+ isEditable={isEditable}
/>
);
})}
diff --git a/src/advanced-settings/setting-card/SettingCard.test.jsx b/src/advanced-settings/setting-card/SettingCard.test.jsx
index cb2fd25aba..f8c3831d1e 100644
--- a/src/advanced-settings/setting-card/SettingCard.test.jsx
+++ b/src/advanced-settings/setting-card/SettingCard.test.jsx
@@ -1,4 +1,4 @@
-import { fireEvent, render, waitFor } from '@testing-library/react';
+import { fireEvent, render, waitFor, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n';
@@ -25,7 +25,7 @@ jest.mock('react-textarea-autosize', () =>
/>
)));
-const RootWrapper = () => (
+const RootWrapper = (props = {}) => (
(
handleBlur={handleBlur}
isEditableState
saveSettingsPrompt={false}
+ {...props}
/>
);
@@ -91,4 +92,33 @@ describe('', () => {
expect(handleBlur).toHaveBeenCalled();
});
});
+ it('renders in read-only mode with disabled input', () => {
+ render();
+ const input = screen.getByLabelText(/Setting Name/i);
+ expect(input).toBeDisabled();
+ });
+
+ it('renders enabled by default when isEditable is not specified (default true)', () => {
+ render();
+ const input = screen.getByLabelText(/Setting Name/i);
+ expect(input).not.toBeDisabled();
+ });
+
+ it('calls setIsEditableState when value changes and isEditableState is false', async () => {
+ // Test for line 45: setIsEditableState(true) called when value changes
+ const { getByLabelText } = render();
+ const input = getByLabelText(/Setting Name/i);
+ fireEvent.change(input, { target: { value: 'new-different-value' } });
+ await waitFor(() => {
+ expect(setIsEditableState).toHaveBeenCalledWith(true);
+ });
+ });
+
+ it('shows help popup when clicking info button', () => {
+ render();
+ const helpButton = screen.getByRole('button', { name: /show help text/i });
+ fireEvent.click(helpButton);
+ // The help text should be visible in the popup - verify component renders help
+ expect(screen.queryByText(/This is a help message/i)).toBeInTheDocument();
+ });
});
diff --git a/src/advanced-settings/setting-card/SettingCard.tsx b/src/advanced-settings/setting-card/SettingCard.tsx
index e7dedd7df4..12a8226327 100644
--- a/src/advanced-settings/setting-card/SettingCard.tsx
+++ b/src/advanced-settings/setting-card/SettingCard.tsx
@@ -25,6 +25,7 @@ const SettingCard = ({
saveSettingsPrompt,
isEditableState,
setIsEditableState,
+ isEditable = true,
}) => {
const intl = useIntl();
const { deprecated, help, displayName } = settingData;
@@ -99,6 +100,7 @@ const SettingCard = ({
onChange={handleSettingChange}
aria-label={displayName}
onBlur={handleCardBlur}
+ disabled={!isEditable}
/>
@@ -133,6 +135,7 @@ SettingCard.propTypes = {
saveSettingsPrompt: PropTypes.bool.isRequired,
isEditableState: PropTypes.bool.isRequired,
setIsEditableState: PropTypes.func.isRequired,
+ isEditable: PropTypes.bool,
};
export default SettingCard;
diff --git a/src/authz/constants.ts b/src/authz/constants.ts
index aa148ea08b..a9b8ae3c41 100644
--- a/src/authz/constants.ts
+++ b/src/authz/constants.ts
@@ -17,7 +17,11 @@ export const CONTENT_LIBRARY_PERMISSIONS = {
export const COURSE_PERMISSIONS = {
MANAGE_ADVANCED_SETTINGS: 'courses.manage_advanced_settings',
+ VIEW_ADVANCED_SETTINGS: 'courses.view_advanced_settings',
VIEW_GRADING_SETTINGS: 'courses.view_grading_settings',
EDIT_GRADING_SETTINGS: 'courses.edit_grading_settings',
+
+ VIEW_PAGES_AND_RESOURCES: 'courses.view_pages_and_resources',
+ MANAGE_PAGES_AND_RESOURCES: 'courses.manage_pages_and_resources',
};
diff --git a/src/authz/permissionHelpers.test.ts b/src/authz/permissionHelpers.test.ts
new file mode 100644
index 0000000000..edc8b5f2ea
--- /dev/null
+++ b/src/authz/permissionHelpers.test.ts
@@ -0,0 +1,51 @@
+import {
+ getGradingPermissions,
+ getPagesAndResourcesPermissions,
+ getAdvancedSettingsPermissions,
+} from './permissionHelpers';
+import { COURSE_PERMISSIONS } from './constants';
+
+describe('permissionHelpers', () => {
+ const courseId = 'course-v1:org+course+run';
+
+ describe('getGradingPermissions', () => {
+ it('returns VIEW and EDIT permissions with the correct actions and scope', () => {
+ const result = getGradingPermissions(courseId);
+
+ expect(result.canViewGradingSettings.action).toBe(COURSE_PERMISSIONS.VIEW_GRADING_SETTINGS);
+ expect(result.canViewGradingSettings.scope).toBe(courseId);
+ expect(result.canEditGradingSettings.action).toBe(COURSE_PERMISSIONS.EDIT_GRADING_SETTINGS);
+ expect(result.canEditGradingSettings.scope).toBe(courseId);
+ });
+ });
+
+ describe('getPagesAndResourcesPermissions', () => {
+ it('returns VIEW and MANAGE permissions with the correct actions and scope', () => {
+ const result = getPagesAndResourcesPermissions(courseId);
+
+ expect(result.canViewPagesAndResources.action).toBe(COURSE_PERMISSIONS.VIEW_PAGES_AND_RESOURCES);
+ expect(result.canViewPagesAndResources.scope).toBe(courseId);
+ expect(result.canManagePagesAndResources.action).toBe(COURSE_PERMISSIONS.MANAGE_PAGES_AND_RESOURCES);
+ expect(result.canManagePagesAndResources.scope).toBe(courseId);
+ });
+ });
+
+ describe('getAdvancedSettingsPermissions', () => {
+ it('returns VIEW and MANAGE permissions with the correct actions and scope', () => {
+ const result = getAdvancedSettingsPermissions(courseId);
+
+ expect(result.canViewAdvancedSettings.action).toBe(COURSE_PERMISSIONS.VIEW_ADVANCED_SETTINGS);
+ expect(result.canViewAdvancedSettings.scope).toBe(courseId);
+ expect(result.canManageAdvancedSettings.action).toBe(COURSE_PERMISSIONS.MANAGE_ADVANCED_SETTINGS);
+ expect(result.canManageAdvancedSettings.scope).toBe(courseId);
+ });
+
+ it('uses the provided courseId as scope', () => {
+ const otherId = 'course-v1:another+test+run';
+ const result = getAdvancedSettingsPermissions(otherId);
+
+ expect(result.canViewAdvancedSettings.scope).toBe(otherId);
+ expect(result.canManageAdvancedSettings.scope).toBe(otherId);
+ });
+ });
+});
diff --git a/src/authz/permissionHelpers.ts b/src/authz/permissionHelpers.ts
index 76585ea0bf..be2a944d54 100644
--- a/src/authz/permissionHelpers.ts
+++ b/src/authz/permissionHelpers.ts
@@ -10,3 +10,25 @@ export const getGradingPermissions = (courseId: string) => ({
scope: courseId,
},
});
+
+export const getPagesAndResourcesPermissions = (courseId: string) => ({
+ canViewPagesAndResources: {
+ action: COURSE_PERMISSIONS.VIEW_PAGES_AND_RESOURCES,
+ scope: courseId,
+ },
+ canManagePagesAndResources: {
+ action: COURSE_PERMISSIONS.MANAGE_PAGES_AND_RESOURCES,
+ scope: courseId,
+ },
+});
+
+export const getAdvancedSettingsPermissions = (courseId: string) => ({
+ canViewAdvancedSettings: {
+ action: COURSE_PERMISSIONS.VIEW_ADVANCED_SETTINGS,
+ scope: courseId,
+ },
+ canManageAdvancedSettings: {
+ action: COURSE_PERMISSIONS.MANAGE_ADVANCED_SETTINGS,
+ scope: courseId,
+ },
+});
diff --git a/src/pages-and-resources/PagesAndResources.test.tsx b/src/pages-and-resources/PagesAndResources.test.tsx
index 7196f19db2..8c06c1a625 100644
--- a/src/pages-and-resources/PagesAndResources.test.tsx
+++ b/src/pages-and-resources/PagesAndResources.test.tsx
@@ -8,8 +8,16 @@ import {
import { getConfig, setConfig } from '@edx/frontend-platform';
import { PLUGIN_OPERATIONS, DIRECT_PLUGIN } from '@openedx/frontend-plugin-framework';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
+import { mockWaffleFlags } from '@src/data/apiHooks.mock';
+import { useCourseUserPermissions } from '@src/authz/hooks';
import { PagesAndResources } from '.';
+// Mock authz hooks
+jest.mock('@src/authz/hooks', () => ({
+ ...jest.requireActual('@src/authz/hooks'),
+ useCourseUserPermissions: jest.fn(),
+}));
+
const mockPlugin = (identifier) => ({
plugins: [
{
@@ -45,8 +53,30 @@ describe('PagesAndResources', () => {
),
},
});
+
+ // Set up waffle flags to disable authz by default
+ mockWaffleFlags({ enableAuthzCourseAuthoring: false });
+
+ // Default: authz disabled allows everything
+ jest.mocked(useCourseUserPermissions).mockReturnValue({
+ isLoading: false,
+ isAuthzEnabled: false,
+ canViewPagesAndResources: true,
+ canManagePagesAndResources: true,
+ } as ReturnType);
});
+ // Helper to set up permission mocks
+ const mockPermissions = (canView: boolean, canManage: boolean) => {
+ mockWaffleFlags({ enableAuthzCourseAuthoring: true });
+ jest.mocked(useCourseUserPermissions).mockReturnValue({
+ isLoading: false,
+ isAuthzEnabled: true,
+ canViewPagesAndResources: canView,
+ canManagePagesAndResources: canManage,
+ } as ReturnType);
+ };
+
it('doesn\'t show content permissions section if relevant apps are not enabled', async () => {
const initialState = {
models: {
@@ -128,4 +158,60 @@ describe('PagesAndResources', () => {
await waitFor(() => expect(screen.queryByTestId('additional_course_plugin')).toBeInTheDocument());
await waitFor(() => expect(screen.queryByTestId('additional_course_content_plugin')).toBeInTheDocument());
});
+
+ describe('permission integration', () => {
+ it('shows PermissionDeniedAlert when user has no VIEW or EDIT permissions', async () => {
+ mockPermissions(false, false);
+
+ const initialState = {
+ models: {
+ courseApps: {},
+ },
+ pagesAndResources: {
+ courseAppIds: [],
+ },
+ };
+
+ initializeMocks({ initialState });
+ renderComponent();
+
+ await waitFor(() => expect(screen.getByTestId('permissionDeniedAlert')).toBeInTheDocument());
+ });
+
+ it('does NOT show PermissionDeniedAlert when user has VIEW permission', async () => {
+ mockPermissions(true, false);
+
+ const initialState = {
+ models: {
+ courseApps: {},
+ },
+ pagesAndResources: {
+ courseAppIds: [],
+ },
+ };
+
+ initializeMocks({ initialState });
+ renderComponent();
+
+ await waitFor(() => expect(screen.queryByTestId('permissionDeniedAlert')).not.toBeInTheDocument());
+ });
+
+ it('does NOT show PermissionDeniedAlert when user has EDIT permission', async () => {
+ mockPermissions(true, true);
+
+ const initialState = {
+ models: {
+ courseApps: {},
+ },
+ pagesAndResources: {
+ courseAppIds: [],
+ },
+ };
+
+ initializeMocks({ initialState });
+ renderComponent();
+
+ await waitFor(() => expect(screen.queryByTestId('permissionDeniedAlert')).not.toBeInTheDocument());
+ });
+ });
});
diff --git a/src/pages-and-resources/PagesAndResources.tsx b/src/pages-and-resources/PagesAndResources.tsx
index dcac151c20..972611a5ea 100644
--- a/src/pages-and-resources/PagesAndResources.tsx
+++ b/src/pages-and-resources/PagesAndResources.tsx
@@ -14,6 +14,8 @@ import { AdditionalCoursePluginSlot } from '@src/plugin-slots/AdditionalCoursePl
import { AdditionalCourseContentPluginSlot } from '@src/plugin-slots/AdditionalCourseContentPluginSlot';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { DeprecatedReduxState } from '@src/store';
+import { useCourseUserPermissions } from '@src/authz/hooks';
+import { getPagesAndResourcesPermissions } from '@src/authz/permissionHelpers';
import messages from './messages';
import DiscussionsSettings from './discussions';
@@ -28,6 +30,13 @@ const PagesAndResources = () => {
const { courseId, courseDetails } = useCourseAuthoringContext();
document.title = getPageHeadTitle(courseDetails?.name || '', intl.formatMessage(messages.heading));
+ const {
+ isLoading: isLoadingUserPermissions,
+ isAuthzEnabled,
+ canViewPagesAndResources,
+ canManagePagesAndResources,
+ } = useCourseUserPermissions(courseId, getPagesAndResourcesPermissions(courseId));
+
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchCourseApps(courseId));
@@ -56,19 +65,25 @@ const PagesAndResources = () => {
}
});
- if (loadingStatus === RequestStatus.IN_PROGRESS) {
+ if (loadingStatus === RequestStatus.IN_PROGRESS || isLoadingUserPermissions) {
// eslint-disable-next-line react/jsx-no-useless-fragment
return <>>;
}
- if (courseAppsApiStatus === RequestStatus.DENIED) {
+ // Gate: if user has neither VIEW nor MANAGE permission, show permission denied
+ const hasNoAccess = (!isAuthzEnabled && courseAppsApiStatus === RequestStatus.DENIED)
+ || (isAuthzEnabled && !canViewPagesAndResources && !canManagePagesAndResources);
+
+ if (hasNoAccess) {
return ;
}
+ // When authz is disabled every authenticated user has full edit access.
+ const isEditable = !isAuthzEnabled || !!canManagePagesAndResources;
const hasAdditionalCoursePlugin = getConfig()?.pluginSlots?.additional_course_plugin != null;
return (
-
+
{intl.formatMessage(messages.heading)}
@@ -81,7 +96,6 @@ const PagesAndResources = () => {
-
{
/>
- } courseId={courseId} />
+ }
+ courseId={courseId}
+ />
{(contentPermissionsPages.length > 0 || hasAdditionalCoursePlugin)
&& (
<>
{intl.formatMessage(messages.contentPermissions)}
- } />
+ }
+ />
>
)}
diff --git a/src/pages-and-resources/PagesAndResourcesProvider.tsx b/src/pages-and-resources/PagesAndResourcesProvider.tsx
index 9184fce86d..4aece4d8c6 100644
--- a/src/pages-and-resources/PagesAndResourcesProvider.tsx
+++ b/src/pages-and-resources/PagesAndResourcesProvider.tsx
@@ -3,19 +3,27 @@ import React, { useMemo } from 'react';
interface PagesAndResourcesContextData {
courseId?: string;
path?: string;
+ isEditable?: boolean;
}
-export const PagesAndResourcesContext = React.createContext({});
+export const PagesAndResourcesContext = React.createContext({
+ isEditable: false,
+});
interface PagesAndResourcesProviderProps {
courseId: string;
+ isEditable?: boolean;
children: React.ReactNode;
}
-const PagesAndResourcesProvider = ({ courseId, children }: PagesAndResourcesProviderProps) => {
+// isEditable defaults to true so that existing renders without the authz RBAC flag
+// continue to work as fully editable. The context default is false (fail-closed) for
+// components that consume it outside of any provider.
+const PagesAndResourcesProvider = ({ courseId, isEditable = true, children }: PagesAndResourcesProviderProps) => {
const contextValue = useMemo(() => ({
courseId,
path: `/course/${courseId}/pages-and-resources`,
- }), []);
+ isEditable,
+ }), [courseId, isEditable]);
return (
{
const { formatMessage } = useIntl();
- const { courseId } = useContext(PagesAndResourcesContext);
+ const { courseId, isEditable } = useContext(PagesAndResourcesContext);
const loadingStatus = useSelector(getLoadingStatus);
const updateSettingsRequestStatus = useSelector(getSavingStatus);
const alertRef = useRef(null);
@@ -139,6 +139,7 @@ const AppSettingsModal = ({
}}
state={submitButtonState}
onClick={handleFormikSubmit(formikProps)}
+ disabled={!isEditable}
/>
}
>
@@ -157,6 +158,7 @@ const AppSettingsModal = ({
onChange={(event) => formikProps.handleChange(event)}
onBlur={formikProps.handleBlur}
checked={formikProps.values.enabled}
+ disabled={!isEditable}
label={
{enableAppLabel}
diff --git a/src/pages-and-resources/discussions/app-list/AppCard.jsx b/src/pages-and-resources/discussions/app-list/AppCard.jsx
index 5e5f1fe1e2..c4ae0e3079 100644
--- a/src/pages-and-resources/discussions/app-list/AppCard.jsx
+++ b/src/pages-and-resources/discussions/app-list/AppCard.jsx
@@ -8,6 +8,8 @@ import {
breakpoints,
} from '@openedx/paragon';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
+import { useContext } from 'react';
+import { PagesAndResourcesContext } from '../../PagesAndResourcesProvider';
import messages from './messages';
import appMessages from '../app-config-form/messages';
import FeaturesList from './FeaturesList';
@@ -20,15 +22,18 @@ const AppCard = ({
}) => {
const intl = useIntl();
const { canChangeProviders } = useCourseAuthoringContext();
+ const { isEditable } = useContext(PagesAndResourcesContext);
+ const canInteract = canChangeProviders && isEditable;
const supportText = app.hasFullSupport
? intl.formatMessage(messages.appFullSupport)
: intl.formatMessage(messages.appBasicSupport);
return (
canChangeProviders && onClick(app.id)}
- onKeyPress={() => canChangeProviders && onClick(app.id)}
+ isClickable={canInteract}
+ aria-disabled={!canInteract}
+ onClick={() => canInteract && onClick(app.id)}
+ onKeyDown={(e) => canInteract && (e.key === 'Enter' || e.key === ' ') && onClick(app.id)}
role="radio"
aria-checked={selected}
className={classNames({
@@ -42,7 +47,7 @@ const AppCard = ({
{
await executeThunk(fetchProviders(courseId), store.dispatch);
};
- const createComponent = (data) => {
+ const createComponent = (data, { isEditable = false } = {}) => {
const wrapper = render(
- jest.fn()}
- selected={selected}
- features={[]}
- />
+
+ jest.fn()}
+ selected={selected}
+ features={[]}
+ />
+
,
);
container = wrapper.container;
@@ -96,4 +100,98 @@ describe('AppCard', () => {
expect(queryByTestId(container, 'card-subtitle')).toHaveTextContent(subtitle);
});
+
+ describe('isEditable integration', () => {
+ test('card responds to click when isEditable=true', async () => {
+ const handleClick = jest.fn();
+ await mockStore(legacyApiResponse);
+
+ const wrapper = render(
+
+
+
+
+ ,
+ );
+ const card = wrapper.container.querySelector('[role="radio"]');
+
+ fireEvent.click(card);
+
+ expect(handleClick).toHaveBeenCalledWith(app.id);
+ });
+
+ test('card does NOT respond to click when isEditable=false', async () => {
+ const handleClick = jest.fn();
+ await mockStore(legacyApiResponse);
+
+ const wrapper = render(
+
+
+
+
+ ,
+ );
+ const card = wrapper.container.querySelector('[role="radio"]');
+
+ fireEvent.click(card);
+
+ expect(handleClick).not.toHaveBeenCalled();
+ });
+
+ test('card responds to keyDown Enter when isEditable=true', async () => {
+ const handleClick = jest.fn();
+ await mockStore(legacyApiResponse);
+
+ const wrapper = render(
+
+
+
+
+ ,
+ );
+ const card = wrapper.container.querySelector('[role="radio"]');
+
+ fireEvent.keyDown(card, { key: 'Enter' });
+
+ expect(handleClick).toHaveBeenCalledWith(app.id);
+ });
+
+ test('card does NOT respond to keyDown when isEditable=false', async () => {
+ const handleClick = jest.fn();
+ await mockStore(legacyApiResponse);
+
+ const wrapper = render(
+
+
+
+
+ ,
+ );
+ const card = wrapper.container.querySelector('[role="radio"]');
+
+ fireEvent.keyDown(card, { key: 'Enter' });
+
+ expect(handleClick).not.toHaveBeenCalled();
+ });
+ });
});
diff --git a/src/pages-and-resources/discussions/app-list/AppList.jsx b/src/pages-and-resources/discussions/app-list/AppList.jsx
index 8e060b10cf..c49e092d36 100644
--- a/src/pages-and-resources/discussions/app-list/AppList.jsx
+++ b/src/pages-and-resources/discussions/app-list/AppList.jsx
@@ -40,7 +40,7 @@ import { discussionRestriction } from '../data/constants';
const AppList = () => {
const intl = useIntl();
const dispatch = useDispatch();
- const { courseId } = useContext(PagesAndResourcesContext);
+ const { courseId, isEditable } = useContext(PagesAndResourcesContext);
const {
appIds,
featureIds,
@@ -155,6 +155,7 @@ const AppList = () => {
onChange={handleChange}
checked={!enabled}
data-testid="hide-discussion"
+ disabled={!isEditable}
>
{intl.formatMessage(messages.hideDiscussionTab)}
@@ -200,6 +201,7 @@ const AppList = () => {
className="ml-2"
variant="primary"
onClick={handleOk}
+ disabled={!isEditable}
/>
}
diff --git a/src/pages-and-resources/discussions/app-list/AppListNextButton.jsx b/src/pages-and-resources/discussions/app-list/AppListNextButton.jsx
index 4119241a83..82ed282ee0 100644
--- a/src/pages-and-resources/discussions/app-list/AppListNextButton.jsx
+++ b/src/pages-and-resources/discussions/app-list/AppListNextButton.jsx
@@ -5,6 +5,7 @@ import { useSelector } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import { DiscussionsContext } from '../DiscussionsProvider';
+import { PagesAndResourcesContext } from '../../PagesAndResourcesProvider';
import messages from './messages';
@@ -12,6 +13,7 @@ const AppListNextButton = () => {
const intl = useIntl();
const { selectedAppId } = useSelector(state => state.discussions);
const { path: discussionsPath } = useContext(DiscussionsContext);
+ const { isEditable } = useContext(PagesAndResourcesContext);
const navigate = useNavigate();
const handleStartConfig = useCallback(() => {
@@ -22,6 +24,7 @@ const AppListNextButton = () => {
diff --git a/src/pages-and-resources/index.ts b/src/pages-and-resources/index.ts
index 6cf33b0219..f35e8b67c0 100644
--- a/src/pages-and-resources/index.ts
+++ b/src/pages-and-resources/index.ts
@@ -1 +1,2 @@
export { default as PagesAndResources } from './PagesAndResources';
+export { PagesAndResourcesContext } from './PagesAndResourcesProvider';
diff --git a/src/pages-and-resources/pages/PageCard.test.jsx b/src/pages-and-resources/pages/PageCard.test.jsx
index 19c6247f58..c1bf2b756c 100644
--- a/src/pages-and-resources/pages/PageCard.test.jsx
+++ b/src/pages-and-resources/pages/PageCard.test.jsx
@@ -9,6 +9,7 @@ import {
} from '@src/testUtils';
import PageGrid from './PageGrid';
+import PageCard from './PageCard';
import PagesAndResourcesProvider from '../PagesAndResourcesProvider';
@@ -37,7 +38,7 @@ const mockPageConfig = [
const renderComponent = () => {
render(
-
+
,
);
@@ -70,4 +71,50 @@ describe('LiveSettings', () => {
expect(textbookSettingsButton).toHaveAttribute('href', textbookPagePath);
});
});
+
+ it('disables legacy-link arrow buttons in readOnly mode, but keeps settings gear accessible', async () => {
+ render(
+
+
+ ,
+ );
+ await waitFor(() => {
+ // Arrow buttons for legacy-link pages must be disabled so auditors
+ // can't navigate to external Studio pages that bypass isEditable.
+ const disabledButtons = screen.queryAllByRole('button').filter((btn) => btn.disabled);
+ expect(disabledButtons.length).toBeGreaterThan(0);
+ });
+ });
+
+ it('all buttons are enabled when isEditable=true', async () => {
+ render(
+
+
+ ,
+ );
+ await waitFor(() => {
+ const buttons = screen.queryAllByRole('button');
+ expect(buttons.length).toBeGreaterThan(0);
+ buttons.forEach((btn) => expect(btn).not.toBeDisabled());
+ });
+ });
+
+ it('renders PageCard with default isEditable=true — settings button is present and enabled', () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText('Test Page')).toBeInTheDocument();
+ });
});
diff --git a/src/pages-and-resources/pages/PageSettingButton.jsx b/src/pages-and-resources/pages/PageSettingButton.jsx
index 4759032ae8..ab2b3ce1b4 100644
--- a/src/pages-and-resources/pages/PageSettingButton.jsx
+++ b/src/pages-and-resources/pages/PageSettingButton.jsx
@@ -17,7 +17,7 @@ const PageSettingButton = ({
allowedOperations,
}) => {
const { formatMessage } = useIntl();
- const { path: pagesAndResourcesPath } = useContext(PagesAndResourcesContext);
+ const { path: pagesAndResourcesPath, isEditable } = useContext(PagesAndResourcesContext);
const navigate = useNavigate();
const waffleFlags = useWaffleFlags(courseId);
@@ -41,6 +41,19 @@ const PageSettingButton = ({
const canConfigureOrEnable = allowedOperations?.configure || allowedOperations?.enable;
+ if (determineLinkDestination && !isEditable) {
+ return (
+
+ );
+ }
+
if (determineLinkDestination) {
return (
diff --git a/src/pages-and-resources/pages/PageSettingButton.test.jsx b/src/pages-and-resources/pages/PageSettingButton.test.jsx
index c44eeeaf6b..9d9ddf80c4 100644
--- a/src/pages-and-resources/pages/PageSettingButton.test.jsx
+++ b/src/pages-and-resources/pages/PageSettingButton.test.jsx
@@ -1,7 +1,8 @@
// @ts-check
-import { screen, render, initializeMocks } from '../../testUtils';
+import { screen, render, initializeMocks, fireEvent } from '../../testUtils';
import PageSettingButton from './PageSettingButton';
import { mockWaffleFlags } from '../../data/apiHooks.mock';
+import PagesAndResourcesProvider from '../PagesAndResourcesProvider';
const defaultProps = {
id: 'page_id',
@@ -10,7 +11,12 @@ const defaultProps = {
allowedOperations: { configure: true, enable: true },
};
-const renderComponent = (props = {}) => render();
+const renderComponent = (props = {}, { isEditable = true } = {}) =>
+ render(
+
+
+ ,
+ );
mockWaffleFlags();
@@ -56,4 +62,33 @@ describe('PageSettingButton', () => {
const linkElement = screen.getByRole('link');
expect(linkElement).toHaveAttribute('href', defaultProps.legacyLink);
});
+
+ it('renders disabled icon button in read-only mode with legacy link', () => {
+ renderComponent({ legacyLink: 'http://legacylink.com/textbooks' }, { isEditable: false });
+
+ const button = screen.getByRole('button');
+ expect(button).toBeDisabled();
+ });
+
+ it('renders arrow link when user is editable', () => {
+ renderComponent({ legacyLink: 'http://legacylink.com/textbooks' }, { isEditable: true });
+
+ const linkElement = screen.getByRole('link');
+ expect(linkElement).toBeInTheDocument();
+ });
+
+ it('does not render when no legacyLink and cannot configure', () => {
+ renderComponent({ allowedOperations: null, legacyLink: null });
+
+ expect(screen.queryByRole('button')).toBeNull();
+ });
+
+ it('navigates to settings page when settings gear button clicked', () => {
+ renderComponent({ legacyLink: 'http://legacylink.com/some-value' });
+
+ const button = screen.getByRole('button');
+ expect(button).toBeInTheDocument();
+ expect(button).not.toBeDisabled();
+ fireEvent.click(button);
+ });
});