Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/authz/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,9 @@ export const CONTENT_LIBRARY_PERMISSIONS = {

export const COURSE_PERMISSIONS = {
MANAGE_ADVANCED_SETTINGS: 'courses.manage_advanced_settings',

VIEW_FILES: 'courses.view_files',
CREATE_FILES: 'courses.create_files',
DELETE_FILES: 'courses.delete_files',
EDIT_FILES: 'courses.edit_files',
};
132 changes: 132 additions & 0 deletions src/authz/hooks.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import React from 'react';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

import * as authzApi from '@src/authz/data/api';
import { PermissionValidationQuery } from '@src/authz/types';
import { mockWaffleFlags } from '@src/data/apiHooks.mock';
import { useUserPermissionsWithAuthzCourse } from './hooks';

jest.mock('@src/data/api');
jest.mock('@src/authz/data/api');

const mockedAuthzApi = jest.mocked(authzApi);

describe('useUserPermissionsWithAuthzCourse', () => {
let queryClient: QueryClient;

const createWrapper = () => function TestWrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
};

const mockPermissions: PermissionValidationQuery = {
canViewFiles: {
action: 'course.view_files',
scope: 'course-v1:Test+101+2023',
},
canManageFiles: {
action: 'course.manage_files',
scope: 'course-v1:Test+101+2023',
},
};

beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
jest.clearAllMocks();
});

it('returns all permissions as true when authz is disabled', async () => {
mockWaffleFlags({
enableAuthzCourseAuthoring: false,
});

const { result } = renderHook(
() => useUserPermissionsWithAuthzCourse('course-v1:Test+101+2023', mockPermissions),
{ wrapper: createWrapper() },
);

await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});

expect(result.current.isAuthzEnabled).toBe(false);
expect(result.current.permissions.canViewFiles).toBe(true);
expect(result.current.permissions.canManageFiles).toBe(true);
});

it('returns loading state when authz is enabled and permissions are loading', async () => {
mockWaffleFlags({
enableAuthzCourseAuthoring: true,
});

mockedAuthzApi.validateUserPermissions.mockImplementation(
() => new Promise(() => {}),
);

const { result } = renderHook(
() => useUserPermissionsWithAuthzCourse('course-v1:Test+101+2023', mockPermissions),
{ wrapper: createWrapper() },
);

await waitFor(() => {
expect(result.current.isAuthzEnabled).toBe(true);
});

expect(result.current.isLoading).toBe(true);
});

it('returns actual permission values when authz is enabled and permissions loaded', async () => {
mockWaffleFlags({
enableAuthzCourseAuthoring: true,
});

mockedAuthzApi.validateUserPermissions.mockResolvedValue({
canViewFiles: true,
canManageFiles: false,
});

const { result } = renderHook(
() => useUserPermissionsWithAuthzCourse('course-v1:Test+101+2023', mockPermissions),
{ wrapper: createWrapper() },
);

await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});

expect(result.current.isAuthzEnabled).toBe(true);
expect(result.current.permissions.canViewFiles).toBe(true);
expect(result.current.permissions.canManageFiles).toBe(false);
});

it('falls back to false for undefined permissions when authz is enabled', async () => {
mockWaffleFlags({
enableAuthzCourseAuthoring: true,
});

mockedAuthzApi.validateUserPermissions.mockResolvedValue({
canViewFiles: true,
});

const { result } = renderHook(
() => useUserPermissionsWithAuthzCourse('course-v1:Test+101+2023', mockPermissions),
{ wrapper: createWrapper() },
);

await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});

expect(result.current.isAuthzEnabled).toBe(true);
expect(result.current.permissions.canViewFiles).toBe(true);
expect(result.current.permissions.canManageFiles).toBe(false);
});
});
82 changes: 82 additions & 0 deletions src/authz/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// src/authz/hooks/useUserPermissionsWithAuthz.ts
import { useWaffleFlags } from '@src/data/apiHooks';
import { useUserPermissions } from '@src/authz/data/apiHooks';
import { PermissionValidationQuery, PermissionValidationAnswer } from '@src/authz/types';

/**
* Return type for the useUserCoursePermissionsWithAuthz hook
*/
interface UseUserPermissionsWithAuthzCourseReturn {
/** Whether permissions are currently loading */
isLoading: boolean;
/** Object containing permission results with boolean values */
permissions: PermissionValidationAnswer;
/** Whether authorization is enabled for the course */
isAuthzEnabled: boolean;
}

