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
1 change: 1 addition & 0 deletions src/authz/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export const COURSE_PERMISSIONS = {
DELETE_FILES: 'courses.delete_files',
EDIT_FILES: 'courses.edit_files',

VIEW_LIBRARY_UPDATES: 'courses.view_library_updates',
MANAGE_LIBRARY_UPDATES: 'courses.manage_library_updates',

VIEW_COURSE_TEAM: 'courses.view_course_team',
Expand Down
6 changes: 5 additions & 1 deletion src/authz/permissionHelpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,10 +182,14 @@ describe('permissionHelpers', () => {
});

describe('getLibraryUpdatesPermissions', () => {
it('returns MANAGE_LIBRARY_UPDATES permission with the correct action and scope', () => {
it('returns library updates permissions with the correct actions and scope', () => {
const result = getLibraryUpdatesPermissions(courseId);

expect(result).toEqual({
canViewLibraryUpdates: {
action: COURSE_PERMISSIONS.VIEW_LIBRARY_UPDATES,
scope: courseId,
},
canManageLibraryUpdates: {
action: COURSE_PERMISSIONS.MANAGE_LIBRARY_UPDATES,
scope: courseId,
Expand Down
4 changes: 4 additions & 0 deletions src/authz/permissionHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ export const getCourseOutlinePermissions = (courseId: string) => ({
});

export const getLibraryUpdatesPermissions = (courseId: string) => ({
canViewLibraryUpdates: {
action: COURSE_PERMISSIONS.VIEW_LIBRARY_UPDATES,
scope: courseId,
},
canManageLibraryUpdates: {
action: COURSE_PERMISSIONS.MANAGE_LIBRARY_UPDATES,
scope: courseId,
Expand Down
120 changes: 107 additions & 13 deletions src/course-libraries/CourseLibraries.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import { mockContentSearchConfig } from '@src/search-manager/data/api.mock';
import { type ToastActionData } from '@src/generic/toast-context';
import { libraryBlockChangesUrl } from '@src/course-unit/data/api';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import { useCourseUserPermissions } from '@src/authz/hooks';
import { useUserPermissions } from '@src/authz/data/apiHooks';
import { mockWaffleFlags } from '@src/data/apiHooks.mock';
import { CourseLibraries } from './CourseLibraries';
import {
mockGetEntityLinks,
Expand Down Expand Up @@ -49,17 +50,25 @@ jest.mock('react-router-dom', () => ({
}],
}));

jest.mock('@src/authz/hooks', () => ({
useCourseUserPermissions: jest.fn(),
jest.mock('@src/authz/data/apiHooks', () => ({
useUserPermissions: jest.fn(),
}));

const mockPermissions = (overrides = {}) =>
jest.mocked(useCourseUserPermissions).mockReturnValue({
isLoading: false,
isAuthzEnabled: true,
canManageLibraryUpdates: true,
...overrides,
} as ReturnType<typeof useCourseUserPermissions>);
/**
* Set the permissions of the current user. `useCourseUserPermissions()` is built on top of
* `useUserPermissions()`, so mocking the latter covers both the course-level library update
* permissions and the per-library `view_library` check that each library card makes.
*/
const mockPermissions = ({ isLoading = false, ...permissions }: Record<string, boolean> = {}) =>
jest.mocked(useUserPermissions).mockReturnValue({
isLoading,
data: {
canViewLibraryUpdates: true,
canManageLibraryUpdates: true,
canViewLibrary: true,
...permissions,
},
} as unknown as ReturnType<typeof useUserPermissions>);

describe('<CourseLibraries />', () => {
beforeEach(() => {
Expand All @@ -68,6 +77,7 @@ describe('<CourseLibraries />', () => {
mockFetchIndexDocuments.applyMock();
localStorage.clear();
searchParamsGetMock.mockReturnValue('all');
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
mockPermissions();
});

Expand All @@ -93,15 +103,15 @@ describe('<CourseLibraries />', () => {
expect(emptyMsg).toBeInTheDocument();
});

it('shows PermissionDeniedAlert when user lacks manage library updates permission', async () => {
mockPermissions({ canManageLibraryUpdates: false });
it('shows PermissionDeniedAlert when user lacks view library updates permission', async () => {
mockPermissions({ canViewLibraryUpdates: false });
await renderCourseLibrariesPage();
expect(await screen.findByTestId('permissionDeniedAlert')).toBeInTheDocument();
expect(screen.queryByText('Libraries')).not.toBeInTheDocument();
});

it('shows a loading spinner while permissions are loading', async () => {
mockPermissions({ isLoading: true, canManageLibraryUpdates: false });
mockPermissions({ isLoading: true, canViewLibraryUpdates: false });
await renderCourseLibrariesPage();
expect(await screen.findByRole('status')).toBeInTheDocument();
expect(screen.queryByTestId('permissionDeniedAlert')).not.toBeInTheDocument();
Expand Down Expand Up @@ -206,6 +216,55 @@ describe('<CourseLibraries />', () => {

expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});

it('shows the read only out of sync alert when user lacks manage permission', async () => {
const user = userEvent.setup();
mockPermissions({ canViewLibraryUpdates: true, canManageLibraryUpdates: false });
await renderCourseLibrariesPage(mockGetEntityLinks.courseKey);
const allTab = await screen.findByRole('tab', { name: 'Libraries' });
await user.click(allTab);
const alert = await screen.findByRole('alert');
expect(
await within(alert).findByText(
'7 library components are out of sync. Review updates to see what changed',
),
).toBeInTheDocument();
expect(
within(alert).queryByText(
'7 library components are out of sync. Review updates to accept or ignore changes',
),
).not.toBeInTheDocument();
});

it('does not show Review Updates button when user lacks manage permission', async () => {
const user = userEvent.setup();
mockPermissions({ canViewLibraryUpdates: true, canManageLibraryUpdates: false });
await renderCourseLibrariesPage(mockGetEntityLinks.courseKey);
const allTab = await screen.findByRole('tab', { name: 'Libraries' });
await user.click(allTab);
expect(screen.queryByRole('button', { name: 'Review Updates' })).not.toBeInTheDocument();
});

it('shows a View Library link on each card when the user can view the library', async () => {
const user = userEvent.setup();
await renderCourseLibrariesPage(mockGetEntityLinks.courseKey);
await user.click(await screen.findByRole('tab', { name: 'Libraries' }));

const links = await screen.findAllByRole('link', { name: 'View Library' });
expect(links.length).toEqual(3);
expect(links[0]).toHaveAttribute('href', expect.stringContaining('library/lib:OpenedX:CSPROB3'));
});

it('does not show the View Library link when user lacks view library permission', async () => {
const user = userEvent.setup();
mockPermissions({ canViewLibrary: false });
await renderCourseLibrariesPage(mockGetEntityLinks.courseKey);
await user.click(await screen.findByRole('tab', { name: 'Libraries' }));

// The libraries are still listed, they just don't link to the library.
expect(await screen.findByText('CS problems 3')).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'View Library' })).not.toBeInTheDocument();
});
});

describe('<CourseLibraries ReviewTab />', () => {
Expand All @@ -218,6 +277,8 @@ describe('<CourseLibraries ReviewTab />', () => {
localStorage.clear();
searchParamsGetMock.mockReturnValue('review');
queryClient = mocks.queryClient;
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
mockPermissions();
});

const renderCourseLibrariesReviewPage = async (courseKey?: string) => {
Expand Down Expand Up @@ -250,6 +311,39 @@ describe('<CourseLibraries ReviewTab />', () => {
expect(ignoreBtns.length).toEqual(7);
});

it('hides update and ignore buttons when user lacks manage permission', async () => {
mockPermissions({ canViewLibraryUpdates: true, canManageLibraryUpdates: false });
await renderCourseLibrariesReviewPage();

const updateBtns = screen.queryAllByRole('button', { name: 'Update' });
expect(updateBtns.length).toEqual(0);
const ignoreBtns = screen.queryAllByRole('button', { name: 'Ignore' });
expect(ignoreBtns.length).toEqual(0);

const reviewBtns = await screen.findAllByRole('button', { name: 'Review Updates' });
expect(reviewBtns.length).toEqual(7);
});

it('disables accept and ignore changes buttons in preview modal for read-only users', async () => {
const user = userEvent.setup();
mockPermissions({ canViewLibraryUpdates: true, canManageLibraryUpdates: false });
await renderCourseLibrariesReviewPage();
const readOnlyMessage =
'Your role doesn\'t include permission to do this. Contact your org admin to request access';

const previewBtns = await screen.findAllByRole('button', { name: 'Review Updates' });
expect(previewBtns.length).toEqual(7);
await user.click(previewBtns[0]);
const dialog = await screen.findByRole('dialog');
const acceptBtn = await within(dialog).findByRole('button', { name: 'Accept changes' });
expect(acceptBtn).toHaveAttribute('aria-disabled', 'true');
const ignoreBtn = await within(dialog).findByRole('button', { name: 'Ignore changes' });
expect(ignoreBtn).toBeDisabled();

await user.hover(acceptBtn.closest('span') ?? acceptBtn);
expect(await screen.findByText(readOnlyMessage)).toBeInTheDocument();
});

test.each([
{
label: 'update changes works with components',
Expand Down
43 changes: 29 additions & 14 deletions src/course-libraries/CourseLibraries.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import sumBy from 'lodash/sumBy';
import { useSearchParams } from 'react-router-dom';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { useCourseUserPermissions } from '@src/authz/hooks';
import { useUserPermissions } from '@src/authz/data/apiHooks';
import { getLibraryUpdatesPermissions } from '@src/authz/permissionHelpers';
import { useStudioHome } from '@src/studio-home/hooks';
import NewsstandIcon from '@src/generic/NewsstandIcon';
Expand All @@ -43,6 +44,7 @@ import type { PublishableEntityLinkSummary } from './data/api';
import ReviewTabContent from './ReviewTabContent';
import { OutOfSyncAlert } from './OutOfSyncAlert';
import LegacyLibContentBlockAlert from './LegacyLibContentBlockAlert';
import { CONTENT_LIBRARY_PERMISSIONS } from '@src/authz/constants';

interface LibraryCardProps {
linkSummary: PublishableEntityLinkSummary;
Expand All @@ -54,8 +56,12 @@ export enum CourseLibraryTabs {
}

const LibraryCard = ({ linkSummary }: LibraryCardProps) => {
const intl = useIntl();

const { data: libraryPermissions } = useUserPermissions({
canViewLibrary: {
action: CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY,
scope: linkSummary.upstreamContextKey,
},
}, !!linkSummary.upstreamContextKey);
return (
<Card className="my-3 border-light-500 border shadow-none">
<Card.Header
Expand All @@ -65,7 +71,7 @@ const LibraryCard = ({ linkSummary }: LibraryCardProps) => {
{linkSummary.upstreamContextTitle}
</Stack>
}
actions={
actions={libraryPermissions?.canViewLibrary && (
<ActionRow>
<Button
destination={`${getConfig().PUBLIC_PATH}library/${linkSummary.upstreamContextKey}`}
Expand All @@ -80,7 +86,7 @@ const LibraryCard = ({ linkSummary }: LibraryCardProps) => {
View Library
</Button>
</ActionRow>
}
)}
size="sm"
/>
<Card.Section>
Expand All @@ -90,13 +96,19 @@ const LibraryCard = ({ linkSummary }: LibraryCardProps) => {
className="x-small"
>
<span>
{intl.formatMessage(messages.totalComponentLabel, { totalComponents: linkSummary.totalCount })}
<FormattedMessage
{...messages.totalComponentLabel}
values={{ totalComponents: linkSummary.totalCount }}
/>
</span>
{linkSummary.readyToSyncCount > 0 && (
<Stack direction="horizontal" gap={1}>
<Icon src={Loop} size="xs" />
<span>
{intl.formatMessage(messages.outOfSyncCountLabel, { outOfSyncCount: linkSummary.readyToSyncCount })}
<FormattedMessage
{...messages.outOfSyncCountLabel}
values={{ outOfSyncCount: linkSummary.readyToSyncCount }}
/>
</span>
</Stack>
)}
Expand Down Expand Up @@ -124,6 +136,7 @@ export const CourseLibraries = () => {

const {
isLoading: isLoadingUserPermissions,
canViewLibraryUpdates,
canManageLibraryUpdates,
} = useCourseUserPermissions(courseId, getLibraryUpdatesPermissions(courseId));

Expand Down Expand Up @@ -191,21 +204,21 @@ export const CourseLibraries = () => {
</Stack>
);
}
return <ReviewTabContent courseId={courseId} />;
}, [outOfSyncCount, isLoading, tabKey]);
return <ReviewTabContent courseId={courseId} readOnly={!canManageLibraryUpdates} />;
}, [canManageLibraryUpdates, outOfSyncCount, isLoading, tabKey]);

if (isLoadingUserPermissions) {
return <Loading />;
}

if (!canManageLibraryUpdates) {
if (!canViewLibraryUpdates) {
return <PermissionDeniedAlert />;
}

if (!isLoadingStudioHome && (!librariesV2Enabled || isFailedLoadingStudioHome)) {
return (
<Alert variant="danger">
{intl.formatMessage(messages.librariesV2DisabledError)}
<FormattedMessage {...messages.librariesV2DisabledError} />
</Alert>
);
}
Expand All @@ -223,18 +236,20 @@ export const CourseLibraries = () => {
onReview={onAlertReview}
showAlert={showReviewAlert && tabKey === CourseLibraryTabs.all}
setShowAlert={setShowReviewAlert}
readOnly={!canManageLibraryUpdates}
/>
<SubHeader
title={intl.formatMessage(messages.headingTitle)}
title={<FormattedMessage {...messages.headingTitle} />}
subtitle={intl.formatMessage(messages.headingSubtitle)}
headerActions={(!showReviewAlert && outOfSyncCount > 0 && tabKey === CourseLibraryTabs.all) ?
headerActions={(canManageLibraryUpdates && !showReviewAlert && outOfSyncCount > 0 &&
tabKey === CourseLibraryTabs.all) ?
(
<Button
variant="primary"
onClick={onAlertReview}
iconBefore={Cached}
>
{intl.formatMessage(messages.reviewUpdatesBtn)}
<FormattedMessage {...messages.reviewUpdatesBtn} />
</Button>
) :
null}
Expand All @@ -258,7 +273,7 @@ export const CourseLibraries = () => {
title={
<Stack direction="horizontal" gap={1}>
<Icon src={Loop} />
{intl.formatMessage(messages.reviewTabTitle)}
<FormattedMessage {...messages.reviewTabTitle} />
</Stack>
}
notification={outOfSyncCount}
Expand Down
15 changes: 11 additions & 4 deletions src/course-libraries/OutOfSyncAlert.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useEffect } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon';
import { Loop } from '@openedx/paragon/icons';
import AlertMessage from '../generic/alert-message';
Expand All @@ -12,6 +12,8 @@ interface OutOfSyncAlertProps {
courseId: string;
onDismiss?: () => void;
onReview: () => void;
/** When true, the user can view library updates but cannot accept or ignore them. */
readOnly: boolean;
}
/**
* Shows an alert when library components used in the current course were updated and the blocks
Expand All @@ -30,8 +32,8 @@ export const OutOfSyncAlert: React.FC<OutOfSyncAlertProps> = ({
courseId,
onDismiss,
onReview,
readOnly,
}) => {
const intl = useIntl();
const { data, isPending } = useEntityLinksSummaryByDownstreamContext(courseId);
const outOfSyncCount = data?.reduce((count, lib) => count + (lib.readyToSyncCount || 0), 0);
const lastPublishedDate = data?.map(lib => new Date(lib.lastPublishedAt || 0).getTime())
Expand Down Expand Up @@ -60,15 +62,20 @@ export const OutOfSyncAlert: React.FC<OutOfSyncAlertProps> = ({

return (
<AlertMessage
title={intl.formatMessage(messages.outOfSyncCountAlertTitle, { outOfSyncCount })}
title={
<FormattedMessage
{...(readOnly ? messages.outOfSyncCountAlertTitleReadOnly : messages.outOfSyncCountAlertTitle)}
values={{ outOfSyncCount }}
/>
}
dismissible
show={showAlert}
icon={Loop}
variant="info"
onClose={dismissAlert}
actions={[
<Button key="review-btn" onClick={onReview}>
{intl.formatMessage(messages.outOfSyncCountAlertReviewBtn)}
<FormattedMessage {...messages.outOfSyncCountAlertReviewBtn} />
</Button>,
]}
/>
Expand Down
Loading