Skip to content

Commit 5bf47e2

Browse files
committed
feat(pages-and-resources): add read-only access for course_auditor role
- Add VIEW_ADVANCED_SETTINGS and PAGE_AND_RESOURCES permissions - Add getPagesAndResourcesPermissions helper - Calculate isEditable and isReadOnly from user permissions - Propagate isEditable via PagesAndResourcesContext - Add disabled={!isEditable} to all forms, toggles, and buttons - Update AppCard and AppListNextButton with isEditable logic - Change default isEditable to false (fail closed) - Add unified permission gate showing PermissionDeniedAlert
1 parent e330471 commit 5bf47e2

20 files changed

Lines changed: 177 additions & 38 deletions

plugins/course-apps/progress/Settings.jsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
import { useIntl } from '@edx/frontend-platform/i18n';
22
import PropTypes from 'prop-types';
3-
import React from 'react';
3+
import React, { useContext } from 'react';
44
import * as Yup from 'yup';
55
import { getConfig } from '@edx/frontend-platform';
66
import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup';
77
import { useAppSetting } from 'CourseAuthoring/utils';
88
import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal';
9+
import { PagesAndResourcesContext } from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider';
910
import messages from './messages';
1011

1112
const ProgressSettings = ({ onClose }) => {
1213
const intl = useIntl();
1314
const [disableProgressGraph, saveSetting] = useAppSetting('disableProgressGraph');
1415
const showProgressGraphSetting = getConfig().ENABLE_PROGRESS_GRAPH_SETTINGS.toString().toLowerCase() === 'true';
16+
const { isEditable = false } = useContext(PagesAndResourcesContext);
1517

1618
const handleSettingsSave = async (values) => {
1719
if (showProgressGraphSetting) { await saveSetting(!values.enableProgressGraph); }
@@ -39,6 +41,7 @@ const ProgressSettings = ({ onClose }) => {
3941
onChange={handleChange}
4042
onBlur={handleBlur}
4143
checked={values.enableProgressGraph}
44+
disabled={!isEditable}
4245
/>
4346
)
4447
)}

src/CourseAuthoringPage.tsx

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
import React, { useEffect } from 'react';
2-
import { useDispatch, useSelector } from 'react-redux';
2+
import { useDispatch } from 'react-redux';
33

44
import {
55
useLocation,
66
} from 'react-router-dom';
77
import { StudioFooterSlot } from '@edx/frontend-component-footer';
88
import Header from './header';
99
import NotFoundAlert from './generic/NotFoundAlert';
10-
import PermissionDeniedAlert from './generic/PermissionDeniedAlert';
1110
import { fetchOnlyStudioHomeData } from './studio-home/data/thunks';
12-
import { getCourseAppsApiStatus } from './pages-and-resources/data/selectors';
1311
import { RequestStatus } from './data/constants';
1412
import Loading from './generic/Loading';
1513
import { useCourseAuthoringContext } from './CourseAuthoringContext';
@@ -30,16 +28,12 @@ const CourseAuthoringPage = ({ children }: Props) => {
3028
const courseOrg = courseDetails?.org;
3129
const courseTitle = courseDetails?.name;
3230
const inProgress = courseDetailStatus === RequestStatus.IN_PROGRESS || courseDetailStatus === RequestStatus.PENDING;
33-
const courseAppsApiStatus = useSelector(getCourseAppsApiStatus);
3431
const { pathname } = useLocation();
3532
const isEditor = pathname.includes('/editor');
3633

3734
if (courseDetailStatus === RequestStatus.NOT_FOUND && !isEditor) {
3835
return <NotFoundAlert />;
3936
}
40-
if (courseAppsApiStatus === RequestStatus.DENIED) {
41-
return <PermissionDeniedAlert />;
42-
}
4337
return (
4438
<div>
4539
{

src/advanced-settings/AdvancedSettings.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ const AdvancedSettings = () => {
5151
action: COURSE_PERMISSIONS.MANAGE_ADVANCED_SETTINGS,
5252
scope: courseId,
5353
},
54+
canViewAdvancedSettings: {
55+
action: COURSE_PERMISSIONS.VIEW_ADVANCED_SETTINGS,
56+
scope: courseId,
57+
},
5458
}, isAuthzEnabled);
5559

5660
const {
@@ -148,15 +152,29 @@ const AdvancedSettings = () => {
148152
showSaveSettingsPrompt(true);
149153
};
150154

151-
// Show permission denied alert when authz is enabled and user doesn't have permission
155+
// Show permission denied alert when authz is enabled and user doesn't have VIEW or MANAGE
152156
const authzIsEnabledAndNoPermission = isAuthzEnabled
153157
&& !isLoadingUserPermissions
158+
&& !userPermissions?.canViewAdvancedSettings
154159
&& !userPermissions?.canManageAdvancedSettings;
155160

156161
if (authzIsEnabledAndNoPermission) {
157162
return <PermissionDeniedAlert />;
158163
}
159164

165+
// Determine if UI should be disabled (has VIEW but not MANAGE) - auditor sees read-only view
166+
const isReadOnly = isAuthzEnabled
167+
&& !isLoadingUserPermissions
168+
&& userPermissions?.canViewAdvancedSettings
169+
&& !userPermissions?.canManageAdvancedSettings;
170+
171+
// Apply read-only mode for auditors
172+
if (isReadOnly) {
173+
setIsEditableState(false);
174+
}
175+
176+
// Show the page content (read-only or editable)
177+
160178
return (
161179
<>
162180
<Helmet>
@@ -255,6 +273,7 @@ const AdvancedSettings = () => {
255273
handleBlur={handleSettingBlur}
256274
isEditableState={isEditableState}
257275
setIsEditableState={setIsEditableState}
276+
readOnly={isReadOnly}
258277
/>
259278
);
260279
})}

