Skip to content

Commit 3b4ae21

Browse files
authored
feat: implement grading permissions and authorization hooks (openedx#2988)
1 parent 1d23bed commit 3b4ae21

17 files changed

Lines changed: 386 additions & 9 deletions

File tree

src/authz/constants.ts

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

1818
export const COURSE_PERMISSIONS = {
1919
MANAGE_ADVANCED_SETTINGS: 'courses.manage_advanced_settings',
20+
21+
VIEW_GRADING_SETTINGS: 'courses.view_grading_settings',
22+
EDIT_GRADING_SETTINGS: 'courses.edit_grading_settings',
2023
};

src/authz/hooks.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { renderHook } from '@testing-library/react';
2+
import { useUserPermissions } from '@src/authz/data/apiHooks';
3+
import { mockWaffleFlags } from '@src/data/apiHooks.mock';
4+
import { useCourseUserPermissions } from './hooks';
5+
import { COURSE_PERMISSIONS } from './constants';
6+
7+
jest.mock('@src/authz/data/apiHooks', () => ({
8+
useUserPermissions: jest.fn(),
9+
}));
10+
11+
const courseId = 'course-v1:org+course+run';
12+
const permissions = {
13+
canView: { action: COURSE_PERMISSIONS.VIEW_GRADING_SETTINGS, scope: courseId },
14+
canEdit: { action: COURSE_PERMISSIONS.EDIT_GRADING_SETTINGS, scope: courseId },
15+
};
16+
17+
describe('useCourseUserPermissions', () => {
18+
beforeEach(() => {
19+
jest.clearAllMocks();
20+
jest.mocked(useUserPermissions).mockReturnValue({
21+
isLoading: false,
22+
data: undefined,
23+
} as unknown as ReturnType<typeof useUserPermissions>);
24+
});
25+
26+
it('defaults all permissions to true when authz is disabled', () => {
27+
mockWaffleFlags({ enableAuthzCourseAuthoring: false });
28+
29+
const { result } = renderHook(() => useCourseUserPermissions(courseId, permissions));
30+
31+
expect(result.current.isLoading).toBe(false);
32+
expect(result.current.isAuthzEnabled).toBe(false);
33+
expect(result.current.canView).toBe(true);
34+
expect(result.current.canEdit).toBe(true);
35+
});
36+
37+
it('returns actual permission values when authz is enabled and permissions are loaded', () => {
38+
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
39+
jest.mocked(useUserPermissions).mockReturnValue({
40+
isLoading: false,
41+
data: { canView: true, canEdit: false },
42+
} as unknown as ReturnType<typeof useUserPermissions>);
43+
44+
const { result } = renderHook(() => useCourseUserPermissions(courseId, permissions));
45+
46+
expect(result.current.isLoading).toBe(false);
47+
expect(result.current.isAuthzEnabled).toBe(true);
48+
expect(result.current.canView).toBe(true);
49+
expect(result.current.canEdit).toBe(false);
50+
});
51+
52+
it('returns isLoading=true and no permission keys while authz permissions are loading', () => {
53+
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
54+
jest.mocked(useUserPermissions).mockReturnValue({
55+
isLoading: true,
56+
data: undefined,
57+
} as unknown as ReturnType<typeof useUserPermissions>);
58+
59+
const { result } = renderHook(() => useCourseUserPermissions(courseId, permissions));
60+
61+
expect(result.current.isLoading).toBe(true);
62+
expect(result.current.isAuthzEnabled).toBe(true);
63+
expect(result.current.canView).toBeUndefined();
64+
expect(result.current.canEdit).toBeUndefined();
65+
});
66+
67+
it('falls back to false for permissions absent from server response when authz is enabled', () => {
68+
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
69+
jest.mocked(useUserPermissions).mockReturnValue({
70+
isLoading: false,
71+
data: {},
72+
} as unknown as ReturnType<typeof useUserPermissions>);
73+
74+
const { result } = renderHook(() => useCourseUserPermissions(courseId, permissions));
75+
76+
expect(result.current.canView).toBe(false);
77+
expect(result.current.canEdit).toBe(false);
78+
});
79+
});

src/authz/hooks.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { useWaffleFlags } from '@src/data/apiHooks';
2+
import { useUserPermissions } from '@src/authz/data/apiHooks';
3+
import { PermissionValidationAnswer, PermissionValidationQuery } from '@src/authz/types';
4+
5+
type UseCourseUserPermissionsReturn<Query extends PermissionValidationQuery> = {
6+
isLoading: boolean;
7+
isAuthzEnabled: boolean;
8+
} & PermissionValidationAnswer<Query>;
9+
10+
/**
11+
* Custom hook to retrieve and evaluate user permissions for the current course using the openedx-authz service.
12+
*
13+
* The hook:
14+
* 1. Validate if authz is enabled via waffle flag
15+
* 2. Fetch user permissions when authz is enabled
16+
* 3. Fallback all permissions to 'true' when authz is disabled
17+
* 4. Provide fallback values for undefined permissions
18+
*
19+
* @param courseId - The course ID to check permissions for
20+
* @param permissions - Object mapping permission names to their action/scope definitions
21+
* @returns Object containing loading state, permissions results, and authz status
22+
*
23+
* @example
24+
* ```tsx
25+
* const { isLoading, canViewGradingSettings, canEditGradingSettings, isAuthzEnabled } = useCourseUserPermissions(
26+
* courseId,
27+
* {
28+
* canViewGradingSettings: {
29+
* action: COURSE_PERMISSIONS.VIEW_GRADING_SETTINGS,
30+
* scope: courseId,
31+
* },
32+
* canEditGradingSettings: {
33+
* action: COURSE_PERMISSIONS.EDIT_GRADING_SETTINGS,
34+
* scope: courseId,
35+
* },
36+
* }
37+
* );
38+
* ```
39+
*/
40+
export const useCourseUserPermissions = <Query extends PermissionValidationQuery>(
41+
courseId: string,
42+
permissions: Query,
43+
): UseCourseUserPermissionsReturn<Query> => {
44+
const waffleFlags = useWaffleFlags(courseId);
45+
const isAuthzEnabled: boolean = waffleFlags?.enableAuthzCourseAuthoring ?? false;
46+
47+
const {
48+
isLoading: isLoadingUserPermissions,
49+
data: userPermissions,
50+
} = useUserPermissions(permissions, isAuthzEnabled);
51+
52+
const resolvePermission = (key: string): boolean => {
53+
if (!isAuthzEnabled) {
54+
return true;
55+
}
56+
return userPermissions?.[key] ?? false;
57+
};
58+
59+
const permissionResults: Record<string, boolean> = isLoadingUserPermissions
60+
? {}
61+
: Object.keys(permissions).reduce<Record<string, boolean>>((acc, key) => {
62+
acc[key] = resolvePermission(key);
63+
return acc;
64+
}, {});
65+
66+
return {
67+
isLoading: isAuthzEnabled ? isLoadingUserPermissions : false,
68+
isAuthzEnabled,
69+
...permissionResults as PermissionValidationAnswer<Query>,
70+
};
71+
};

src/authz/permissionHelpers.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { COURSE_PERMISSIONS } from './constants';
2+
3+
export const getGradingPermissions = (courseId: string) => ({
4+
canViewGradingSettings: {
5+
action: COURSE_PERMISSIONS.VIEW_GRADING_SETTINGS,
6+
scope: courseId,
7+
},
8+
canEditGradingSettings: {
9+
action: COURSE_PERMISSIONS.EDIT_GRADING_SETTINGS,
10+
scope: courseId,
11+
},
12+
});

src/authz/types.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,6 @@ export interface PermissionValidationQuery {
1111
[permissionKey: string]: PermissionValidationRequestItem;
1212
}
1313

14-
export interface PermissionValidationAnswer {
15-
[permissionKey: string]: boolean;
16-
}
14+
export type PermissionValidationAnswer<Query extends PermissionValidationQuery = PermissionValidationQuery> = {
15+
[K in keyof Query]: boolean;
16+
};

src/grading-settings/GradingSettings.jsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ import { Helmet } from 'react-helmet';
1212
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
1313
import { STATEFUL_BUTTON_STATES } from '@src/constants';
1414
import { useCourseSettings } from '@src/data/apiHooks';
15+
import { useCourseUserPermissions } from '@src/authz/hooks';
16+
import { getGradingPermissions } from '@src/authz/permissionHelpers';
1517
import ConnectionErrorAlert from '@src/generic/ConnectionErrorAlert';
18+
import PermissionDeniedAlert from '@src/generic/PermissionDeniedAlert';
1619
import SectionSubHeader from '@src/generic/section-sub-header';
1720
import SubHeader from '@src/generic/sub-header/SubHeader';
1821
import AlertMessage from '@src/generic/alert-message';
@@ -34,6 +37,13 @@ import messages from './messages';
3437
const GradingSettings = () => {
3538
const intl = useIntl();
3639
const { courseId, courseDetails } = useCourseAuthoringContext();
40+
41+
const {
42+
isLoading: isLoadingUserPermissions,
43+
canViewGradingSettings,
44+
canEditGradingSettings,
45+
} = useCourseUserPermissions(courseId, getGradingPermissions(courseId));
46+
3747
const {
3848
data: gradingSettings,
3949
isLoading: isGradingSettingsLoading,
@@ -55,7 +65,7 @@ const GradingSettings = () => {
5565
const courseGradingDetails = gradingSettings?.courseDetails;
5666
const isLoadingDenied = isGradingSettingsError || isCourseSettingsError;
5767
const [showSuccessAlert, setShowSuccessAlert] = useState(false);
58-
const isLoading = isCourseSettingsLoading || isGradingSettingsLoading;
68+
const isLoading = isCourseSettingsLoading || isGradingSettingsLoading || isLoadingUserPermissions;
5969
const [isQueryPending, setIsQueryPending] = useState(false);
6070
const [showOverrideInternetConnectionAlert, setOverrideInternetConnectionAlert] = useState(false);
6171
const [eligibleGrade, setEligibleGrade] = useState(null);
@@ -93,6 +103,10 @@ const GradingSettings = () => {
93103
}
94104
}, [savePending]);
95105

106+
if (!isLoadingUserPermissions && !canViewGradingSettings) {
107+
return <PermissionDeniedAlert />;
108+
}
109+
96110
if (isLoadingDenied) {
97111
return (
98112
<Container size="xl" className="course-unit px-4 mt-4">
@@ -105,6 +119,8 @@ const GradingSettings = () => {
105119
return null;
106120
}
107121

122+
const isEditable = !isLoadingUserPermissions && canEditGradingSettings;
123+
108124
const handleQueryProcessing = () => {
109125
setShowSuccessAlert(false);
110126
updateGradingSettings(gradingData);
@@ -177,6 +193,7 @@ const GradingSettings = () => {
177193
setOverrideInternetConnectionAlert={setOverrideInternetConnectionAlert}
178194
setEligibleGrade={setEligibleGrade}
179195
defaultGradeDesignations={gradingSettings?.defaultGradeDesignations}
196+
isEditable={isEditable}
180197
/>
181198
</section>
182199
{courseSettingsData.creditEligibilityEnabled && courseSettingsData.isCreditCourse && (
@@ -191,6 +208,7 @@ const GradingSettings = () => {
191208
minimumGradeCredit={minimumGradeCredit}
192209
setGradingData={setGradingData}
193210
setShowSuccessAlert={setShowSuccessAlert}
211+
isEditable={isEditable}
194212
/>
195213
</section>
196214
)}
@@ -204,6 +222,7 @@ const GradingSettings = () => {
204222
gracePeriod={gracePeriod}
205223
setGradingData={setGradingData}
206224
setShowSuccessAlert={setShowSuccessAlert}
225+
isEditable={isEditable}
207226
/>
208227
</section>
209228
<section>
@@ -222,11 +241,13 @@ const GradingSettings = () => {
222241
setGradingData={setGradingData}
223242
courseAssignmentLists={courseAssignmentLists}
224243
setShowSuccessAlert={setShowSuccessAlert}
244+
isEditable={isEditable}
225245
/>
226246
<Button
227247
variant="primary"
228248
iconBefore={IconAdd}
229249
onClick={handleAddAssignment}
250+
disabled={!isEditable}
230251
>
231252
{intl.formatMessage(messages.addNewAssignmentTypeBtn)}
232253
</Button>
@@ -270,6 +291,7 @@ const GradingSettings = () => {
270291
key="statefulBtn"
271292
onClick={handleSendGradingSettingsData}
272293
state={isQueryPending ? STATEFUL_BUTTON_STATES.pending : STATEFUL_BUTTON_STATES.default}
294+
disabled={!isEditable}
273295
{...updateValuesButtonState}
274296
/>,
275297
].filter(Boolean)}

src/grading-settings/GradingSettings.test.jsx

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,23 @@ import {
77
} from '@src/testUtils';
88
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
99
import { getCourseSettingsApiUrl } from '@src/data/api';
10+
import { mockWaffleFlags } from '@src/data/apiHooks.mock';
11+
import { useCourseUserPermissions } from '@src/authz/hooks';
1012

1113
import gradingSettings from './__mocks__/gradingSettings';
1214
import { getGradingSettingsApiUrl } from './data/api';
1315
import * as apiHooks from './data/apiHooks';
1416
import GradingSettings from './GradingSettings';
1517
import messages from './messages';
1618

19+
jest.mock('@src/authz/hooks', () => ({
20+
useCourseUserPermissions: jest.fn().mockReturnValue({
21+
isLoading: false,
22+
canViewGradingSettings: true,
23+
canEditGradingSettings: true,
24+
}),
25+
}));
26+
1727
const courseId = '123';
1828
let axiosMock;
1929

@@ -129,3 +139,70 @@ describe('<GradingSettings />', () => {
129139
expect(screen.getByTestId('connectionErrorAlert')).toBeInTheDocument();
130140
});
131141
});
142+
143+
describe('<GradingSettings /> permissions', () => {
144+
beforeEach(() => {
145+
jest.restoreAllMocks();
146+
const mocks = initializeMocks();
147+
Object.defineProperty(window, 'scrollTo', { value: jest.fn(), writable: true });
148+
const { axiosMock: mock } = mocks;
149+
mock.onGet(getGradingSettingsApiUrl(courseId)).reply(200, gradingSettings);
150+
mock.onPost(getGradingSettingsApiUrl(courseId)).reply(200, {});
151+
mock.onGet(getCourseSettingsApiUrl(courseId)).reply(200, {});
152+
jest.mocked(useCourseUserPermissions).mockReturnValue({
153+
isLoading: false,
154+
canViewGradingSettings: true,
155+
canEditGradingSettings: true,
156+
});
157+
});
158+
159+
it('should render normally when authz flag is disabled (no regression)', async () => {
160+
mockWaffleFlags({ enableAuthzCourseAuthoring: false });
161+
render(<RootWrapper />);
162+
expect(await screen.findAllByText(messages.headingTitle.defaultMessage)).not.toHaveLength(0);
163+
});
164+
165+
it('should render normally when user has view and edit permissions', async () => {
166+
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
167+
render(<RootWrapper />);
168+
expect(await screen.findAllByText(messages.headingTitle.defaultMessage)).not.toHaveLength(0);
169+
});
170+
171+
it('should show permission denied alert when user lacks view permission', async () => {
172+
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
173+
jest.mocked(useCourseUserPermissions).mockReturnValue({
174+
isLoading: false,
175+
canViewGradingSettings: false,
176+
canEditGradingSettings: false,
177+
});
178+
render(<RootWrapper />);
179+
expect(await screen.findByTestId('permissionDeniedAlert')).toBeInTheDocument();
180+
});
181+
182+
it('should disable inputs when user has view but not edit permission', async () => {
183+
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
184+
jest.mocked(useCourseUserPermissions).mockReturnValue({
185+
isLoading: false,
186+
canViewGradingSettings: true,
187+
canEditGradingSettings: false,
188+
});
189+
render(<RootWrapper />);
190+
const segmentInputs = await screen.findAllByTestId('grading-scale-segment-input');
191+
segmentInputs.forEach((input) => expect(input).toBeDisabled());
192+
});
193+
194+
it('should disable save button when user lacks edit permission', async () => {
195+
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
196+
jest.mocked(useCourseUserPermissions).mockReturnValue({
197+
isLoading: false,
198+
canViewGradingSettings: true,
199+
canEditGradingSettings: false,
200+
});
201+
render(<RootWrapper />);
202+
const segmentInputs = await screen.findAllByTestId('grading-scale-segment-input');
203+
// Trigger a change to show the save alert
204+
fireEvent.change(segmentInputs[1], { target: { value: 'Test' } });
205+
const saveBtn = screen.getByTestId('grading-settings-save-alert').querySelector('button[type="button"]:last-child');
206+
expect(saveBtn).toBeDisabled();
207+
});
208+
});

0 commit comments

Comments
 (0)