Skip to content

Commit 7eb55f5

Browse files
feat: adding permission validations from authz for files page for view, create, edit and delete
1 parent 36d42b0 commit 7eb55f5

17 files changed

Lines changed: 722 additions & 38 deletions

src/authz/constants.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,8 @@ export const COURSE_PERMISSIONS = {
2424
VIEW_SCHEDULE_AND_DETAILS: 'courses.view_schedule_and_details',
2525
EDIT_SCHEDULE: 'courses.edit_schedule',
2626
EDIT_DETAILS: 'courses.edit_details',
27+
VIEW_FILES: 'courses.view_files',
28+
CREATE_FILES: 'courses.create_files',
29+
DELETE_FILES: 'courses.delete_files',
30+
EDIT_FILES: 'courses.edit_files',
2731
};

src/authz/hooks.test.tsx

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import React from 'react';
2+
import { renderHook, waitFor } from '@testing-library/react';
3+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
4+
5+
import * as authzApi from '@src/authz/data/api';
6+
import { PermissionValidationQuery } from '@src/authz/types';
7+
import { mockWaffleFlags } from '@src/data/apiHooks.mock';
8+
import { useUserPermissionsWithAuthzCourse } from './hooks';
9+
10+
jest.mock('@src/data/api');
11+
jest.mock('@src/authz/data/api');
12+
13+
const mockedAuthzApi = jest.mocked(authzApi);
14+
15+
describe('useUserPermissionsWithAuthzCourse', () => {
16+
let queryClient: QueryClient;
17+
18+
const createWrapper = () =>
19+
function TestWrapper({ children }: { children: React.ReactNode; }) {
20+
return (
21+
<QueryClientProvider client={queryClient}>
22+
{children}
23+
</QueryClientProvider>
24+
);
25+
};
26+
27+
const mockPermissions: PermissionValidationQuery = {
28+
canViewFiles: {
29+
action: 'course.view_files',
30+
scope: 'course-v1:Test+101+2023',
31+
},
32+
canManageFiles: {
33+
action: 'course.manage_files',
34+
scope: 'course-v1:Test+101+2023',
35+
},
36+
};
37+
38+
beforeEach(() => {
39+
queryClient = new QueryClient({
40+
defaultOptions: {
41+
queries: { retry: false },
42+
},
43+
});
44+
jest.clearAllMocks();
45+
});
46+
47+
it('returns all permissions as true when authz is disabled', async () => {
48+
mockWaffleFlags({
49+
enableAuthzCourseAuthoring: false,
50+
});
51+
52+
const { result } = renderHook(
53+
() => useUserPermissionsWithAuthzCourse('course-v1:Test+101+2023', mockPermissions),
54+
{ wrapper: createWrapper() },
55+
);
56+
57+
await waitFor(() => {
58+
expect(result.current.isLoading).toBe(false);
59+
});
60+
61+
expect(result.current.isAuthzEnabled).toBe(false);
62+
expect(result.current.permissions.canViewFiles).toBe(true);
63+
expect(result.current.permissions.canManageFiles).toBe(true);
64+
});
65+
66+
it('returns loading state when authz is enabled and permissions are loading', async () => {
67+
mockWaffleFlags({
68+
enableAuthzCourseAuthoring: true,
69+
});
70+
71+
mockedAuthzApi.validateUserPermissions.mockImplementation(
72+
() => new Promise(() => {}),
73+
);
74+
75+
const { result } = renderHook(
76+
() => useUserPermissionsWithAuthzCourse('course-v1:Test+101+2023', mockPermissions),
77+
{ wrapper: createWrapper() },
78+
);
79+
80+
await waitFor(() => {
81+
expect(result.current.isAuthzEnabled).toBe(true);
82+
});
83+
84+
expect(result.current.isLoading).toBe(true);
85+
});
86+
87+
it('returns actual permission values when authz is enabled and permissions loaded', async () => {
88+
mockWaffleFlags({
89+
enableAuthzCourseAuthoring: true,
90+
});
91+
92+
mockedAuthzApi.validateUserPermissions.mockResolvedValue({
93+
canViewFiles: true,
94+
canManageFiles: false,
95+
});
96+
97+
const { result } = renderHook(
98+
() => useUserPermissionsWithAuthzCourse('course-v1:Test+101+2023', mockPermissions),
99+
{ wrapper: createWrapper() },
100+
);
101+
102+
await waitFor(() => {
103+
expect(result.current.isLoading).toBe(false);
104+
});
105+
106+
expect(result.current.isAuthzEnabled).toBe(true);
107+
expect(result.current.permissions.canViewFiles).toBe(true);
108+
expect(result.current.permissions.canManageFiles).toBe(false);
109+
});
110+
111+
it('falls back to false for undefined permissions when authz is enabled', async () => {
112+
mockWaffleFlags({
113+
enableAuthzCourseAuthoring: true,
114+
});
115+
116+
mockedAuthzApi.validateUserPermissions.mockResolvedValue({
117+
canViewFiles: true,
118+
});
119+
120+
const { result } = renderHook(
121+
() => useUserPermissionsWithAuthzCourse('course-v1:Test+101+2023', mockPermissions),
122+
{ wrapper: createWrapper() },
123+
);
124+
125+
await waitFor(() => {
126+
expect(result.current.isLoading).toBe(false);
127+
});
128+
129+
expect(result.current.isAuthzEnabled).toBe(true);
130+
expect(result.current.permissions.canViewFiles).toBe(true);
131+
expect(result.current.permissions.canManageFiles).toBe(false);
132+
});
133+
});

