diff --git a/src/authz/constants.ts b/src/authz/constants.ts new file mode 100644 index 0000000000..b9b14bbbf0 --- /dev/null +++ b/src/authz/constants.ts @@ -0,0 +1,16 @@ +export const CONTENT_LIBRARY_PERMISSIONS = { + DELETE_LIBRARY: 'content_libraries.delete_library', + MANAGE_LIBRARY_TAGS: 'content_libraries.manage_library_tags', + VIEW_LIBRARY: 'content_libraries.view_library', + + EDIT_LIBRARY_CONTENT: 'content_libraries.edit_library_content', + PUBLISH_LIBRARY_CONTENT: 'content_libraries.publish_library_content', + REUSE_LIBRARY_CONTENT: 'content_libraries.reuse_library_content', + + CREATE_LIBRARY_COLLECTION: 'content_libraries.create_library_collection', + EDIT_LIBRARY_COLLECTION: 'content_libraries.edit_library_collection', + DELETE_LIBRARY_COLLECTION: 'content_libraries.delete_library_collection', + + MANAGE_LIBRARY_TEAM: 'content_libraries.manage_library_team', + VIEW_LIBRARY_TEAM: 'content_libraries.view_library_team', +}; diff --git a/src/authz/data/api.ts b/src/authz/data/api.ts new file mode 100644 index 0000000000..218689f71c --- /dev/null +++ b/src/authz/data/api.ts @@ -0,0 +1,10 @@ +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { PermissionValidationRequest, PermissionValidationResponse } from '@src/authz/types'; +import { getApiUrl } from './utils'; + +export const validateUserPermissions = async ( + validations: PermissionValidationRequest[], +): Promise => { + const { data } = await getAuthenticatedHttpClient().post(getApiUrl('/api/authz/v1/permissions/validate/me'), validations); + return data; +}; diff --git a/src/authz/data/apiHooks.test.tsx b/src/authz/data/apiHooks.test.tsx new file mode 100644 index 0000000000..5536bd4aaf --- /dev/null +++ b/src/authz/data/apiHooks.test.tsx @@ -0,0 +1,98 @@ +import { act, ReactNode } from 'react'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { useValidateUserPermissions } from './apiHooks'; + +jest.mock('@edx/frontend-platform/auth', () => ({ + getAuthenticatedHttpClient: jest.fn(), +})); + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + return wrapper; +}; + +const permissions = [ + { + action: 'act:read', + object: 'lib:test-lib', + scope: 'org:OpenedX', + }, +]; + +const mockValidPermissions = [ + { action: 'act:read', object: 'lib:test-lib', allowed: true }, +]; + +const mockInvalidPermissions = [ + { action: 'act:read', object: 'lib:test-lib', allowed: false }, +]; + +describe('useValidateUserPermissions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns allowed true when permissions are valid', async () => { + getAuthenticatedHttpClient.mockReturnValue({ + post: jest.fn().mockResolvedValueOnce({ data: mockValidPermissions }), + }); + + const { result } = renderHook(() => useValidateUserPermissions(permissions), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current).toBeDefined()); + await waitFor(() => expect(result.current.data).toBeDefined()); + + expect(getAuthenticatedHttpClient).toHaveBeenCalled(); + expect(result.current.data![0].allowed).toBe(true); + }); + + it('returns allowed false when permissions are invalid', async () => { + getAuthenticatedHttpClient.mockReturnValue({ + post: jest.fn().mockResolvedValue({ data: mockInvalidPermissions }), + }); + + const { result } = renderHook(() => useValidateUserPermissions(permissions), { + wrapper: createWrapper(), + }); + await waitFor(() => expect(result.current).toBeDefined()); + await waitFor(() => expect(result.current.data).toBeDefined()); + + expect(getAuthenticatedHttpClient).toHaveBeenCalled(); + expect(result.current.data![0].allowed).toBe(false); + }); + + it('handles error when the API call fails', async () => { + const mockError = new Error('API Error'); + + getAuthenticatedHttpClient.mockReturnValue({ + post: jest.fn().mockRejectedValue(new Error('API Error')), + }); + + try { + act(() => { + renderHook(() => useValidateUserPermissions(permissions), { + wrapper: createWrapper(), + }); + }); + } catch (error) { + expect(error).toEqual(mockError); // Check for the expected error + } + }); +}); diff --git a/src/authz/data/apiHooks.ts b/src/authz/data/apiHooks.ts new file mode 100644 index 0000000000..d4dbb32286 --- /dev/null +++ b/src/authz/data/apiHooks.ts @@ -0,0 +1,32 @@ +import { useQuery } from '@tanstack/react-query'; +import { PermissionValidationRequest, PermissionValidationResponse } from '@src/authz/types'; +import { validateUserPermissions } from './api'; + +const adminConsoleQueryKeys = { + all: ['authz'], + permissions: (permissions: PermissionValidationRequest[]) => [...adminConsoleQueryKeys.all, 'validatePermissions', permissions] as const, +}; + +/** + * React Query hook to validate if the current user has permissions over a certain object in the instance. + * It helps to: + * - Determine whether the current user can access certain object. + * - Provide role-based rendering logic for UI components. + * + * @param permissions - The array of objects and actions to validate. + * + * @example + * const { data } = useValidateUserPermissions([{ + "action": "act:read", + "scope": "org:OpenedX" + }]); + * if (data[0].allowed) { ... } + * + */ +export const useValidateUserPermissions = ( + permissions: PermissionValidationRequest[], +) => useQuery({ + queryKey: adminConsoleQueryKeys.permissions(permissions), + queryFn: () => validateUserPermissions(permissions), + retry: false, +}); diff --git a/src/authz/data/utils.ts b/src/authz/data/utils.ts new file mode 100644 index 0000000000..8676ba1abd --- /dev/null +++ b/src/authz/data/utils.ts @@ -0,0 +1,4 @@ +import { getConfig } from '@edx/frontend-platform'; + +export const getApiUrl = (path: string) => `${getConfig().LMS_BASE_URL}${path || ''}`; +export const getStudioApiUrl = (path: string) => `${getConfig().STUDIO_BASE_URL}${path || ''}`; diff --git a/src/authz/types.ts b/src/authz/types.ts new file mode 100644 index 0000000000..902df408f4 --- /dev/null +++ b/src/authz/types.ts @@ -0,0 +1,8 @@ +export interface PermissionValidationRequest { + action: string; + scope?: string; +} + +export interface PermissionValidationResponse extends PermissionValidationRequest { + allowed: boolean; +} diff --git a/src/library-authoring/common/context/LibraryContext.tsx b/src/library-authoring/common/context/LibraryContext.tsx index 13a38472fd..d948921ad3 100644 --- a/src/library-authoring/common/context/LibraryContext.tsx +++ b/src/library-authoring/common/context/LibraryContext.tsx @@ -7,6 +7,8 @@ import { useState, } from 'react'; import { useParams } from 'react-router-dom'; +import { useValidateUserPermissions } from '@src/authz/data/apiHooks'; +import { CONTENT_LIBRARY_PERMISSIONS } from '@src/authz/constants'; import { ContainerType } from '../../../generic/key-utils'; import type { ComponentPicker } from '../../component-picker'; @@ -14,6 +16,10 @@ import type { ContentLibrary, BlockTypeMetadata } from '../../data/api'; import { useContentLibrary } from '../../data/apiHooks'; import { useComponentPickerContext } from './ComponentPickerContext'; +const LIBRARY_PERMISSIONS = [ + CONTENT_LIBRARY_PERMISSIONS.PUBLISH_LIBRARY_CONTENT, +]; + export interface ComponentEditorInfo { usageKey: string; blockType?:string @@ -25,6 +31,7 @@ export type LibraryContextData = { libraryId: string; libraryData?: ContentLibrary; readOnly: boolean; + canPublish: boolean; isLoadingLibraryData: boolean; /** The ID of the current collection/container, on the sidebar OR page */ collectionId: string | undefined; @@ -107,6 +114,10 @@ export const LibraryProvider = ({ componentPickerMode, } = useComponentPickerContext(); + const permissions = LIBRARY_PERMISSIONS.map(action => ({ action, scope: libraryId })); + + const { isLoading: isLoadingUserPermissions, data: userPermissions } = useValidateUserPermissions(permissions); + const canPublish = userPermissions ? userPermissions[0]?.allowed : false; const readOnly = !!componentPickerMode || !libraryData?.canEditLibrary; // Parse the initial collectionId and/or container ID(s) from the current URL params @@ -131,7 +142,8 @@ export const LibraryProvider = ({ containerId, setContainerId, readOnly, - isLoadingLibraryData, + canPublish, + isLoadingLibraryData: isLoadingLibraryData || isLoadingUserPermissions, showOnlyPublished, extraFilter, isCreateCollectionModalOpen, @@ -154,7 +166,9 @@ export const LibraryProvider = ({ containerId, setContainerId, readOnly, + canPublish, isLoadingLibraryData, + isLoadingUserPermissions, showOnlyPublished, extraFilter, isCreateCollectionModalOpen, diff --git a/src/library-authoring/library-info/LibraryInfo.test.tsx b/src/library-authoring/library-info/LibraryInfo.test.tsx index 0971d5d051..abbbeaaa3b 100644 --- a/src/library-authoring/library-info/LibraryInfo.test.tsx +++ b/src/library-authoring/library-info/LibraryInfo.test.tsx @@ -8,6 +8,8 @@ import { waitFor, initializeMocks, } from '@src/testUtils'; +import { validateUserPermissions } from '@src/authz/data/api'; +import { CONTENT_LIBRARY_PERMISSIONS } from '@src/authz/constants'; import { mockContentLibrary } from '../data/api.mocks'; import { getCommitLibraryChangesUrl } from '../data/api'; import { LibraryProvider } from '../common/context/LibraryContext'; @@ -33,6 +35,7 @@ const render = (libraryId: string = mockLibraryId) => baseRender( let axiosMock: MockAdapter; let mockShowToast: (message: string) => void; +let validateUserPermissionsMock: jest.SpiedFunction; mockContentLibrary.applyMock(); @@ -41,6 +44,14 @@ describe('', () => { const mocks = initializeMocks(); axiosMock = mocks.axiosMock; mockShowToast = mocks.mockShowToast; + validateUserPermissionsMock = mocks.validateUserPermissionsMock; + + validateUserPermissionsMock.mockResolvedValue([ + { + action: CONTENT_LIBRARY_PERMISSIONS.PUBLISH_LIBRARY_CONTENT, + allowed: true, + }, + ]); }); afterEach(() => { diff --git a/src/library-authoring/library-info/LibraryPublishStatus.tsx b/src/library-authoring/library-info/LibraryPublishStatus.tsx index b046b858a6..c22d04f9ed 100644 --- a/src/library-authoring/library-info/LibraryPublishStatus.tsx +++ b/src/library-authoring/library-info/LibraryPublishStatus.tsx @@ -12,7 +12,7 @@ import messages from './messages'; const LibraryPublishStatus = () => { const intl = useIntl(); - const { libraryData, readOnly } = useLibraryContext(); + const { libraryData, readOnly, canPublish } = useLibraryContext(); const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useToggle(false); const commitLibraryChanges = useCommitLibraryChanges(); @@ -51,10 +51,10 @@ const LibraryPublishStatus = () => { <> ; /** To use this: `const { mockShowToast } = initializeMocks()` and `expect(mockShowToast).toHaveBeenCalled()` */ let mockToastContext: ToastContextData = { @@ -192,12 +194,17 @@ export function initializeMocks({ user = defaultUser, initialState = undefined } // Clear the call counts etc. of all mocks. This doesn't remove the mock's effects; just clears their history. jest.clearAllMocks(); + // Mock user permissions to avoid breaking tests that monitor axios calls + // If needed, override the mockResolvedValue in your test + validateUserPermissionsMock = jest.spyOn(authzApi, 'validateUserPermissions').mockResolvedValue([]); + return { reduxStore, axiosMock, mockShowToast: mockToastContext.showToast, mockToastAction: mockToastContext.toastAction, queryClient, + validateUserPermissionsMock, }; }