From d9d6a90a78ddb76d4c8ec106e0198698e942ac74 Mon Sep 17 00:00:00 2001 From: jacobo-dominguez-wgu Date: Fri, 13 Mar 2026 09:28:04 -0600 Subject: [PATCH] feat: adding permission validations from authz for files page for view, create, edit and delete --- src/authz/constants.ts | 5 + src/authz/hooks.test.tsx | 132 ++++++++++++++++++ src/authz/hooks.ts | 82 +++++++++++ src/authz/permissionHelpers.test.ts | 49 +++++++ src/authz/permissionHelpers.ts | 20 +++ .../files-page/CourseFilesTable.tsx | 12 ++ .../files-page/FileInfoModalSidebar.jsx | 3 + src/files-and-videos/files-page/FilesPage.jsx | 17 ++- .../files-page/FilesPage.test.jsx | 113 +++++++++++++++ src/files-and-videos/generic/FileMenu.jsx | 34 +++-- src/files-and-videos/generic/FileTable.jsx | 25 +++- .../generic/table-components/GalleryCard.jsx | 9 ++ .../generic/table-components/TableActions.jsx | 37 +++-- .../table-components/TableActions.test.jsx | 45 ++++++ .../table-custom-columns/MoreInfoColumn.jsx | 53 ++++--- src/header/hooks.test.tsx | 58 ++++++++ src/header/hooks.tsx | 16 ++- 17 files changed, 663 insertions(+), 47 deletions(-) create mode 100644 src/authz/hooks.test.tsx create mode 100644 src/authz/hooks.ts create mode 100644 src/authz/permissionHelpers.test.ts create mode 100644 src/authz/permissionHelpers.ts 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)} - handleLock(id, !locked)} - > - {locked ? intl.formatMessage(messages.unlockMenuTitle) : intl.formatMessage(messages.lockMenuTitle)} - + { permissions.canEditFiles && ( + handleLock(id, !locked)} + > + {locked ? intl.formatMessage(messages.unlockMenuTitle) : intl.formatMessage(messages.lockMenuTitle)} + + )} )} {intl.formatMessage(messages.infoTitle)} -
- { - handleOpenDeleteConfirmation([{ original: row.original }]); - close(); - }} - > - {intl.formatMessage(messages.deleteTitle)} - + + { permissions.caneDeleteFiles && ( + <> +
+ { + handleOpenDeleteConfirmation([{ original: row.original }]); + close(); + }} + > + {intl.formatMessage(messages.deleteTitle)} + + + )} @@ -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({