src/authz/hooks.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@ type UseCourseUserPermissionsReturn<Query extends PermissionValidationQuery> = {
77
isAuthzEnabled: boolean;
88
} & PermissionValidationAnswer<Query>;
99

10+
/**
11+
* Return type for the useUserCoursePermissionsWithAuthz hook
12+
*/
13+
interface UseUserPermissionsWithAuthzCourseReturn {
14+
/** Whether permissions are currently loading */
15+
isLoading: boolean;
16+
/** Object containing permission results with boolean values */
17+
permissions: PermissionValidationAnswer;
18+
/** Whether authorization is enabled for the course */
19+
isAuthzEnabled: boolean;
20+
}
21+
1022
/**
1123
* Custom hook to retrieve and evaluate user permissions for the current course using the openedx-authz service.
1224
*
@@ -69,3 +81,69 @@ export const useCourseUserPermissions = <Query extends PermissionValidationQuery
6981
...permissionResults as PermissionValidationAnswer<Query>,
7082
};
7183
};
84+
85+
/**
86+
* Custom hook to handle user permissions with course authorization waffle flag
87+
*
88+
* This hook abstracts the common pattern of:
89+
* 1. Checking if authz is enabled via waffle flag
90+
* 2. Fetching user permissions when authz is enabled
91+
* 3. Defaulting all permissions to true when authz is disabled
92+
* 4. Providing fallback values for undefined permissions
93+
*
94+
* @param courseId - The course ID to check permissions for
95+
* @param permissions - Object mapping permission names to their action/scope definitions
96+
* @returns Object containing loading state, permissions results, and authz status
97+
*
98+
* @example
99+
* ```tsx
100+
* const { isLoading, permissions, isAuthzEnabled } = useUserPermissionsWithAuthzCourse(
101+
* courseId,
102+
* {
103+
* canViewFiles: {
104+
* action: COURSE_PERMISSIONS.VIEW_FILES,
105+
* scope: courseId,
106+
* },
107+
* canManageFiles: {
108+
* action: COURSE_PERMISSIONS.MANAGE_FILES,
109+
* scope: courseId,
110+
* },
111+
* }
112+
* );
113+
*
114+
* const { canViewFiles, canManageFiles } = permissions;
115+
* ```
116+
*/
117+
export const useUserPermissionsWithAuthzCourse = (
118+
courseId: string,
119+
permissions: PermissionValidationQuery,
120+
): UseUserPermissionsWithAuthzCourseReturn => {
121+
const waffleFlags = useWaffleFlags(courseId);
122+
const isAuthzEnabled: boolean = waffleFlags?.enableAuthzCourseAuthoring ?? false;
123+
124+
const {
125+
isLoading: isLoadingUserPermissions,
126+
data: userPermissions,
127+
} = useUserPermissions(permissions, isAuthzEnabled);
128+
129+
// Build permission results object
130+
const permissionResults: PermissionValidationAnswer = {};
131+
132+
if (isAuthzEnabled && !isLoadingUserPermissions) {
133+
// Authz is enabled and permissions loaded, use actual permission values with fallback to false
134+
Object.keys(permissions).forEach((permissionKey: string) => {
135+
permissionResults[permissionKey] = userPermissions?.[permissionKey] ?? false;
136+
});
137+
} else if (!isLoadingUserPermissions) {
138+
// Authz is disabled or permissions still loading, default all to true
139+
Object.keys(permissions).forEach((permissionKey: string) => {
140+
permissionResults[permissionKey] = true;
141+
});
142+
}
143+
144+
return {
145+
isLoading: isAuthzEnabled ? isLoadingUserPermissions : false,
146+
permissions: permissionResults,
147+
isAuthzEnabled,
148+
};
149+
};
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { getFilesPermissions, getGradingPermissions } from './permissionHelpers';
2+
import { COURSE_PERMISSIONS } from './constants';
3+
4+
describe('permissionHelpers', () => {
5+
const mockCourseId = 'course-v1:edX+DemoX+Demo_Course';
6+
7+
describe('getFilesPermissions', () => {
8+
it('should return correct permission structure for file operations', () => {
9+
const result = getFilesPermissions(mockCourseId);
10+
11+
expect(result).toEqual({
12+
canViewFiles: {
13+
action: COURSE_PERMISSIONS.VIEW_FILES,
14+
scope: mockCourseId,
15+
},
16+
canCreateFiles: {
17+
action: COURSE_PERMISSIONS.CREATE_FILES,
18+
scope: mockCourseId,
19+
},
20+
canDeleteFiles: {
21+
action: COURSE_PERMISSIONS.DELETE_FILES,
22+
scope: mockCourseId,
23+
},
24+
canEditFiles: {
25+
action: COURSE_PERMISSIONS.EDIT_FILES,
26+
scope: mockCourseId,
27+
},
28+
});
29+
});
30+
31+
it('should use the provided courseId as scope for all permissions', () => {
32+
const customCourseId = 'course-v1:TestOrg+TestCourse+2024';
33+
const result = getFilesPermissions(customCourseId);
34+
35+
Object.values(result).forEach(permission => {
36+
expect(permission.scope).toBe(customCourseId);
37+
});
38+
});
39+
40+
it('should use correct COURSE_PERMISSIONS constants for each action', () => {
41+
const result = getFilesPermissions(mockCourseId);
42+
43+
expect(result.canViewFiles.action).toBe(COURSE_PERMISSIONS.VIEW_FILES);
44+
expect(result.canCreateFiles.action).toBe(COURSE_PERMISSIONS.CREATE_FILES);
45+
expect(result.canDeleteFiles.action).toBe(COURSE_PERMISSIONS.DELETE_FILES);
46+
expect(result.canEditFiles.action).toBe(COURSE_PERMISSIONS.EDIT_FILES);
47+
});
48+
});
49+
50+
describe('getGradingPermissions', () => {
51+
it('should return correct permission structure for grading operations', () => {
52+
const result = getGradingPermissions(mockCourseId);
53+
54+
expect(result).toEqual({
55+
canViewGradingSettings: {
56+
action: COURSE_PERMISSIONS.VIEW_GRADING_SETTINGS,
57+
scope: mockCourseId,
58+
},
59+
canEditGradingSettings: {
60+
action: COURSE_PERMISSIONS.EDIT_GRADING_SETTINGS,
61+
scope: mockCourseId,
62+
},
63+
});
64+
});
65+
66+
it('should use the provided courseId as scope for all permissions', () => {
67+
const customCourseId = 'course-v1:TestOrg+TestCourse+2024';
68+
const result = getGradingPermissions(customCourseId);
69+
70+
Object.values(result).forEach(permission => {
71+
expect(permission.scope).toBe(customCourseId);
72+
});
73+
});
74+
75+
it('should use correct COURSE_PERMISSIONS constants for each action', () => {
76+
const result = getGradingPermissions(mockCourseId);
77+
78+
expect(result.canViewGradingSettings.action).toBe(COURSE_PERMISSIONS.VIEW_GRADING_SETTINGS);
79+
expect(result.canEditGradingSettings.action).toBe(COURSE_PERMISSIONS.EDIT_GRADING_SETTINGS);
80+
});
81+
});
82+
});

