Skip to content
Open
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
12 changes: 11 additions & 1 deletion src/authz-module/audit-user/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
import AuthZLayout from '@src/authz-module/components/AuthZLayout';
import { useNavigate, useParams } from 'react-router-dom';
import { useUserAccount, useValidateUserPermissionsNonSuspense } from '@src/data/hooks';
import { LIBRARY_ROLE_KEYS } from '@src/authz-module/roles-permissions';
import { useViewTeamPermissions } from '@src/authz-module/hooks/useViewTeamPermissions';
import baseMessages from '@src/authz-module/messages';
import AddRoleButton from '@src/authz-module/components/AddRoleButton';
import {
Expand Down Expand Up @@ -42,9 +44,17 @@ const AuditUserPage = () => {
isLoading: isLoadingUser, data: user, isError: isErrorUser, error: errorUser,
} = useUserAccount(username);
const { querySettings, handleTableFetch } = useQuerySettings();

const { isCourseViewAllowed } = useViewTeamPermissions();

const effectiveQuerySettings = useMemo(() => {
if (isCourseViewAllowed || querySettings.roles) { return querySettings; }
return { ...querySettings, roles: LIBRARY_ROLE_KEYS };
}, [isCourseViewAllowed, querySettings]);

const {
isLoading: isLoadingUserAssignments, data: { results: userAssignments, count } = { results: [], count: 0 },
} = useUserAssignedRoles(username, querySettings);
} = useUserAssignedRoles(username, effectiveQuerySettings);
const [roleToDelete, setRoleToDelete] = useState<RoleToDelete | null>(null);
const [showConfirmDeletionModal, setShowConfirmDeletionModal] = useState(false);
const {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ describe('RolesFilter', () => {

it('shows no role options while permissions are still loading', async () => {
const user = userEvent.setup();
mockUsePermissions.mockReturnValue({ data: undefined });
mockUsePermissions.mockReturnValue({ data: undefined, isLoading: true });
renderWrapper(<RolesFilter {...defaultProps} />);
const menu = await openDropdown(user);
expect(menu.queryByText('Courses')).not.toBeInTheDocument();
Expand Down
30 changes: 9 additions & 21 deletions src/authz-module/components/TableControlBar/RolesFilter.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { useMemo } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Person } from '@openedx/paragon/icons';
import { useValidateUserPermissionsNonSuspense } from '@src/data/hooks';
import { CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS } from '@src/authz-module/roles-permissions';
import { useViewTeamPermissions } from '@src/authz-module/hooks/useViewTeamPermissions';
import { CONTEXT_TYPES } from '@src/authz-module/constants';
import MultipleChoiceFilter from './MultipleChoiceFilter';
import { MultipleChoiceFilterProps } from './types';
Expand All @@ -14,27 +13,16 @@ const RolesFilter = ({
filterButtonText, filterValue, setFilter, disabled,
}: RolesFilterProps) => {
const intl = useIntl();
const { data: permissions } = useValidateUserPermissionsNonSuspense([
{ action: CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM },
{ action: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM },
]);
const { isCourseViewAllowed, isLibraryViewAllowed, isLoading } = useViewTeamPermissions();

// Only show role groups for the domains the user can view. Global roles stay
// hidden until a platform-wide permission is available to gate them on.
const allowedContexts = useMemo(() => {
const contexts = new Set<string>();
permissions?.forEach((p) => {
if (!p.allowed) { return; }
if (p.action === CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM) { contexts.add(CONTEXT_TYPES.LIBRARY); }
if (p.action === CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM) { contexts.add(CONTEXT_TYPES.COURSE); }
const rolesOptions = useMemo(() => {
if (isLoading) { return []; }
return getRolesFiltersOptions(intl).filter((option) => {
if (option.contextType === CONTEXT_TYPES.COURSE) { return isCourseViewAllowed; }
if (option.contextType === CONTEXT_TYPES.LIBRARY) { return isLibraryViewAllowed; }
return false;
});
return contexts;
}, [permissions]);

const rolesOptions = useMemo(
() => getRolesFiltersOptions(intl).filter((option) => allowedContexts.has(option.contextType)),
[intl, allowedContexts],
);
}, [intl, isCourseViewAllowed, isLibraryViewAllowed, isLoading]);
return (
<MultipleChoiceFilter
filterButtonText={filterButtonText}
Expand Down
56 changes: 48 additions & 8 deletions src/authz-module/components/TableControlBar/ScopesFilter.test.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,47 @@
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWrapper } from '@src/setupTest';
import { useValidateUserPermissionsNonSuspense } from '@src/data/hooks';
import { useScopes } from '@src/authz-module/data/hooks';
import { CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS } from '@src/authz-module/roles-permissions';
import ScopesFilter from './ScopesFilter';

jest.mock('@src/data/hooks', () => ({
useValidateUserPermissionsNonSuspense: jest.fn(),
}));

const mockUsePermissions = useValidateUserPermissionsNonSuspense as jest.Mock;

jest.mock('@src/authz-module/data/hooks', () => ({
useScopes: () => ({
useScopes: jest.fn(() => ({
data: {
pages: [
{
results: [
{
externalKey: 'course:123',
name: 'Test Course',
organization: { name: 'Test Org' },
externalKey: 'course-v1:org+course+run',
displayName: 'Test Course',
org: { shortName: 'TestOrg' },
},
{
externalKey: 'library:456',
name: 'Test Library',
organization: { name: 'Another Org' },
externalKey: 'lib:org:library',
displayName: 'Test Library',
org: { shortName: 'TestOrg' },
},
],
},
],
},
}),
})),
}));

const mockUseScopes = useScopes as jest.Mock;

const permissionsData = ({ library, course }: { library?: boolean; course?: boolean }) => [
{ action: CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM, allowed: !!library },
{ action: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM, allowed: !!course },
];

describe('ScopesFilter', () => {
const defaultProps = {
filterButtonText: 'Scopes',
Expand All @@ -36,6 +52,7 @@ describe('ScopesFilter', () => {

beforeEach(() => {
jest.clearAllMocks();
mockUsePermissions.mockReturnValue({ data: permissionsData({ library: true, course: true }) });
});

it('renders without crashing', () => {
Expand Down Expand Up @@ -68,4 +85,27 @@ describe('ScopesFilter', () => {
renderWrapper(<ScopesFilter {...defaultProps} setFilter={mockSetFilter} />);
expect(screen.getByText('Scopes')).toBeInTheDocument();
});

it('fetches all scope types when the user can view courses', () => {
renderWrapper(<ScopesFilter {...defaultProps} />);
expect(mockUseScopes).toHaveBeenCalledWith(
expect.not.objectContaining({ scopeType: 'library' }),
);
});

it('fetches only library scopes when the user cannot view courses', () => {
mockUsePermissions.mockReturnValue({ data: permissionsData({ library: true, course: false }) });
renderWrapper(<ScopesFilter {...defaultProps} />);
expect(mockUseScopes).toHaveBeenCalledWith(
expect.objectContaining({ scopeType: 'library' }),
);
});

it('defaults to showing only library scopes while permissions are loading', () => {
mockUsePermissions.mockReturnValue({ data: undefined, isLoading: true });
renderWrapper(<ScopesFilter {...defaultProps} />);
expect(mockUseScopes).toHaveBeenCalledWith(
expect.objectContaining({ scopeType: 'library' }),
);
});
});
10 changes: 9 additions & 1 deletion src/authz-module/components/TableControlBar/ScopesFilter.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useMemo, useState } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { LocationOn } from '@openedx/paragon/icons';
import { useViewTeamPermissions } from '@src/authz-module/hooks/useViewTeamPermissions';
import { useScopes } from '@src/authz-module/data/hooks';
import { DEFAULT_FILTER_PAGE_SIZE } from '@src/authz-module/constants';
import { MultipleChoiceFilterProps } from './types';
Expand All @@ -15,7 +16,14 @@ const ScopesFilter = ({
}: ScopesFilterProps) => {
const { formatMessage } = useIntl();
const [searchValue, setSearchValue] = useState<string | undefined>(undefined);
const { data: scopesData } = useScopes({ search: searchValue, pageSize: DEFAULT_FILTER_PAGE_SIZE });

const { isCourseViewAllowed } = useViewTeamPermissions();

const { data: scopesData } = useScopes({
search: searchValue,
pageSize: DEFAULT_FILTER_PAGE_SIZE,
...(isCourseViewAllowed ? {} : { scopeType: 'library' }),
});

const filterChoices = useMemo(() => (scopesData?.pages?.flatMap((p) => p.results) ?? []).map((scope) => {
const scopeIcon = scope.externalKey?.startsWith('lib') ? RESOURCE_ICONS.LIBRARY : RESOURCE_ICONS.COURSE;
Expand Down
18 changes: 18 additions & 0 deletions src/authz-module/hooks/useViewTeamPermissions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { useValidateUserPermissionsNonSuspense } from '@src/data/hooks';
import {
CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS, VIEW_TEAM_PERMISSIONS,
} from '@src/authz-module/roles-permissions';

export const useViewTeamPermissions = () => {
const { data: permissions, isLoading } = useValidateUserPermissionsNonSuspense(VIEW_TEAM_PERMISSIONS);

const isCourseViewAllowed = permissions
? permissions.some((p) => p.action === CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM && p.allowed)
: false;

const isLibraryViewAllowed = permissions
? permissions.some((p) => p.action === CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM && p.allowed)
: false;

return { isCourseViewAllowed, isLibraryViewAllowed, isLoading };
};
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,26 @@ const allowAllPermissions = {
isLoading: false,
};

const mockPermissions = (
manageData: typeof allowAllPermissions,
{ courseViewAllowed = true } = {},
) => (permissions: { action: string }[]) => {
const isViewCall = permissions.some(
(p) => p.action === CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM
|| p.action === CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM,
);
if (isViewCall) {
return {
data: [
{ action: CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM, allowed: true },
{ action: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM, allowed: courseViewAllowed },
],
isLoading: false,
};
}
return manageData;
};

const setupMocks = ({ users = '', from = '' } = {}) => {
const { useSearchParams, useNavigate } = jest.requireMock('react-router-dom');
const params = new URLSearchParams();
Expand All @@ -73,7 +93,7 @@ describe('AssignRoleWizardPage', () => {
mutateAsync: jest.fn(),
isPending: false,
});
mockUseValidatePermissions.mockReturnValue(allowAllPermissions);
mockUseValidatePermissions.mockImplementation(mockPermissions(allowAllPermissions));
});

it('renders the page with the wizard and title', () => {
Expand Down Expand Up @@ -147,10 +167,10 @@ describe('AssignRoleWizardPage', () => {
});

it.each(scopeRoles)('shows only the roles for the allowed scope %#', ({ action, roles }) => {
mockUseValidatePermissions.mockReturnValue({
mockUseValidatePermissions.mockImplementation(mockPermissions({
data: scopeRoles.map((scope) => ({ action: scope.action, allowed: scope.action === action })),
isLoading: false,
});
}));
setupMocks();
renderPage();

Expand All @@ -165,10 +185,10 @@ describe('AssignRoleWizardPage', () => {
});

it('shows no roles when no scope is allowed', () => {
mockUseValidatePermissions.mockReturnValue({
mockUseValidatePermissions.mockImplementation(mockPermissions({
data: scopeRoles.map(({ action }) => ({ action, allowed: false })),
isLoading: false,
});
}));
setupMocks();
renderPage();
allRoles.forEach((role) => {
Expand All @@ -177,15 +197,29 @@ describe('AssignRoleWizardPage', () => {
});

it('ignores allowed permissions whose action is not a known role scope', () => {
mockUseValidatePermissions.mockReturnValue({
mockUseValidatePermissions.mockImplementation(mockPermissions({
data: [{ action: 'some.unrelated.permission', allowed: true }],
isLoading: false,
});
}));
setupMocks();
renderPage();
allRoles.forEach((role) => {
expect(screen.queryByText(role.name)).not.toBeInTheDocument();
});
});

it('hides course roles when VIEW_COURSE_TEAM is not allowed even if MANAGE_COURSE_TEAM is allowed', () => {
mockUseValidatePermissions.mockImplementation(
mockPermissions(allowAllPermissions, { courseViewAllowed: false }),
);
setupMocks();
renderPage();
courseRolesMetadata.forEach((role) => {
expect(screen.queryByText(role.name)).not.toBeInTheDocument();
});
libraryRolesMetadata.forEach((role) => {
expect(screen.getByText(role.name)).toBeInTheDocument();
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS, courseRolesMetadata, libraryRolesMetadata,
MANAGE_TEAM_PERMISSIONS,
} from '../roles-permissions';
import { useViewTeamPermissions } from '../hooks/useViewTeamPermissions';

const AssignRoleWizardPage = () => {
const intl = useIntl();
Expand All @@ -23,12 +24,15 @@ const AssignRoleWizardPage = () => {
? `${ROUTES.HOME_PATH}/user/${presetUser}`
: returnTo;

const { data: permissionValidationResponse } = useValidateUserPermissionsNonSuspense(MANAGE_TEAM_PERMISSIONS);
const { data: managePermissions } = useValidateUserPermissionsNonSuspense(MANAGE_TEAM_PERMISSIONS);
const { isCourseViewAllowed } = useViewTeamPermissions();

const rolesAssignable = permissionValidationResponse?.flatMap((p) => {
const rolesAssignable = managePermissions?.flatMap((p) => {
if (!p.allowed) { return []; }
if (p.action === CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM) { return libraryRolesMetadata; }
if (p.action === CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM) { return courseRolesMetadata; }
if (p.action === CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM) {
return isCourseViewAllowed ? courseRolesMetadata : [];
}
return [];
});

Expand Down
9 changes: 8 additions & 1 deletion src/authz-module/roles-permissions/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CONTENT_COURSE_PERMISSIONS } from './course/constants';
import { CONTENT_LIBRARY_PERMISSIONS } from './library/constants';
import { CONTENT_LIBRARY_PERMISSIONS, libraryRolesMetadata as _libraryRolesMetadata } from './library/constants';

export {
CONTENT_LIBRARY_PERMISSIONS,
Expand All @@ -9,6 +9,8 @@ export {
rolesLibraryObject,
} from './library/constants';

export const LIBRARY_ROLE_KEYS = _libraryRolesMetadata.map((r) => r.role).join(',');

export {
CONTENT_COURSE_PERMISSIONS,
courseResourceTypes,
Expand All @@ -21,3 +23,8 @@ export const MANAGE_TEAM_PERMISSIONS: { action: string }[] = [
{ action: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM },
{ action: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM },
];

export const VIEW_TEAM_PERMISSIONS: { action: string }[] = [
{ action: CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM },
{ action: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM },
];
Loading