/**
* Custom hook to handle user permissions with course authorization waffle flag
*
* This hook abstracts the common pattern of:
* 1. Checking if authz is enabled via waffle flag
* 2. Fetching user permissions when authz is enabled
* 3. Defaulting all permissions to true when authz is disabled
* 4. Providing fallback values for undefined permissions
*
* @param courseId - The course ID to check permissions for
* @param permissions - Object mapping permission names to their action/scope definitions
* @returns Object containing loading state, permissions results, and authz status
*
* @example
* ```tsx
* const { isLoading, permissions, isAuthzEnabled } = useUserPermissionsWithAuthzCourse(
* courseId,
* {
* canViewFiles: {
* action: COURSE_PERMISSIONS.VIEW_FILES,
* scope: courseId,
* },
* canManageFiles: {
* action: COURSE_PERMISSIONS.MANAGE_FILES,
* scope: courseId,
* },
* }
* );
*
* const { canViewFiles, canManageFiles } = permissions;
* ```
*/
export const useUserPermissionsWithAuthzCourse = (
courseId: string,
permissions: PermissionValidationQuery,
): UseUserPermissionsWithAuthzCourseReturn => {
const waffleFlags = useWaffleFlags(courseId);
const isAuthzEnabled: boolean = waffleFlags?.enableAuthzCourseAuthoring ?? false;

const {
isLoading: isLoadingUserPermissions,
data: userPermissions,
} = useUserPermissions(permissions, isAuthzEnabled);

// Build permission results object
const permissionResults: PermissionValidationAnswer = {};

if (isAuthzEnabled && !isLoadingUserPermissions) {
// Authz is enabled and permissions loaded, use actual permission values with fallback to false
Object.keys(permissions).forEach((permissionKey: string) => {
permissionResults[permissionKey] = userPermissions?.[permissionKey] ?? false;
});
} else if (!isLoadingUserPermissions) {
// Authz is disabled or permissions still loading, default all to true
Object.keys(permissions).forEach((permissionKey: string) => {
permissionResults[permissionKey] = true;
});
}

return {
isLoading: isAuthzEnabled ? isLoadingUserPermissions : false,
permissions: permissionResults,
isAuthzEnabled,
};
};
49 changes: 49 additions & 0 deletions src/authz/permissionHelpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { getFilesPermissions } from './permissionHelpers';
import { COURSE_PERMISSIONS } from './constants';

describe('permissionHelpers', () => {
describe('getFilesPermissions', () => {
const mockCourseId = 'course-v1:edX+DemoX+Demo_Course';

it('should return correct permission structure for file operations', () => {
const result = getFilesPermissions(mockCourseId);

expect(result).toEqual({
canViewFiles: {
action: COURSE_PERMISSIONS.VIEW_FILES,
scope: mockCourseId,
},
canCreateFiles: {
action: COURSE_PERMISSIONS.CREATE_FILES,
scope: mockCourseId,
},
canDeleteFiles: {
action: COURSE_PERMISSIONS.DELETE_FILES,
scope: mockCourseId,
},
canEditFiles: {
action: COURSE_PERMISSIONS.EDIT_FILES,
scope: mockCourseId,
},
});
});

it('should use the provided courseId as scope for all permissions', () => {
const customCourseId = 'course-v1:TestOrg+TestCourse+2024';
const result = getFilesPermissions(customCourseId);

Object.values(result).forEach(permission => {
expect(permission.scope).toBe(customCourseId);
});
});

it('should use correct COURSE_PERMISSIONS constants for each action', () => {
const result = getFilesPermissions(mockCourseId);

expect(result.canViewFiles.action).toBe(COURSE_PERMISSIONS.VIEW_FILES);
expect(result.canCreateFiles.action).toBe(COURSE_PERMISSIONS.CREATE_FILES);
expect(result.canDeleteFiles.action).toBe(COURSE_PERMISSIONS.DELETE_FILES);
expect(result.canEditFiles.action).toBe(COURSE_PERMISSIONS.EDIT_FILES);
});
});
});
20 changes: 20 additions & 0 deletions src/authz/permissionHelpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { COURSE_PERMISSIONS } from './constants';