src/authz/permissionHelpers.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,22 @@ export const getGradingPermissions = (courseId: string) => ({
2525
scope: courseId,
2626
},
2727
});
28+
29+
export const getFilesPermissions = (courseId: string) => ({
30+
canViewFiles: {
31+
action: COURSE_PERMISSIONS.VIEW_FILES,
32+
scope: courseId,
33+
},
34+
canCreateFiles: {
35+
action: COURSE_PERMISSIONS.CREATE_FILES,
36+
scope: courseId,
37+
},
38+
canDeleteFiles: {
39+
action: COURSE_PERMISSIONS.DELETE_FILES,
40+
scope: courseId,
41+
},
42+
canEditFiles: {
43+
action: COURSE_PERMISSIONS.EDIT_FILES,
44+
scope: courseId,
45+
},
46+
});

src/files-and-videos/files-page/CourseFilesTable.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import { getFileSizeToClosestByte } from '@src/utils';
2828
import React from 'react';
2929
import { useDispatch, useSelector } from 'react-redux';
3030
import { useParams } from 'react-router-dom';
31+
import { useUserPermissionsWithAuthzCourse } from '@src/authz/hooks';
32+
import { getFilesPermissions } from '@src/authz/permissionHelpers';
3133

3234
export const CourseFilesTable = () => {
3335
const intl = useIntl();
@@ -40,6 +42,10 @@ export const CourseFilesTable = () => {
4042
errors: errorMessages,
4143
} = useSelector((state: DeprecatedReduxState) => state.assets);
4244

45+
const {
46+
permissions,
47+
} = useUserPermissionsWithAuthzCourse(courseId, getFilesPermissions(courseId));
48+
4349
const handleErrorReset = (error) => dispatch(resetErrors(error));
4450
const handleDeleteFile = (id) => dispatch(deleteAssetFile(courseId, id));
4551
const handleDownloadFile = (selectedRows) =>
@@ -69,6 +75,7 @@ export const CourseFilesTable = () => {
6975
FileInfoModalSidebar({
7076
asset,
7177
handleLockedAsset: handleLockFile,
78+
canLockFile: permissions.canEditFiles,
7279
});
7380

7481
const assets = useModels('assets', assetIds);
@@ -180,6 +187,11 @@ export const CourseFilesTable = () => {
180187
thumbnailPreview,
181188
infoModalSidebar,
182189
files: assets,
190+
permissions: {
191+
canCreateFiles: permissions.canCreateFiles,
192+
canDeleteFiles: permissions.canDeleteFiles,
193+
canEditFiles: permissions.canEditFiles,
194+
},
183195
}}
184196
/>
185197
<FileValidationModal {...{ handleFileOverwrite }} />

0 commit comments

Comments
 (0)