diff --git a/src/authz/constants.ts b/src/authz/constants.ts
index 88d4b4da58..be4e08446f 100644
--- a/src/authz/constants.ts
+++ b/src/authz/constants.ts
@@ -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',
};
diff --git a/src/authz/hooks.test.tsx b/src/authz/hooks.test.tsx
new file mode 100644
index 0000000000..b871e82ea9
--- /dev/null
+++ b/src/authz/hooks.test.tsx
@@ -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 (
+
+ {children}
+
+ );
+ };
+
+ 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);
+ });
+});
diff --git a/src/authz/hooks.ts b/src/authz/hooks.ts
new file mode 100644
index 0000000000..18c57b234d
--- /dev/null
+++ b/src/authz/hooks.ts
@@ -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,
+ };
+};
diff --git a/src/authz/permissionHelpers.test.ts b/src/authz/permissionHelpers.test.ts
new file mode 100644
index 0000000000..38b4ccba30
--- /dev/null
+++ b/src/authz/permissionHelpers.test.ts
@@ -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);
+ });
+ });
+});
diff --git a/src/authz/permissionHelpers.ts b/src/authz/permissionHelpers.ts
new file mode 100644
index 0000000000..a7c0bf00a0
--- /dev/null
+++ b/src/authz/permissionHelpers.ts
@@ -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,
+ },
+});
diff --git a/src/files-and-videos/files-page/CourseFilesTable.tsx b/src/files-and-videos/files-page/CourseFilesTable.tsx
index d71b90476e..a362c19fab 100644
--- a/src/files-and-videos/files-page/CourseFilesTable.tsx
+++ b/src/files-and-videos/files-page/CourseFilesTable.tsx
@@ -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();
@@ -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({
@@ -66,6 +72,7 @@ export const CourseFilesTable = () => {
const infoModalSidebar = (asset) => FileInfoModalSidebar({
asset,
handleLockedAsset: handleLockFile,
+ canLockFile: permissions.canEditFiles,
});
const assets = useModels('assets', assetIds);
@@ -176,6 +183,11 @@ export const CourseFilesTable = () => {
thumbnailPreview,
infoModalSidebar,
files: assets,
+ permissions: {
+ canCreateFiles: permissions.canCreateFiles,
+ canDeleteFiles: permissions.canDeleteFiles,
+ canEditFiles: permissions.canEditFiles,
+ },
}}
/>
diff --git a/src/files-and-videos/files-page/FileInfoModalSidebar.jsx b/src/files-and-videos/files-page/FileInfoModalSidebar.jsx
index 4c0062bce1..c58d145155 100644
--- a/src/files-and-videos/files-page/FileInfoModalSidebar.jsx
+++ b/src/files-and-videos/files-page/FileInfoModalSidebar.jsx
@@ -19,6 +19,7 @@ import './FileInfoModalSidebar.scss';
const FileInfoModalSidebar = ({
asset,
handleLockedAsset,
+ canLockFile = true,
}) => {
const intl = useIntl();
const [lockedState, setLockedState] = useState(asset?.locked);
@@ -93,6 +94,7 @@ const FileInfoModalSidebar = ({
/>
{
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 ;
+ }
+
const handleErrorReset = (error) => dispatch(resetErrors(error));
if (loadingStatus === RequestStatus.DENIED) {
diff --git a/src/files-and-videos/files-page/FilesPage.test.jsx b/src/files-and-videos/files-page/FilesPage.test.jsx
index 7dbe06474d..2fd22655ba 100644
--- a/src/files-and-videos/files-page/FilesPage.test.jsx
+++ b/src/files-and-videos/files-page/FilesPage.test.jsx
@@ -13,6 +13,7 @@ import {
initializeMocks,
} from '@src/testUtils';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
+import { useUserPermissionsWithAuthzCourse } from '@src/authz/hooks';
import { executeThunk } from '@src/utils';
import { RequestStatus } from '@src/data/constants';
import FilesPage from './FilesPage';
@@ -45,6 +46,18 @@ let file;
ReactDOM.createPortal = jest.fn(node => node);
jest.mock('file-saver');
+jest.mock('@src/authz/hooks', () => ({
+ useUserPermissionsWithAuthzCourse: jest.fn().mockReturnValue({
+ isLoading: false,
+ permissions: {
+ canViewFiles: true,
+ canEditFiles: true,
+ canDeleteFiles: true,
+ canCreateFiles: true,
+ },
+ }),
+}));
+
const renderComponent = () => {
render(
@@ -684,4 +697,104 @@ describe('FilesAndUploads', () => {
});
});
});
+
+ describe('permissions', () => {
+ beforeEach(() => {
+ const mocks = initializeMocks({
+ initialState: {
+ ...initialState,
+ assets: {
+ ...initialState.assets,
+ assetIds: [],
+ },
+ models: {},
+ },
+ });
+ store = mocks.reduxStore;
+ axiosMock = mocks.axiosMock;
+ file = new File(['(⌐□_□)'], 'download.png', { type: 'image/png' });
+ global.localStorage.clear();
+ });
+ it('should render permission alert when the user is not authorized to view files', async () => {
+ useUserPermissionsWithAuthzCourse.mockReturnValue({
+ isLoading: false,
+ permissions: {
+ canViewFiles: false,
+ canEditFiles: true,
+ canDeleteFiles: true,
+ canCreateFiles: true,
+ },
+ });
+ await mockStore(RequestStatus.SUCCESSFUL);
+ expect(await screen.findByText(/You are not authorized to view this page/)).toBeInTheDocument();
+ });
+
+ it('should not render dropzone when is not authorized to create files', async () => {
+ useUserPermissionsWithAuthzCourse.mockReturnValue({
+ isLoading: false,
+ permissions: {
+ canViewFiles: true,
+ canEditFiles: true,
+ canDeleteFiles: true,
+ canCreateFiles: false,
+ },
+ });
+ await emptyMockStore(RequestStatus.SUCCESSFUL);
+
+ expect(screen.queryByTestId('files-dropzone')).toBeNull();
+ });
+
+ it('should render dropzone when is authorized to create files', async () => {
+ useUserPermissionsWithAuthzCourse.mockReturnValue({
+ isLoading: false,
+ permissions: {
+ canViewFiles: true,
+ canEditFiles: true,
+ canDeleteFiles: true,
+ canCreateFiles: true,
+ },
+ });
+ await emptyMockStore(RequestStatus.SUCCESSFUL);
+
+ expect(screen.queryByTestId('files-dropzone')).toBeInTheDocument();
+ });
+
+ it('should not render delete item when is not authorized to delete files', async () => {
+ const user = userEvent.setup();
+ useUserPermissionsWithAuthzCourse.mockReturnValue({
+ isLoading: false,
+ permissions: {
+ canViewFiles: true,
+ canEditFiles: true,
+ canDeleteFiles: false,
+ canCreateFiles: true,
+ },
+ });
+ await mockStore(RequestStatus.SUCCESSFUL);
+
+ const actionsButton = screen.getByText(messages.actionsButtonLabel.defaultMessage);
+ await user.click(actionsButton);
+
+ expect(screen.queryByText(messages.deleteTitle.defaultMessage)).toBeNull();
+ });
+
+ it('should render delete item when is authorized to delete files', async () => {
+ const user = userEvent.setup();
+ useUserPermissionsWithAuthzCourse.mockReturnValue({
+ isLoading: false,
+ permissions: {
+ canViewFiles: true,
+ canEditFiles: true,
+ canDeleteFiles: true,
+ canCreateFiles: true,
+ },
+ });
+ await mockStore(RequestStatus.SUCCESSFUL);
+
+ const actionsButton = screen.getByText(messages.actionsButtonLabel.defaultMessage);
+ await user.click(actionsButton);
+
+ expect(screen.queryByText(messages.deleteTitle.defaultMessage)).toBeInTheDocument();
+ });
+ });
});
diff --git a/src/files-and-videos/generic/FileMenu.jsx b/src/files-and-videos/generic/FileMenu.jsx
index c0a4924aa2..aeda123f11 100644
--- a/src/files-and-videos/generic/FileMenu.jsx
+++ b/src/files-and-videos/generic/FileMenu.jsx
@@ -19,6 +19,10 @@ const FileMenu = ({
portableUrl,
id,
fileType,
+ permissions = {
+ canEditFiles: true,
+ canDeleteFiles: true,
+ },
}) => {
const intl = useIntl();
return (
@@ -50,9 +54,11 @@ const FileMenu = ({
>
{intl.formatMessage(messages.copyWebUrlTitle)}
-
- {locked ? intl.formatMessage(messages.unlockMenuTitle) : intl.formatMessage(messages.lockMenuTitle)}
-
+ {permissions.canEditFiles && (
+
+ {locked ? intl.formatMessage(messages.unlockMenuTitle) : intl.formatMessage(messages.lockMenuTitle)}
+
+ )}
>
)}
@@ -61,13 +67,17 @@ const FileMenu = ({
{intl.formatMessage(messages.infoTitle)}
-
-
- {intl.formatMessage(messages.deleteTitle)}
-
+ {permissions.canDeleteFiles && (
+ <>
+
+
+ {intl.formatMessage(messages.deleteTitle)}
+
+ >
+ )}
);
@@ -83,6 +93,10 @@ FileMenu.propTypes = {
portableUrl: PropTypes.string,
id: PropTypes.string.isRequired,
fileType: PropTypes.string.isRequired,
+ permissions: PropTypes.shape({
+ canEditFiles: PropTypes.bool,
+ canDeleteFiles: PropTypes.bool,
+ }),
};
FileMenu.defaultProps = {
diff --git a/src/files-and-videos/generic/FileTable.jsx b/src/files-and-videos/generic/FileTable.jsx
index 78e676fa88..d681da071a 100644
--- a/src/files-and-videos/generic/FileTable.jsx
+++ b/src/files-and-videos/generic/FileTable.jsx
@@ -42,6 +42,11 @@ const FileTable = ({
maxFileSize,
thumbnailPreview,
infoModalSidebar,
+ permissions = {
+ canCreateFiles: true,
+ canDeleteFiles: true,
+ canEditFiles: true,
+ },
}) => {
const intl = useIntl();
const pageCount = Math.ceil(files.length / 50);
@@ -152,6 +157,10 @@ const FileTable = ({
fileType,
setInitialState,
}}
+ permissions={{
+ canCreateFiles: permissions.canCreateFiles,
+ canDeleteFiles: permissions.canDeleteFiles,
+ }}
/>
);
@@ -166,6 +175,10 @@ const FileTable = ({
className,
original,
fileType,
+ permissions: {
+ canEditFiles: permissions.canEditFiles,
+ canDeleteFiles: permissions.canDeleteFiles,
+ },
}}
/>
);
@@ -180,6 +193,10 @@ const FileTable = ({
handleOpenFileInfo,
handleOpenDeleteConfirmation,
fileType,
+ permissions: {
+ canEditFiles: permissions.canEditFiles,
+ caneDeleteFiles: permissions.canDeleteFiles,
+ },
}),
};
@@ -222,7 +239,7 @@ const FileTable = ({
FilterStatusComponent={FilterStatus}
RowStatusComponent={RowStatus}
>
- {isEmpty(files) && loadingStatus !== RequestStatus.IN_PROGRESS ? (
+ {permissions.canCreateFiles && isEmpty(files) && loadingStatus !== RequestStatus.IN_PROGRESS ? (
{
const lockFile = () => {
const { locked, id } = original;
@@ -49,6 +53,7 @@ const GalleryCard = ({
},
}])}
openDeleteConfirmation={() => handleOpenDeleteConfirmation([{ original }])}
+ permissions={permissions}
/>
)}
@@ -105,6 +110,10 @@ GalleryCard.propTypes = {
handleOpenFileInfo: PropTypes.func.isRequired,
thumbnailPreview: PropTypes.func.isRequired,
fileType: PropTypes.string.isRequired,
+ permissions: PropTypes.shape({
+ canEditFiles: PropTypes.bool,
+ canDeleteFiles: PropTypes.bool,
+ }),
};
export default GalleryCard;
diff --git a/src/files-and-videos/generic/table-components/TableActions.jsx b/src/files-and-videos/generic/table-components/TableActions.jsx
index 8323aa3d8f..a49c0710f2 100644
--- a/src/files-and-videos/generic/table-components/TableActions.jsx
+++ b/src/files-and-videos/generic/table-components/TableActions.jsx
@@ -22,6 +22,10 @@ const TableActions = ({
encodingsDownloadUrl,
fileType,
setInitialState,
+ permissions = {
+ canCreateFiles: true,
+ canDeleteFiles: true,
+ },
}) => {
const intl = useIntl();
const [isSortOpen, openSort, closeSort] = useToggle(false);
@@ -65,19 +69,26 @@ const TableActions = ({
>
-
- handleOpenDeleteConfirmation(selectedFlatRows)}
- disabled={isEmpty(selectedFlatRows)}
- >
-
-
+ {permissions.canDeleteFiles
+ && (
+ <>
+
+ handleOpenDeleteConfirmation(selectedFlatRows)}
+ disabled={isEmpty(selectedFlatRows)}
+ >
+
+
+ >
+ )}
-
+ { permissions.canCreateFiles && (
+
+ )}
>
);
@@ -109,6 +120,10 @@ TableActions.propTypes = {
handleSort: PropTypes.func.isRequired,
fileType: PropTypes.string.isRequired,
setInitialState: PropTypes.func.isRequired,
+ permissions: PropTypes.shape({
+ canCreateFiles: PropTypes.bool,
+ canDeleteFiles: PropTypes.bool,
+ }),
};
TableActions.defaultProps = {
diff --git a/src/files-and-videos/generic/table-components/TableActions.test.jsx b/src/files-and-videos/generic/table-components/TableActions.test.jsx
index 803987f36c..e83284481f 100644
--- a/src/files-and-videos/generic/table-components/TableActions.test.jsx
+++ b/src/files-and-videos/generic/table-components/TableActions.test.jsx
@@ -1,5 +1,6 @@
import React from 'react';
import { screen, fireEvent } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
import { DataTableContext } from '@openedx/paragon';
import { initializeMocks, render } from '../../../testUtils';
import TableActions from './TableActions';
@@ -132,4 +133,48 @@ describe('TableActions', () => {
fireEvent.click(screen.getByRole('button', { name: messages.actionsButtonLabel.defaultMessage }));
expect(screen.getByRole('link', { name: messages.downloadEncodingsTitle.defaultMessage })).toHaveAttribute('href', expect.stringContaining(encodingsDownloadUrl));
});
+
+ test('does not render delete menu item when canDeleteFiles permission is false', async () => {
+ const user = userEvent.setup();
+ const permissions = {
+ canCreateFiles: true,
+ canDeleteFiles: false,
+ };
+
+ renderWithContext({
+ permissions,
+ selectedFlatRows: [{ original: { id: '1', displayName: 'Video 1', wrapperType: 'video' } }],
+ });
+
+ await user.click(screen.getByRole('button', { name: messages.actionsButtonLabel.defaultMessage }));
+
+ expect(screen.queryByText(messages.deleteTitle.defaultMessage)).not.toBeInTheDocument();
+ expect(screen.getByText(messages.downloadTitle.defaultMessage)).toBeInTheDocument();
+ });
+
+ test('does not render create button when canEditFiles permission is false', () => {
+ const permissions = {
+ canCreateFiles: true,
+ canDeleteFiles: false,
+ };
+
+ renderWithContext({
+ permissions,
+ selectedFlatRows: [{ original: { id: '1', displayName: 'Video 1', wrapperType: 'video' } }],
+ });
+
+ expect(screen.getByRole('button', { name: /Add videos/ })).toBeInTheDocument();
+ });
+
+ test('renders add videos button and delete menu item with permissions defaults', async () => {
+ const user = userEvent.setup();
+ renderWithContext({
+ selectedFlatRows: [{ original: { id: '1', displayName: 'Video 1', wrapperType: 'video' } }],
+ });
+
+ expect(screen.getByRole('button', { name: /Add videos/ })).toBeInTheDocument();
+ await user.click(screen.getByRole('button', { name: messages.actionsButtonLabel.defaultMessage }));
+
+ expect(screen.queryByText(messages.deleteTitle.defaultMessage)).toBeInTheDocument();
+ });
});
diff --git a/src/files-and-videos/generic/table-components/table-custom-columns/MoreInfoColumn.jsx b/src/files-and-videos/generic/table-components/table-custom-columns/MoreInfoColumn.jsx
index c389ec5fd0..8d10310729 100644
--- a/src/files-and-videos/generic/table-components/table-custom-columns/MoreInfoColumn.jsx
+++ b/src/files-and-videos/generic/table-components/table-custom-columns/MoreInfoColumn.jsx
@@ -21,6 +21,10 @@ const MoreInfoColumn = ({
handleOpenFileInfo,
handleOpenDeleteConfirmation,
fileType,
+ permissions = {
+ canEditFiles: true,
+ caneDeleteFiles: true,
+ },
}) => {
const intl = useIntl();
const [isOpen, , close, toggle] = useToggle();
@@ -89,13 +93,15 @@ const MoreInfoColumn = ({
>
{intl.formatMessage(messages.copyWebUrlTitle)}
-
+ { permissions.canEditFiles && (
+
+ )}
>
)}
-
-
+
+ { permissions.caneDeleteFiles && (
+ <>
+
+
+ >
+ )}
>
@@ -146,6 +157,10 @@ MoreInfoColumn.propTypes = {
handleOpenFileInfo: PropTypes.func.isRequired,
handleOpenDeleteConfirmation: PropTypes.func.isRequired,
fileType: PropTypes.string.isRequired,
+ permissions: PropTypes.shape({
+ canEditFiles: PropTypes.bool,
+ canDeleteFiles: PropTypes.bool,
+ }),
};
MoreInfoColumn.defaultProps = {
diff --git a/src/header/hooks.test.tsx b/src/header/hooks.test.tsx
index b0a3de9ef5..7d8a1b3c14 100644
--- a/src/header/hooks.test.tsx
+++ b/src/header/hooks.test.tsx
@@ -51,7 +51,26 @@ const createWrapper = () => {
};
describe('header utils', () => {
+ beforeEach(() => {
+ mockWaffleFlags({
+ enableAuthzCourseAuthoring: false,
+ useNewVideoUploadsPage: false,
+ useNewCertificatesPage: false,
+ });
+ });
+
describe('getContentMenuItems', () => {
+ beforeEach(() => {
+ mockWaffleFlags({
+ enableAuthzCourseAuthoring: false,
+ useNewVideoUploadsPage: false,
+ useNewCertificatesPage: false,
+ });
+ jest.mocked(useUserPermissions).mockReturnValue({
+ isLoading: false,
+ data: { canViewCourseUpdates: false },
+ } as any);
+ });
it('when video upload page enabled should include Video Uploads option', () => {
jest.mocked(useSelector).mockReturnValue({
librariesV2Enabled: false,
@@ -81,6 +100,45 @@ describe('header utils', () => {
const actualItems = renderHook(() => useContentMenuItems('course-123'), { wrapper: createWrapper() }).result.current;
expect(actualItems[1]).toEqual({ href: '/course/course-123/libraries', title: 'Library Updates' });
});
+ it('when authz enabled and user has no permission to view files should not include files option', () => {
+ mockWaffleFlags({ enableAuthzCourseAuthoring: true });
+ jest.mocked(useUserPermissions).mockReturnValue({
+ isLoading: false,
+ data: { canViewFiles: false },
+ } as any);
+ jest.mocked(useSelector).mockReturnValue({
+ librariesV2Enabled: false,
+ });
+ const actualItems = renderHook(() => useContentMenuItems('course-123'), { wrapper: createWrapper() }).result.current;
+ const actualItemsTitle = actualItems.map((item) => item.title);
+ expect(actualItemsTitle).not.toContain(messages['header.links.filesAndUploads'].defaultMessage);
+ });
+ it('when authz enabled and user has permission to view files should include files option', () => {
+ mockWaffleFlags({ enableAuthzCourseAuthoring: true });
+ jest.mocked(useUserPermissions).mockReturnValue({
+ isLoading: false,
+ data: { canViewFiles: true },
+ } as any);
+ jest.mocked(useSelector).mockReturnValue({
+ librariesV2Enabled: false,
+ });
+ const actualItems = renderHook(() => useContentMenuItems('course-123'), { wrapper: createWrapper() }).result.current;
+ const actualItemsTitle = actualItems.map((item) => item.title);
+ expect(actualItemsTitle).toContain(messages['header.links.filesAndUploads'].defaultMessage);
+ });
+ it('when authz disabled user should view files option', () => {
+ mockWaffleFlags({ enableAuthzCourseAuthoring: false });
+ jest.mocked(useUserPermissions).mockReturnValue({
+ isLoading: false,
+ data: { canViewFiles: true },
+ } as any);
+ jest.mocked(useSelector).mockReturnValue({
+ librariesV2Enabled: false,
+ });
+ const actualItems = renderHook(() => useContentMenuItems('course-123'), { wrapper: createWrapper() }).result.current;
+ const actualItemsTitle = actualItems.map((item) => item.title);
+ expect(actualItemsTitle).toContain(messages['header.links.filesAndUploads'].defaultMessage);
+ });
});
describe('getSettingsMenuitems', () => {
diff --git a/src/header/hooks.tsx b/src/header/hooks.tsx
index 5c12bcfe9f..d6182270aa 100644
--- a/src/header/hooks.tsx
+++ b/src/header/hooks.tsx
@@ -10,6 +10,8 @@ import courseOptimizerMessages from '@src/optimizer-page/messages';
import { SidebarActions } from '@src/library-authoring/common/context/SidebarContext';
import { LibQueryParamKeys } from '@src/library-authoring/routes';
+import { useUserPermissionsWithAuthzCourse } from '@src/authz/hooks';
+import { getFilesPermissions } from '@src/authz/permissionHelpers';
import { useUserPermissions } from '@src/authz/data/apiHooks';
import { COURSE_PERMISSIONS } from '@src/authz/constants';
import messages from './messages';
@@ -17,9 +19,11 @@ import messages from './messages';
export const useContentMenuItems = (courseId: string) => {
const intl = useIntl();
const studioBaseUrl = getConfig().STUDIO_BASE_URL;
- const waffleFlags = useWaffleFlags();
+ const waffleFlags = useWaffleFlags(courseId);
const { librariesV2Enabled } = useSelector(getStudioHomeData);
+ const { permissions: { canViewFiles } } = useUserPermissionsWithAuthzCourse(courseId, getFilesPermissions(courseId));
+
const items = [
{
href: waffleFlags.useNewCourseOutlinePage ? `/course/${courseId}` : `${studioBaseUrl}/course/${courseId}`,
@@ -33,10 +37,12 @@ export const useContentMenuItems = (courseId: string) => {
href: getPagePath(courseId, 'true', 'tabs'),
title: intl.formatMessage(messages['header.links.pages']),
},
- {
- href: waffleFlags.useNewFilesUploadsPage ? `/course/${courseId}/assets` : `${studioBaseUrl}/assets/${courseId}`,
- title: intl.formatMessage(messages['header.links.filesAndUploads']),
- },
+ ...(canViewFiles
+ ? [{
+ href: waffleFlags.useNewFilesUploadsPage ? `/course/${courseId}/assets` : `${studioBaseUrl}/assets/${courseId}`,
+ title: intl.formatMessage(messages['header.links.filesAndUploads']),
+ }] : []
+ ),
];
if (getConfig().ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN === 'true' || waffleFlags.useNewVideoUploadsPage) {
items.push({