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); + }); });