Skip to content
16 changes: 16 additions & 0 deletions src/authz/constants.ts
Original file line number Diff line number Diff line change
@@ -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',
};
10 changes: 10 additions & 0 deletions src/authz/data/api.ts
Original file line number Diff line number Diff line change
@@ -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<PermissionValidationResponse[]> => {
const { data } = await getAuthenticatedHttpClient().post(getApiUrl('/api/authz/v1/permissions/validate/me'), validations);
return data;
};
98 changes: 98 additions & 0 deletions src/authz/data/apiHooks.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);

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
}
});
});
32 changes: 32 additions & 0 deletions src/authz/data/apiHooks.ts
Original file line number Diff line number Diff line change
@@ -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 = (
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is minor/optional feedback about the name of this hook:

To me, "validate user permissions" sounds like an action, like it would throw an exception if the user doesn't have some permissions. But this is just fetching some data, not making an action.

I think "useScopedPermissions" or just "useUserPermissions" or something like that would better reflect that this is just getting the user permissions, but you still have to validate/check that they're allowed or not yourself.

permissions: PermissionValidationRequest[],
) => useQuery<PermissionValidationResponse[], Error>({
queryKey: adminConsoleQueryKeys.permissions(permissions),
queryFn: () => validateUserPermissions(permissions),
retry: false,
});
4 changes: 4 additions & 0 deletions src/authz/data/utils.ts
Original file line number Diff line number Diff line change
@@ -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 || ''}`;
8 changes: 8 additions & 0 deletions src/authz/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export interface PermissionValidationRequest {
action: string;
scope?: string;
}

export interface PermissionValidationResponse extends PermissionValidationRequest {
allowed: boolean;
}
16 changes: 15 additions & 1 deletion src/library-authoring/common/context/LibraryContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,19 @@ 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';
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,
Comment on lines +19 to +20
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LIBRARY_PERMISSIONS and CONTENT_LIBRARY_PERMISSIONS are basically the same name, so it's not very clear how they are different.

];

export interface ComponentEditorInfo {
usageKey: string;
blockType?:string
Expand All @@ -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;
Expand Down Expand Up @@ -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;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something we could improve is being explicit about the action we are requesting instead of using userPermissions[0].

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to the ADR, the API guarantees that the order of the response will match the requested permissions, that's why I'm not trying to match it explicitly.

Copy link

@MaferMazu MaferMazu Nov 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't know about the order 🤔

The suggestion was more about readability and clarity regarding the permission I am requesting, and I still think it is important. Since that index depends on the order of the elements in LIBRARY_PERMISSIONS, if that list grows, I don't think it will be clear enough to use only the indexes.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1. Also, hard-coding 0 here means that this code would become wrong if someone else changed the order of the LIBRARY_PERMISSIONS constant. Which could definitely happen, and then there would be a security hole.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am thinking about this issue: openedx/openedx-authz#144. I haven't refined it yet, but I would probably need to add more params to the request to see other permissions.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would suggest an API like this (no change to the REST API or the internal arrays, just implement some helper logic in the hook to support this):

const possiblePermissions = {
  canPublish: CONTENT_LIBRARY_PERMISSIONS.PUBLISH_LIBRARY_CONTENT,
];

const {
  isLoading: isLoadingUserPermissions,
  data: userPermissions,
} = useScopedUserPermissions(possiblePermissions, { scope: libraryId });

// API is useScopedUserPermissions(actions object, extra fields to mix in);

const canPublish = userPermissions?.canPublish;
// or
const canPublish = userPermissions?.canPublish.allowed; // (this is more verbose, and requiring these creates security bugs whenever users forget to include `.allowed`, but if you know there will likely be other fields besides .allowed in the future, it's better to be more verbose now)

const readOnly = !!componentPickerMode || !libraryData?.canEditLibrary;

// Parse the initial collectionId and/or container ID(s) from the current URL params
Expand All @@ -131,7 +142,8 @@ export const LibraryProvider = ({
containerId,
setContainerId,
readOnly,
isLoadingLibraryData,
canPublish,
isLoadingLibraryData: isLoadingLibraryData || isLoadingUserPermissions,
showOnlyPublished,
extraFilter,
isCreateCollectionModalOpen,
Expand All @@ -154,7 +166,9 @@ export const LibraryProvider = ({
containerId,
setContainerId,
readOnly,
canPublish,
isLoadingLibraryData,
isLoadingUserPermissions,
showOnlyPublished,
extraFilter,
isCreateCollectionModalOpen,
Expand Down
11 changes: 11 additions & 0 deletions src/library-authoring/library-info/LibraryInfo.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -33,6 +35,7 @@ const render = (libraryId: string = mockLibraryId) => baseRender(<LibraryInfo />

let axiosMock: MockAdapter;
let mockShowToast: (message: string) => void;
let validateUserPermissionsMock: jest.SpiedFunction<typeof validateUserPermissions>;

mockContentLibrary.applyMock();

Expand All @@ -41,6 +44,14 @@ describe('<LibraryInfo />', () => {
const mocks = initializeMocks();
axiosMock = mocks.axiosMock;
mockShowToast = mocks.mockShowToast;
validateUserPermissionsMock = mocks.validateUserPermissionsMock;

validateUserPermissionsMock.mockResolvedValue([
{
action: CONTENT_LIBRARY_PERMISSIONS.PUBLISH_LIBRARY_CONTENT,
allowed: true,
},
]);
});

afterEach(() => {
Expand Down
6 changes: 3 additions & 3 deletions src/library-authoring/library-info/LibraryPublishStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -51,10 +51,10 @@ const LibraryPublishStatus = () => {
<>
<StatusWidget
{...libraryData}
onCommit={!readOnly ? commit : undefined}
onCommit={!readOnly && canPublish ? commit : undefined}
onCommitStatus={commitLibraryChanges.status}
onCommitLabel={intl.formatMessage(messages.publishLibraryButtonLabel)}
onRevert={!readOnly ? openConfirmModal : undefined}
onRevert={!readOnly && canPublish ? openConfirmModal : undefined}
/>
<DeleteModal
isOpen={isConfirmModalOpen}
Expand Down
7 changes: 7 additions & 0 deletions src/testUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
Routes,
} from 'react-router-dom';

import * as authzApi from '@src/authz/data/api';
import { ToastContext, type ToastContextData } from './generic/toast-context';
import initializeReduxStore, { type DeprecatedReduxState } from './store';
import { getApiWaffleFlagsUrl } from './data/api';
Expand All @@ -31,6 +32,7 @@ import { getApiWaffleFlagsUrl } from './data/api';
let reduxStore: Store;
let queryClient: QueryClient;
let axiosMock: MockAdapter;
let validateUserPermissionsMock: jest.SpiedFunction<typeof authzApi.validateUserPermissions>;

/** To use this: `const { mockShowToast } = initializeMocks()` and `expect(mockShowToast).toHaveBeenCalled()` */
let mockToastContext: ToastContextData = {
Expand Down Expand Up @@ -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,
};
}

Expand Down