src/advanced-settings/setting-card/SettingCard.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const SettingCard = ({
2525
saveSettingsPrompt,
2626
isEditableState,
2727
setIsEditableState,
28+
readOnly = false,
2829
}) => {
2930
const intl = useIntl();
3031
const { deprecated, help, displayName } = settingData;
@@ -99,6 +100,7 @@ const SettingCard = ({
99100
onChange={handleSettingChange}
100101
aria-label={displayName}
101102
onBlur={handleCardBlur}
103+
disabled={readOnly}
102104
/>
103105
</Form.Group>
104106
</Card.Section>
@@ -133,6 +135,11 @@ SettingCard.propTypes = {
133135
saveSettingsPrompt: PropTypes.bool.isRequired,
134136
isEditableState: PropTypes.bool.isRequired,
135137
setIsEditableState: PropTypes.func.isRequired,
138+
readOnly: PropTypes.bool,
139+
};
140+
141+
SettingCard.defaultProps = {
142+
readOnly: false,
136143
};
137144

138145
export default SettingCard;

src/authz/constants.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@ export const CONTENT_LIBRARY_PERMISSIONS = {
1717

1818
export const COURSE_PERMISSIONS = {
1919
MANAGE_ADVANCED_SETTINGS: 'courses.manage_advanced_settings',
20+
VIEW_ADVANCED_SETTINGS: 'courses.view_advanced_settings',
2021

2122
VIEW_GRADING_SETTINGS: 'courses.view_grading_settings',
2223
EDIT_GRADING_SETTINGS: 'courses.edit_grading_settings',
24+
25+
VIEW_PAGES_AND_RESOURCES: 'courses.view_pages_and_resources',
26+
EDIT_PAGES_AND_RESOURCES: 'courses.manage_pages_and_resources',
2327
};

src/authz/permissionHelpers.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,14 @@ export const getGradingPermissions = (courseId: string) => ({
1010
scope: courseId,
1111
},
1212
});
13+
14+
export const getPagesAndResourcesPermissions = (courseId: string) => ({
15+
canViewPagesAndResources: {
16+
action: COURSE_PERMISSIONS.VIEW_PAGES_AND_RESOURCES,
17+
scope: courseId,
18+
},
19+
canEditPagesAndResources: {
20+
action: COURSE_PERMISSIONS.EDIT_PAGES_AND_RESOURCES,
21+
scope: courseId,
22+
},
23+
});

src/pages-and-resources/PagesAndResources.tsx

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import { AdditionalCoursePluginSlot } from '@src/plugin-slots/AdditionalCoursePl
1414
import { AdditionalCourseContentPluginSlot } from '@src/plugin-slots/AdditionalCourseContentPluginSlot';
1515
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
1616
import { DeprecatedReduxState } from '@src/store';
17+
import { useCourseUserPermissions } from '@src/authz/hooks';
18+
import { getPagesAndResourcesPermissions } from '@src/authz/permissionHelpers';
1719

1820
import messages from './messages';
1921
import DiscussionsSettings from './discussions';
@@ -28,6 +30,13 @@ const PagesAndResources = () => {
2830
const { courseId, courseDetails } = useCourseAuthoringContext();
2931
document.title = getPageHeadTitle(courseDetails?.name || '', intl.formatMessage(messages.heading));
3032

33+
const {
34+
isLoading: isLoadingUserPermissions,
35+
isAuthzEnabled,
36+
canViewPagesAndResources,
37+
canEditPagesAndResources,
38+
} = useCourseUserPermissions(courseId, getPagesAndResourcesPermissions(courseId));
39+
3140
const dispatch = useDispatch();
3241
useEffect(() => {
3342
dispatch(fetchCourseApps(courseId));
@@ -56,19 +65,33 @@ const PagesAndResources = () => {
5665
}
5766
});
5867

59-
if (loadingStatus === RequestStatus.IN_PROGRESS) {
68+
if (loadingStatus === RequestStatus.IN_PROGRESS || isLoadingUserPermissions) {
6069
// eslint-disable-next-line react/jsx-no-useless-fragment
6170
return <></>;
6271
}
6372

64-
if (courseAppsApiStatus === RequestStatus.DENIED) {
73+
// Gate: if user has neither VIEW nor MANAGE permission, show permission denied
74+
const hasNoAccess = (!isAuthzEnabled && courseAppsApiStatus === RequestStatus.DENIED)
75+
|| (isAuthzEnabled && !isLoadingUserPermissions && !canViewPagesAndResources && !canEditPagesAndResources);
76+
77+
if (hasNoAccess) {
6578
return <PermissionDeniedAlert />;
6679
}
6780

81+
const isEditable = !isLoadingUserPermissions && canEditPagesAndResources;
6882
const hasAdditionalCoursePlugin = getConfig()?.pluginSlots?.additional_course_plugin != null;
6983

84+
// Read-only mode: has VIEW permission but not MANAGE (auditor)
85+
const isReadOnly = isAuthzEnabled
86+
&& !isLoadingUserPermissions
87+
&& canViewPagesAndResources
88+
&& !canEditPagesAndResources;
89+
90+
// For the modal: if readOnly, isEditable should be false (show disabled fields)
91+
const isEditableForModal = isReadOnly ? false : isEditable;
92+
7093
return (
71-
<PagesAndResourcesProvider courseId={courseId}>
94+
<PagesAndResourcesProvider courseId={courseId} isEditable={isEditableForModal}>
7295
<main className="container container-mw-md px-3">
7396
<div className="d-flex justify-content-between my-4 my-md-5 align-items-center">
7497
<h3 className="m-0">{intl.formatMessage(messages.heading)}</h3>
@@ -81,7 +104,6 @@ const PagesAndResources = () => {
81104
<Button variant="outline-primary" className="p-2">{intl.formatMessage(messages.viewLiveButton)}</Button>
82105
</Hyperlink>
83106
</div>
84-
85107
<Routes>
86108
<Route
87109
path="discussion/configure/:appId"
@@ -117,14 +139,23 @@ const PagesAndResources = () => {
117139
/>
118140
</Routes>
119141

120-
<PageGrid pages={pages} pluginSlotComponent={<AdditionalCoursePluginSlot />} courseId={courseId} />
142+
<PageGrid
143+
pages={pages}
144+
pluginSlotComponent={<AdditionalCoursePluginSlot />}
145+
courseId={courseId}
146+
readOnly={isReadOnly}
147+
/>
121148
{(contentPermissionsPages.length > 0 || hasAdditionalCoursePlugin)
122149
&& (
123150
<>
124151
<div className="d-flex justify-content-between my-4 my-md-5 align-items-center">
125152
<h3 className="m-0">{intl.formatMessage(messages.contentPermissions)}</h3>
126153
</div>
127-
<PageGrid pages={contentPermissionsPages} pluginSlotComponent={<AdditionalCourseContentPluginSlot />} />
154+
<PageGrid
155+
pages={contentPermissionsPages}
156+
pluginSlotComponent={<AdditionalCourseContentPluginSlot />}
157+
readOnly={isReadOnly}
158+
/>
128159
</>
129160
)}
130161
</main>

src/pages-and-resources/PagesAndResourcesProvider.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,24 @@ import React, { useMemo } from 'react';
33
interface PagesAndResourcesContextData {
44
courseId?: string;
55
path?: string;
6+
isEditable?: boolean;
67
}
7-
export const PagesAndResourcesContext = React.createContext<PagesAndResourcesContextData>({});
8+
export const PagesAndResourcesContext = React.createContext<PagesAndResourcesContextData>({
9+
isEditable: false,
10+
});
811

912
interface PagesAndResourcesProviderProps {
1013
courseId: string;
14+
isEditable?: boolean;
1115
children: React.ReactNode;
1216
}
1317

14-
const PagesAndResourcesProvider = ({ courseId, children }: PagesAndResourcesProviderProps) => {
18+
const PagesAndResourcesProvider = ({ courseId, isEditable = true, children }: PagesAndResourcesProviderProps) => {
1519
const contextValue = useMemo(() => ({
1620
courseId,
1721
path: `/course/${courseId}/pages-and-resources`,
18-
}), []);
22+
isEditable,
23+
}), [courseId, isEditable]);
1924
return (
2025
<PagesAndResourcesContext.Provider
2126
value={contextValue}

src/pages-and-resources/app-settings-modal/AppSettingsModal.jsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ const AppSettingsModal = ({
5252
hideAppToggle,
5353
}) => {
5454
const { formatMessage } = useIntl();
55-
const { courseId } = useContext(PagesAndResourcesContext);
55+
const { courseId, isEditable = false } = useContext(PagesAndResourcesContext);
5656
const loadingStatus = useSelector(getLoadingStatus);
5757
const updateSettingsRequestStatus = useSelector(getSavingStatus);
5858
const alertRef = useRef(null);
@@ -139,6 +139,7 @@ const AppSettingsModal = ({
139139
}}
140140
state={submitButtonState}
141141
onClick={handleFormikSubmit(formikProps)}
142+
disabled={!isEditable}
142143
/>
143144
}
144145
>
@@ -157,6 +158,7 @@ const AppSettingsModal = ({
157158
onChange={(event) => formikProps.handleChange(event)}
158159
onBlur={formikProps.handleBlur}
159160
checked={formikProps.values.enabled}
161+
disabled={!isEditable}
160162
label={
161163
<div className="d-flex align-items-center">
162164
{enableAppLabel}

src/pages-and-resources/discussions/app-config-form/AppConfigForm.jsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ const AppConfigForm = ({
4242
const navigate = useNavigate();
4343

4444
const { formRef } = useContext(AppConfigFormContext);
45-
const { path: pagesAndResourcesPath } = useContext(PagesAndResourcesContext);
45+
const { path: pagesAndResourcesPath, isEditable: contextIsEditable = false } = useContext(PagesAndResourcesContext);
4646
const { appId: routeAppId } = useParams();
4747
const [isLoading, setLoading] = useState(true);
4848
const {
@@ -102,6 +102,7 @@ const AppConfigForm = ({
102102
formRef={formRef}
103103
onSubmit={handleSubmit}
104104
legacy
105+
isEditable={contextIsEditable}
105106
/>
106107
);
107108
} else if (selectedAppId === 'openedx') {
@@ -110,13 +111,15 @@ const AppConfigForm = ({
110111
formRef={formRef}
111112
onSubmit={handleSubmit}
112113
legacy={false}
114+
isEditable={contextIsEditable}
113115
/>
114116
);
115117
} else {
116118
form = (
117119
<LtiConfigForm
118120
formRef={formRef}
119121
onSubmit={handleSubmit}
122+
isEditable={contextIsEditable}
120123
/>
121124
);
122125
}

0 commit comments

Comments
 (0)