export const getFilesPermissions = (courseId: string) => ({
canViewFiles: {
action: COURSE_PERMISSIONS.VIEW_FILES,
scope: courseId,
},
canCreateFiles: {
action: COURSE_PERMISSIONS.CREATE_FILES,
scope: courseId,
},
canDeleteFiles: {
action: COURSE_PERMISSIONS.DELETE_FILES,
scope: courseId,
},
canEditFiles: {
action: COURSE_PERMISSIONS.EDIT_FILES,
scope: courseId,
},
});
12 changes: 12 additions & 0 deletions src/files-and-videos/files-page/CourseFilesTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router-dom';
import { UPLOAD_FILE_MAX_SIZE } from '@src/constants';
import { useUserPermissionsWithAuthzCourse } from '@src/authz/hooks';
import { getFilesPermissions } from '@src/authz/permissionHelpers';

export const CourseFilesTable = () => {
const intl = useIntl();
Expand All @@ -39,6 +41,10 @@ export const CourseFilesTable = () => {
errors: errorMessages,
} = useSelector((state: DeprecatedReduxState) => state.assets);

const {
permissions,
} = useUserPermissionsWithAuthzCourse(courseId, getFilesPermissions(courseId));

const handleErrorReset = (error) => dispatch(resetErrors(error));
const handleDeleteFile = (id) => dispatch(deleteAssetFile(courseId, id));
const handleDownloadFile = (selectedRows) => dispatch(fetchAssetDownload({
Expand Down Expand Up @@ -66,6 +72,7 @@ export const CourseFilesTable = () => {
const infoModalSidebar = (asset) => FileInfoModalSidebar({
asset,
handleLockedAsset: handleLockFile,
canLockFile: permissions.canEditFiles,
});

const assets = useModels('assets', assetIds);
Expand Down Expand Up @@ -176,6 +183,11 @@ export const CourseFilesTable = () => {
thumbnailPreview,
infoModalSidebar,
files: assets,
permissions: {
canCreateFiles: permissions.canCreateFiles,
canDeleteFiles: permissions.canDeleteFiles,
canEditFiles: permissions.canEditFiles,
},
}}
/>
<FileValidationModal {...{ handleFileOverwrite }} />
Expand Down
3 changes: 3 additions & 0 deletions src/files-and-videos/files-page/FileInfoModalSidebar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import './FileInfoModalSidebar.scss';
const FileInfoModalSidebar = ({
asset,
handleLockedAsset,
canLockFile = true,
}) => {
const intl = useIntl();
const [lockedState, setLockedState] = useState(asset?.locked);
Expand Down Expand Up @@ -93,6 +94,7 @@ const FileInfoModalSidebar = ({
/>
<ActionRow.Spacer />
<CheckboxControl
disabled={!canLockFile}
checked={lockedState}
onChange={handleLock}
aria-label="Checkbox"
Expand All @@ -115,6 +117,7 @@ FileInfoModalSidebar.propTypes = {
usageLocations: PropTypes.arrayOf(PropTypes.string),
}).isRequired,
handleLockedAsset: PropTypes.func.isRequired,
canLockFile: PropTypes.bool,
};

export default FileInfoModalSidebar;
17 changes: 16 additions & 1 deletion src/files-and-videos/files-page/FilesPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import Placeholder from '@src/editors/Placeholder';
import { RequestStatus } from '@src/data/constants';
import getPageHeadTitle from '@src/generic/utils';
import EditFileAlertsSlot from '@src/plugin-slots/EditFileAlertsSlot';

import { useUserPermissionsWithAuthzCourse } from '@src/authz/hooks';
import { getFilesPermissions } from '@src/authz/permissionHelpers';
import PermissionDeniedAlert from '@src/generic/PermissionDeniedAlert';
import { EditFileErrors } from '../generic';
import { fetchAssets, resetErrors } from './data/thunks';
import FilesPageProvider from './FilesPageProvider';
Expand All @@ -30,10 +32,23 @@ const FilesPage = () => {
errors: errorMessages,
} = useSelector(state => state.assets);

const {
isLoading: isLoadingPermissions,
permissions,
} = useUserPermissionsWithAuthzCourse(courseId, getFilesPermissions(courseId));

const {
canViewFiles,
} = permissions;

useEffect(() => {
dispatch(fetchAssets(courseId));
}, [courseId]);

if (!isLoadingPermissions && !canViewFiles) {
return <PermissionDeniedAlert />;
}

const handleErrorReset = (error) => dispatch(resetErrors(error));

if (loadingStatus === RequestStatus.DENIED) {
Expand Down
Loading
Loading