Skip to content

Commit 4839aab

Browse files
feat: adding permission validations from authz for files page for view, create, edit and delete
1 parent 448fcad commit 4839aab

17 files changed

Lines changed: 663 additions & 46 deletions

src/authz/constants.ts

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

1818
export const COURSE_PERMISSIONS = {
1919
MANAGE_ADVANCED_SETTINGS: 'courses.manage_advanced_settings',
20+
21+
VIEW_FILES: 'courses.view_files',
22+
CREATE_FILES: 'courses.create_files',
23+
DELETE_FILES: 'courses.delete_files',
24+
EDIT_FILES: 'courses.edit_files',
2025
};

src/authz/hooks.test.tsx

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

src/authz/hooks.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// src/authz/hooks/useUserPermissionsWithAuthz.ts
2+
import { useWaffleFlags } from '@src/data/apiHooks';
3+
import { useUserPermissions } from '@src/authz/data/apiHooks';
4+
import { PermissionValidationQuery, PermissionValidationAnswer } from '@src/authz/types';
5+
6+
/**
7+
* Return type for the useUserCoursePermissionsWithAuthz hook
8+
*/
9+
interface UseUserPermissionsWithAuthzCourseReturn {
10+
/** Whether permissions are currently loading */
11+
isLoading: boolean;
12+
/** Object containing permission results with boolean values */
13+
permissions: PermissionValidationAnswer;
14+
/** Whether authorization is enabled for the course */
15+
isAuthzEnabled: boolean;
16+
}
17+
18+
/**
19+
* Custom hook to handle user permissions with course authorization waffle flag
20+
*
21+
* This hook abstracts the common pattern of:
22+
* 1. Checking if authz is enabled via waffle flag
23+
* 2. Fetching user permissions when authz is enabled
24+
* 3. Defaulting all permissions to true when authz is disabled
25+
* 4. Providing fallback values for undefined permissions
26+
*
27+
* @param courseId - The course ID to check permissions for
28+
* @param permissions - Object mapping permission names to their action/scope definitions
29+
* @returns Object containing loading state, permissions results, and authz status
30+
*
31+
* @example
32+
* ```tsx
33+
* const { isLoading, permissions, isAuthzEnabled } = useUserPermissionsWithAuthzCourse(
34+
* courseId,
35+
* {
36+
* canViewFiles: {
37+
* action: COURSE_PERMISSIONS.VIEW_FILES,
38+
* scope: courseId,
39+
* },
40+
* canManageFiles: {
41+
* action: COURSE_PERMISSIONS.MANAGE_FILES,
42+
* scope: courseId,
43+
* },
44+
* }
45+
* );
46+
*
47+
* const { canViewFiles, canManageFiles } = permissions;
48+
* ```
49+
*/
50+
export const useUserPermissionsWithAuthzCourse = (
51+
courseId: string,
52+
permissions: PermissionValidationQuery,
53+
): UseUserPermissionsWithAuthzCourseReturn => {
54+
const waffleFlags = useWaffleFlags(courseId);
55+
const isAuthzEnabled: boolean = waffleFlags?.enableAuthzCourseAuthoring ?? false;
56+
57+
const {
58+
isLoading: isLoadingUserPermissions,
59+
data: userPermissions,
60+
} = useUserPermissions(permissions, isAuthzEnabled);
61+
62+
// Build permission results object
63+
const permissionResults: PermissionValidationAnswer = {};
64+
65+
if (isAuthzEnabled && !isLoadingUserPermissions) {
66+
// Authz is enabled and permissions loaded, use actual permission values with fallback to false
67+
Object.keys(permissions).forEach((permissionKey: string) => {
68+
permissionResults[permissionKey] = userPermissions?.[permissionKey] ?? false;
69+
});
70+
} else if (!isLoadingUserPermissions) {
71+
// Authz is disabled or permissions still loading, default all to true
72+
Object.keys(permissions).forEach((permissionKey: string) => {
73+
permissionResults[permissionKey] = true;
74+
});
75+
}
76+
77+
return {
78+
isLoading: isAuthzEnabled ? isLoadingUserPermissions : false,
79+
permissions: permissionResults,
80+
isAuthzEnabled,
81+
};
82+
};
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { getFilesPermissions } from './permissionHelpers';
2+
import { COURSE_PERMISSIONS } from './constants';
3+
4+
describe('permissionHelpers', () => {
5+
describe('getFilesPermissions', () => {
6+
const mockCourseId = 'course-v1:edX+DemoX+Demo_Course';
7+
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+
});

src/authz/permissionHelpers.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { COURSE_PERMISSIONS } from './constants';
2+
3+
export const getFilesPermissions = (courseId: string) => ({
4+
canViewFiles: {
5+
action: COURSE_PERMISSIONS.VIEW_FILES,
6+
scope: courseId,
7+
},
8+
canCreateFiles: {
9+
action: COURSE_PERMISSIONS.CREATE_FILES,
10+
scope: courseId,
11+
},
12+
canDeleteFiles: {
13+
action: COURSE_PERMISSIONS.DELETE_FILES,
14+
scope: courseId,
15+
},
16+
canEditFiles: {
17+
action: COURSE_PERMISSIONS.EDIT_FILES,
18+
scope: courseId,
19+
},
20+
});

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) => dispatch(fetchAssetDownload({
@@ -67,6 +73,7 @@ export const CourseFilesTable = () => {
6773
const infoModalSidebar = (asset) => FileInfoModalSidebar({
6874
asset,
6975
handleLockedAsset: handleLockFile,
76+
canLockFile: permissions.canEditFiles,
7077
});
7178

7279
const assets = useModels('assets', assetIds);
@@ -178,6 +185,11 @@ export const CourseFilesTable = () => {
178185
thumbnailPreview,
179186
infoModalSidebar,
180187
files: assets,
188+
permissions: {
189+
canCreateFiles: permissions.canCreateFiles,
190+
canDeleteFiles: permissions.canDeleteFiles,
191+
canEditFiles: permissions.canEditFiles,
192+
},
181193
}}
182194
/>
183195
<FileValidationModal {...{ handleFileOverwrite }} />

src/files-and-videos/files-page/FileInfoModalSidebar.jsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import './FileInfoModalSidebar.scss';
1919
const FileInfoModalSidebar = ({
2020
asset,
2121
handleLockedAsset,
22+
canLockFile = true,
2223
}) => {
2324
const intl = useIntl();
2425
const [lockedState, setLockedState] = useState(asset?.locked);
@@ -93,6 +94,7 @@ const FileInfoModalSidebar = ({
9394
/>
9495
<ActionRow.Spacer />
9596
<CheckboxControl
97+
disabled={!canLockFile}
9698
checked={lockedState}
9799
onChange={handleLock}
98100
aria-label="Checkbox"
@@ -115,6 +117,7 @@ FileInfoModalSidebar.propTypes = {
115117
usageLocations: PropTypes.arrayOf(PropTypes.string),
116118
}).isRequired,
117119
handleLockedAsset: PropTypes.func.isRequired,
120+
canLockFile: PropTypes.bool,
118121
};
119122

120123
export default FileInfoModalSidebar;

src/files-and-videos/files-page/FilesPage.jsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ import EditFileAlertsSlot from '@src/plugin-slots/EditFileAlertsSlot';
1313
import { AlertAgreementGatedFeature } from '@src/generic/agreement-gated-feature';
1414
import { AgreementGated } from '@src/constants';
1515

16+
import { useUserPermissionsWithAuthzCourse } from '@src/authz/hooks';
17+
import { getFilesPermissions } from '@src/authz/permissionHelpers';
18+
import PermissionDeniedAlert from '@src/generic/PermissionDeniedAlert';
1619
import { EditFileErrors } from '../generic';
1720
import { fetchAssets, resetErrors } from './data/thunks';
1821
import FilesPageProvider from './FilesPageProvider';
@@ -32,10 +35,23 @@ const FilesPage = () => {
3235
errors: errorMessages,
3336
} = useSelector(state => state.assets);
3437

38+
const {
39+
isLoading: isLoadingPermissions,
40+
permissions,
41+
} = useUserPermissionsWithAuthzCourse(courseId, getFilesPermissions(courseId));
42+
43+
const {
44+
canViewFiles,
45+
} = permissions;
46+
3547
useEffect(() => {
3648
dispatch(fetchAssets(courseId));
3749
}, [courseId]);
3850

51+
if (!isLoadingPermissions && !canViewFiles) {
52+
return <PermissionDeniedAlert />;
53+
}
54+
3955
const handleErrorReset = (error) => dispatch(resetErrors(error));
4056

4157
if (loadingStatus === RequestStatus.DENIED) {

0 commit comments

Comments
 (0)